diff --git a/.github/workflows/run-schema-crash-test.yml b/.github/workflows/run-schema-crash-test.yml new file mode 100644 index 000000000..a57a46b69 --- /dev/null +++ b/.github/workflows/run-schema-crash-test.yml @@ -0,0 +1,55 @@ +name: Schema Crash Test + +on: + push: + branches: ["main"] + paths: + - "src/fastmcp/utilities/json_schema_type.py" + - "src/fastmcp/utilities/json_schema.py" + - "src/fastmcp/utilities/openapi/**" + - "src/fastmcp/server/providers/openapi/**" + - "src/fastmcp/client/mixins/tools.py" + - "tests/utilities/json_schema_type/test_real_world_schemas.py" + - ".github/workflows/run-schema-crash-test.yml" + + pull_request: + paths: + - "src/fastmcp/utilities/json_schema_type.py" + - "src/fastmcp/utilities/json_schema.py" + - "src/fastmcp/utilities/openapi/**" + - "src/fastmcp/server/providers/openapi/**" + - "src/fastmcp/client/mixins/tools.py" + - "tests/utilities/json_schema_type/test_real_world_schemas.py" + - ".github/workflows/run-schema-crash-test.yml" + + workflow_dispatch: + +permissions: + contents: read + +jobs: + schema_crash_test: + name: "Real-world schema crash test (232K schemas)" + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync + + - name: Clone openapi-directory + run: git clone --depth 1 https://github.com/APIs-guru/openapi-directory.git /tmp/openapi-directory + + - name: Run schema crash test + env: + RUN_REAL_WORLD_SCHEMA_TEST: "1" + OPENAPI_DIRECTORY_PATH: /tmp/openapi-directory + run: uv run pytest tests/utilities/json_schema_type/test_real_world_schemas.py -m integration -v -n auto --timeout-method=thread diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5166a3b18..dbac1d9c2 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -87,7 +87,7 @@ jobs: resolution: locked - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "22" diff --git a/.github/workflows/update-sdk-docs.yml b/.github/workflows/update-sdk-docs.yml index db9b6fba4..c8fd96287 100644 --- a/.github/workflows/update-sdk-docs.yml +++ b/.github/workflows/update-sdk-docs.yml @@ -42,7 +42,7 @@ jobs: run: uv sync --python 3.12 - name: Install just - uses: extractions/setup-just@v3 + uses: extractions/setup-just@v4 - name: Generate SDK documentation run: just api-ref-all diff --git a/CLAUDE.md b/CLAUDE.md index ac80f2d25..3add49c73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,8 @@ When modifying MCP functionality, changes typically need to be applied across al - **Resource Templates** (`src/resources/`) - **Prompts** (`src/prompts/`) +**Before writing cross-component logic (dedupe, grouping, lookups, identity checks), read `FastMCPComponent` in `src/fastmcp/utilities/components.py`.** The base class defines the shared surface — `name`, `version`, `tags`, `meta`, and critically the `key` property which is the canonical MCP identity (encodes type, identifier, and version). Prefer `item.key` over ad-hoc `name or uri or uri_template` fallbacks; overrides in `Resource` and `ResourceTemplate` already handle URI-based identity, and `.key` includes the version suffix so variants of the same component don't falsely collide. + ## Development Rules **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. @@ -63,6 +65,8 @@ When modifying MCP functionality, changes typically need to be applied across al - **NEVER** force-push on collaborative repos - **ALWAYS** run prek before PRs - **NEVER** create a release, comment on an issue, or open a PR unless specifically instructed to do so. +- **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. ### Releases @@ -82,6 +86,16 @@ The handwritten notes are prepended above the auto-generated changelog and are t **Before drafting, always read recent existing releases** (`gh release list` then `gh release view `) to absorb the voice, structure, and level of detail. Each release builds on the tone of previous ones — don't guess at the style from these instructions alone. +**To preview what PRs will be in the release** before it's cut, call the GitHub generate-notes API. This returns the exact auto-generated changelog that `--generate-notes` would append, so you can see the full PR list — useful for picking a pun theme and making sure nothing's been missed: + +```bash +gh api -X POST repos/PrefectHQ/fastmcp/releases/generate-notes \ + -f tag_name=v3.2.3 \ + -f target_commitish=main \ + -f previous_tag_name=v3.2.2 \ + --jq '.body' +``` + **Point releases** (3.0, 3.1, 3.2) get narrative prose: open with the theme of the release, then walk through headline features conceptually — what they enable, why they matter, how they fit together. Write it the way a blog post reads, not a changelog. Multiple paragraphs, code examples where they clarify. **Patch releases** (3.1.1, 3.0.2) get 1-2 sentences explaining what broke and what the fix does. Keep it minimal — the auto-generated changelog has the details. diff --git a/docs/apps/architecture.mdx b/docs/apps/architecture.mdx index ee666568f..26588c2f8 100644 --- a/docs/apps/architecture.mdx +++ b/docs/apps/architecture.mdx @@ -10,7 +10,7 @@ import { VersionBadge } from '/snippets/version-badge.mdx' -This page explains the internal architecture of Prefab apps — how your Python code becomes an interactive UI inside a host client's conversation. If you're building [custom HTML apps](/apps/low-level), the pipeline is simpler and covered on that page. You don't need to understand any of this to build Prefab apps, but the mental model is useful when you're debugging, extending, or contributing. +This page explains how Prefab apps work under the hood — how your Python code becomes an interactive UI inside a host client's conversation. You don't need any of this to build apps, but the mental model is useful when something isn't rendering the way you expect, when tool calls from the UI aren't reaching your server, or when you're building [custom HTML apps](/apps/low-level) and need to understand the protocol directly. ## The Pipeline diff --git a/docs/apps/examples.mdx b/docs/apps/examples.mdx new file mode 100644 index 000000000..024808f5d --- /dev/null +++ b/docs/apps/examples.mdx @@ -0,0 +1,140 @@ +--- +title: Examples +sidebarTitle: Examples +description: Example apps you can run right now. +icon: images +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +Every example below is a working FastMCP server you can run with `fastmcp dev apps` or connect to from any MCP host. The source is in `examples/apps/` in the repository. + + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +## Running Examples + +Preview any example in your browser with the dev server: + +```bash +pip install "fastmcp[apps]" +fastmcp dev apps examples/apps/sales_dashboard/sales_dashboard_server.py +``` + +The dev server opens an interactive browser UI where you can select a tool and provide arguments. In a real deployment, the LLM provides these arguments on the fly based on the conversation. For example, the quiz example works best when connected to an MCP host like Goose or Claude Desktop, where the LLM generates the questions itself. + +## Standalone Examples + +### Sales Dashboard + +A full dashboard with KPI metrics, revenue trends, segment breakdown, and a deal pipeline table. Shows what you can build with a single `app=True` tool and Prefab's chart and data components. + +```bash +fastmcp dev apps examples/apps/sales_dashboard/sales_dashboard_server.py +``` + +### System Monitor + +Reads live CPU, memory, and disk stats from your machine using `psutil`. Auto-refreshes via `SetInterval` calling a backend tool, with a dropdown to control the refresh rate. The chart accumulates 100 data points over time. + +```bash +pip install psutil +fastmcp dev apps examples/apps/system_monitor/system_monitor_server.py +``` + +### Quiz + +The LLM generates trivia questions and passes them to the tool. The user answers via buttons, sees correct/incorrect feedback, and tracks score across questions. Demonstrates multi-turn client-side state with FastMCPApp. + +```bash +fastmcp dev apps examples/apps/quiz/quiz_server.py +``` + +### Interactive Map + +Accepts addresses or place names, geocodes them via OpenStreetMap Nominatim (free, no API key), and renders an interactive Leaflet map using Prefab's `Embed` component with inline HTML. Proves that Prefab apps aren't limited to built-in components. + +```bash +fastmcp dev apps examples/apps/map/map_server.py +``` + +## Built-in Providers + +These are ready-made capabilities you add with a single `add_provider()` call. + +### [File Upload](/apps/providers/file-upload) + +Drag-and-drop file upload. The user drops files, clicks Upload, and the server stores them. The LLM can list and read uploaded files through model-visible tools. + +```python +from fastmcp.apps.file_upload import FileUpload +mcp.add_provider(FileUpload()) +``` + +### [Approval](/apps/providers/approval) + +Human-in-the-loop confirmation. The LLM presents what it's about to do, the user clicks Approve or Reject, and the decision flows back as a message. + +```python +from fastmcp.apps.approval import Approval +mcp.add_provider(Approval()) +``` + +### [Choice](/apps/providers/choice) + +Present clickable options instead of asking users to type. Clean structured input without parsing free text. + +```python +from fastmcp.apps.choice import Choice +mcp.add_provider(Choice()) +``` + +### [Form Input](/apps/providers/form) + +Generate a validated form from a Pydantic model. Submission is validated against the model before being returned. + +```python +from fastmcp.apps.form import FormInput +mcp.add_provider(FormInput(model=MyModel)) +``` + +### [Generative UI](/apps/providers/generative) + +The LLM writes Prefab Python code at runtime and the result renders as a streaming interactive UI. Tailored visualizations for any data. See the [full guide](/apps/generative) for details. + +```python +from fastmcp.apps.generative import GenerativeUI +mcp.add_provider(GenerativeUI()) +``` diff --git a/docs/apps/generative.mdx b/docs/apps/generative.mdx index 4046acd0f..86d306dd7 100644 --- a/docs/apps/generative.mdx +++ b/docs/apps/generative.mdx @@ -121,8 +121,13 @@ Generative UI requires `fastmcp[apps]` which installs `prefab-ui`. The Pyodide s The streaming renderer loads Pyodide from CDN in the browser. The CSP is configured automatically by the provider — no manual setup needed. +## Sandbox Limitations + +The Pyodide sandbox includes the Python standard library and Prefab. External packages (NumPy, pandas, requests, etc.) are **not available** — the LLM's code must work with only built-in Python and Prefab components. If the LLM tries to import an unavailable package, the sandbox will raise an `ImportError`. + ## Next Steps +- **[GenerativeUI Provider Reference](/apps/providers/generative)** — Configuration options and quick setup - **[Prefab UI](/apps/prefab)** — The component library and state system the LLM writes code against - **[Prefab Component Reference](https://prefab.prefect.io/docs/components)** — Full component library documentation - **[Development](/apps/development)** — Preview generative UI tools locally with `fastmcp dev apps` diff --git a/docs/apps/images/app-approval.png b/docs/apps/images/app-approval.png new file mode 100644 index 000000000..162f4847f Binary files /dev/null and b/docs/apps/images/app-approval.png differ diff --git a/docs/apps/images/app-choice.png b/docs/apps/images/app-choice.png new file mode 100644 index 000000000..178f6a2b0 Binary files /dev/null and b/docs/apps/images/app-choice.png differ diff --git a/docs/apps/images/app-example-map.png b/docs/apps/images/app-example-map.png new file mode 100644 index 000000000..5859c59c2 Binary files /dev/null and b/docs/apps/images/app-example-map.png differ diff --git a/docs/apps/images/app-example-quiz.png b/docs/apps/images/app-example-quiz.png new file mode 100644 index 000000000..b16bcaf43 Binary files /dev/null and b/docs/apps/images/app-example-quiz.png differ diff --git a/docs/apps/images/app-example-sales-dashboard.png b/docs/apps/images/app-example-sales-dashboard.png new file mode 100644 index 000000000..e0fe709a9 Binary files /dev/null and b/docs/apps/images/app-example-sales-dashboard.png differ diff --git a/docs/apps/images/app-example-system-dashboard.png b/docs/apps/images/app-example-system-dashboard.png new file mode 100644 index 000000000..7b85d7ac1 Binary files /dev/null and b/docs/apps/images/app-example-system-dashboard.png differ diff --git a/docs/apps/images/app-file-upload.png b/docs/apps/images/app-file-upload.png new file mode 100644 index 000000000..1178c09af Binary files /dev/null and b/docs/apps/images/app-file-upload.png differ diff --git a/docs/apps/images/app-form.png b/docs/apps/images/app-form.png new file mode 100644 index 000000000..30567e37e Binary files /dev/null and b/docs/apps/images/app-form.png differ diff --git a/docs/apps/images/app-quickstart-dev-2.png b/docs/apps/images/app-quickstart-dev-2.png new file mode 100644 index 000000000..f04d96d72 Binary files /dev/null and b/docs/apps/images/app-quickstart-dev-2.png differ diff --git a/docs/apps/images/app-quickstart-dev.png b/docs/apps/images/app-quickstart-dev.png new file mode 100644 index 000000000..d043f0ed3 Binary files /dev/null and b/docs/apps/images/app-quickstart-dev.png differ diff --git a/docs/apps/images/app-quickstart.png b/docs/apps/images/app-quickstart.png new file mode 100644 index 000000000..ddca745cf Binary files /dev/null and b/docs/apps/images/app-quickstart.png differ diff --git a/docs/apps/low-level.mdx b/docs/apps/low-level.mdx index 9ce86e3dd..adda74799 100644 --- a/docs/apps/low-level.mdx +++ b/docs/apps/low-level.mdx @@ -145,6 +145,8 @@ The `App` object provides: - **`app.onhostcontextchanged`** — callback for host context changes (e.g., safe area insets) - **`app.getHostContext()`** — get current host context +See the full [ext-apps SDK documentation](https://github.com/modelcontextprotocol/ext-apps) for the complete API reference. + If your HTML loads external scripts, styles, or makes API calls, you need to declare those domains in the CSP configuration. See [Security](#security) below. diff --git a/docs/apps/overview.mdx b/docs/apps/overview.mdx index c74482bd7..cb3c7c1ea 100644 --- a/docs/apps/overview.mdx +++ b/docs/apps/overview.mdx @@ -25,10 +25,28 @@ The examples throughout the Apps docs require the `apps` extra: pip install "fastmcp[apps]" ``` -This installs [Prefab UI](https://prefab.prefect.io), the component library used to build app UIs. Pin `prefab-ui` to a specific version in production — it's in early development and its API changes frequently. +This installs [Prefab UI](https://prefab.prefect.io), the component library used to build app UIs. -## Prefab Apps + +FastMCP pins a **minimum** version of `prefab-ui` for compatibility but intentionally does **not** pin an upper bound. Prefab is a rapidly evolving library with frequent breaking changes. If you are deploying to production, you **must** pin `prefab-ui` to a specific version in your own dependencies. Without a pin, a fresh deploy could pull a newer Prefab version that changes component APIs, breaking your app. + + +## Which Approach? + +Most apps start with **[Prefab Apps](/apps/prefab)** — add `app=True` to a tool and return components. That covers charts, tables, dashboards, and client-side interactivity. + +When your UI needs multiple backend tools with managed visibility and composition safety, use **[FastMCPApp](/apps/interactive-apps)**. + +When you want the LLM to design the UI at runtime, use **[Generative UI](/apps/generative)**. + +When you need your own HTML/JS (maps, 3D, video), use **[Custom HTML](/apps/low-level)**. + +FastMCP also includes ready-made **[app providers](/apps/providers/approval)** that add common capabilities with a single `add_provider()` call. + +## Building Apps + +### Prefab Apps @@ -68,7 +86,7 @@ Prefab apps aren't limited to static displays. Prefab's state system and client- See [Prefab Apps](/apps/prefab) for the full guide. -## FastMCPApp +### FastMCPApp @@ -134,7 +152,7 @@ You *can* build server-interactive UIs without `FastMCPApp` — it's all the sam See [FastMCPApp](/apps/interactive-apps) for the full guide. -## Generative UI +### Generative UI @@ -148,19 +166,9 @@ mcp = FastMCP("Prefab Studio") mcp.add_provider(GenerativeUI()) ``` -See [Generative UI](/apps/generative) for the full guide. +See [Generative UI](/apps/generative) for the full guide, or the [provider reference](/apps/providers/generative) for configuration options. -## Which Approach? - -Most apps start with **[Prefab Apps](/apps/prefab)** — add `app=True` to a tool and return components. That covers charts, tables, dashboards, and client-side interactivity. - -When your UI needs multiple backend tools with managed visibility and composition safety, use **[FastMCPApp](/apps/interactive-apps)**. - -When you want the LLM to design the UI at runtime, use **[Generative UI](/apps/generative)**. - -When you need your own HTML/JS (maps, 3D, video), use **[Custom HTML](/apps/low-level)**. - -## Custom HTML Apps +### Custom HTML All the approaches above use [Prefab UI](https://prefab.prefect.io) to build UIs in pure Python. If you need full control — your own HTML, CSS, JavaScript, a specific framework — you can use the [MCP Apps extension directly](/apps/low-level). You write the HTML yourself and communicate with the host via the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) SDK. diff --git a/docs/apps/prefab.mdx b/docs/apps/prefab.mdx index 0ee703f5e..156767869 100644 --- a/docs/apps/prefab.mdx +++ b/docs/apps/prefab.mdx @@ -10,9 +10,9 @@ import { VersionBadge } from '/snippets/version-badge.mdx' - -[Prefab](https://prefab.prefect.io) is in early, active development — its API changes frequently and breaking changes can occur with any release. Always pin `prefab-ui` to a specific version in your dependencies (see below). - + +[Prefab](https://prefab.prefect.io) is in early, active development — breaking changes can occur with any release. FastMCP pins a minimum version of `prefab-ui` for compatibility but does not pin an upper bound. If you are deploying to production, **pin `prefab-ui` to a specific version** in your own dependencies. + [Prefab UI](https://prefab.prefect.io) is the component library behind all FastMCP app features. You describe layouts, charts, tables, and forms in Python, and Prefab compiles them to interactive UIs that render in the host's conversation. diff --git a/docs/apps/providers/approval.mdx b/docs/apps/providers/approval.mdx new file mode 100644 index 000000000..15b683d15 --- /dev/null +++ b/docs/apps/providers/approval.mdx @@ -0,0 +1,80 @@ +--- +title: Approval +sidebarTitle: Approval +description: Human-in-the-loop approval gates for agent actions +icon: shield-check +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +`Approval` adds a human-in-the-loop confirmation step to any server. The LLM presents what it's about to do, the user approves or rejects via buttons, and the decision flows back into the conversation as a message. + + + The Approval provider shown in Goose, with a payment confirmation card and Approve/Cancel buttons + + +```python +from fastmcp import FastMCP +from fastmcp.apps.approval import Approval + +mcp = FastMCP("My Server") +mcp.add_provider(Approval()) +``` + +This registers a single tool: + +| Tool | Visibility | Purpose | +|------|-----------|---------| +| `request_approval` | Model | Shows an approval card, sends the user's decision back as a message | + +The LLM calls `request_approval` with a summary (and optional details) whenever it's about to take a significant action. The user sees a card with Approve and Reject buttons. Clicking either sends a message back into the conversation via `SendMessage`, which triggers the LLM's next turn. + +The message looks like it came from the user: + +``` +"Deploy v3.2 to production" — I selected: Approve +``` + + +Approval is an advisory gate, not an enforcement mechanism. The conversation isn't blocked while the card is open — the user can keep typing, and a determined LLM could proceed without waiting. Think of it as a strong UX signal that encourages confirmation, not a security boundary. For hard enforcement, implement approval logic server-side in your tool implementations. + + +## Configuration + +The constructor sets defaults; the LLM can override all of these per-call via tool arguments. + +```python +Approval( + name="Approval", # App name + title="Approval Required", # Card heading + approve_text="Approve", # Approve button label + reject_text="Reject", # Reject button label + approve_variant="default", # "default", "destructive", "success", "info" + reject_variant="outline", # same options plus "outline" +) +``` + +The LLM can customize each invocation: + +```python +request_approval( + summary="Delete 47 files from /tmp", + details="This cannot be undone.", + title="Destructive Action", + approve_text="Delete", + approve_variant="destructive", + reject_text="Keep files", +) +``` + +## How It Works + +When the user clicks a button, two things happen: + +1. `SendMessage` pushes the decision into the conversation as a user message +2. `SetState("decided", True)` replaces the buttons with "Response sent." + +The tool description instructs the LLM to stop and wait for the "I selected:" message before proceeding. If approved, it continues. If rejected, it acknowledges and asks how to proceed. diff --git a/docs/apps/providers/choice.mdx b/docs/apps/providers/choice.mdx new file mode 100644 index 000000000..71672a95c --- /dev/null +++ b/docs/apps/providers/choice.mdx @@ -0,0 +1,72 @@ +--- +title: Choice +sidebarTitle: Choice +description: Present clickable options instead of free-text responses +icon: list-check +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +`Choice` lets the LLM present a set of options as clickable buttons instead of asking the user to type a response. The selection flows back into the conversation as a message, giving the LLM clean structured input. + + + The Choice provider shown in Goose, with four lunch options as clickable buttons + + +```python +from fastmcp import FastMCP +from fastmcp.apps.choice import Choice + +mcp = FastMCP("My Server") +mcp.add_provider(Choice()) +``` + +This registers a single tool: + +| Tool | Visibility | Purpose | +|------|-----------|---------| +| `choose` | Model | Shows a card with clickable options, sends the selection back as a message | + +The LLM calls `choose` with a prompt and a list of options. The user sees a card with one button per option. Clicking one sends a message back into the conversation: + +``` +"Which deployment strategy?" — I selected: Blue-green +``` + + +This is an advisory interaction, not an enforcement mechanism. The conversation isn't blocked while the card is open — the user can keep typing, and the LLM could proceed without waiting. The tool description instructs the LLM to stop and wait for the "I selected:" response, but for hard enforcement, implement selection logic server-side. + + +## Configuration + +The constructor sets defaults; the LLM can override `title` per-call. + +```python +Choice( + name="Choice", # App name + title="Choose an Option", # Default card heading + variant="outline", # Button style for all options +) +``` + +The LLM provides the options per-call: + +```python +choose( + prompt="What should we have for lunch?", + options=["Pizza", "Tacos", "Ramen", "Salad"], + title="The Important Questions", +) +``` + +## How It Works + +Each option renders as a full-width button in a vertical stack. When the user clicks one: + +1. `SendMessage` pushes the selection into the conversation as a user message +2. `SetState("decided", True)` replaces the buttons with "Response sent." + +The tool description instructs the LLM to stop and wait for the "I selected:" message before proceeding with whatever the user chose. diff --git a/docs/apps/providers/file-upload.mdx b/docs/apps/providers/file-upload.mdx new file mode 100644 index 000000000..13cf2402e --- /dev/null +++ b/docs/apps/providers/file-upload.mdx @@ -0,0 +1,129 @@ +--- +title: File Upload +sidebarTitle: File Upload +description: Drag-and-drop file upload for any MCP server +icon: upload +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +`FileUpload` adds drag-and-drop file upload to any server. Users upload files through an interactive UI, bypassing the LLM context window entirely. The LLM can then list and read uploaded files through model-visible tools. + + + The FileUpload provider shown in Goose, with a drag-and-drop zone for uploading files + + +```python +from fastmcp import FastMCP +from fastmcp.apps.file_upload import FileUpload + +mcp = FastMCP("My Server") +mcp.add_provider(FileUpload()) +``` + +This registers four tools: + +| Tool | Visibility | Purpose | +|------|-----------|---------| +| `file_manager` | Model | Opens the drag-and-drop upload UI | +| `store_files` | App only | Called by the UI when the user clicks Upload | +| `list_files` | Model | Returns metadata for all uploaded files | +| `read_file` | Model | Returns a file's contents by name | + +The LLM sees `file_manager`, `list_files`, and `read_file`. It calls `file_manager` to show the upload interface, then uses `list_files` and `read_file` to work with whatever the user uploaded. `store_files` is app-only — the UI calls it directly and the LLM never needs to know about it. + +## Configuration + +```python +FileUpload( + name="Files", # App name (used in tool routing) + max_file_size=10 * 1024 * 1024, # 10 MB default, enforced server-side + title="File Upload", # Heading shown in the UI + description="Drop files to...", # Description text below the heading + drop_label="Drop files here", # Label inside the drop zone +) +``` + +The `max_file_size` limit is enforced both in the UI (the DropZone rejects oversized files) and on the server (the `store_files` tool validates before calling `on_store`). + +## Storage Scoping + +By default, files are stored in memory and scoped by MCP session ID. Each session gets its own isolated file store — files uploaded in one conversation aren't visible in another. + +This works with **stdio**, **SSE**, and **stateful HTTP** transports, where sessions persist across requests. + + +In **stateless HTTP** mode, each request creates a new session object with a new ID. Files stored during one request (e.g. the UI upload) will be invisible to the next request (e.g. the LLM calling `list_files`). You **must** override `_get_scope_key` to use a stable identifier like a user ID from your auth token. + + +For stateless deployments, override `_get_scope_key` to return a stable identifier. For example, to scope files by authenticated user: + +```python +from fastmcp.apps.file_upload import FileUpload + +class UserScopedUpload(FileUpload): + def _get_scope_key(self, ctx): + return ctx.access_token["sub"] +``` + +For process-wide shared storage (all users see all files): + +```python +class SharedUpload(FileUpload): + def _get_scope_key(self, ctx): + return "__shared__" +``` + +## Custom Storage + +The default implementation stores files in memory for the lifetime of the server process. For persistent storage, subclass `FileUpload` and override three methods. Each receives the current `Context`, giving you access to session IDs, auth tokens, and request metadata for partitioning and authorization. + +```python +import base64 + +from fastmcp.apps.file_upload import FileUpload + +class S3Upload(FileUpload): + def on_store(self, files, ctx): + user_id = ctx.access_token["sub"] + for f in files: + s3.put_object( + Bucket="uploads", + Key=f"{user_id}/{f['name']}", + Body=base64.b64decode(f["data"]), + ) + return self.on_list(ctx) + + def on_list(self, ctx): + user_id = ctx.access_token["sub"] + objects = s3.list_objects(Bucket="uploads", Prefix=f"{user_id}/") + return [ + { + "name": obj["Key"].split("/", 1)[1], + "type": "application/octet-stream", + "size": obj["Size"], + "size_display": f"{obj['Size']} B", + "uploaded_at": obj["LastModified"].isoformat(), + } + for obj in objects.get("Contents", []) + ] + + def on_read(self, name, ctx): + user_id = ctx.access_token["sub"] + obj = s3.get_object(Bucket="uploads", Key=f"{user_id}/{name}") + content = obj["Body"].read() + return { + "name": name, + "size": obj["ContentLength"], + "type": obj["ContentType"], + "uploaded_at": obj["LastModified"].isoformat(), + "content": content.decode("utf-8"), + } +``` + +Each file dict passed to `on_store` contains `name`, `size`, `type`, and `data` (base64-encoded content). The return value from `on_store` and `on_list` should be a list of summary dicts with `name`, `type`, `size`, `size_display`, and `uploaded_at` fields — these populate the file list in the UI. + +`on_read` returns a dict with file metadata and either `content` (decoded text) or `content_base64` (a base64 preview for binary files). diff --git a/docs/apps/providers/form.mdx b/docs/apps/providers/form.mdx new file mode 100644 index 000000000..ca598f33f --- /dev/null +++ b/docs/apps/providers/form.mdx @@ -0,0 +1,105 @@ +--- +title: Form Input +sidebarTitle: Form Input +description: Collect structured data from users via Pydantic models +icon: rectangle-list +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +`FormInput` generates a validated form from a Pydantic model. The user fills it out, and the submission is validated against the model before being returned. Structured elicitation that can't be hallucinated. + + + The FormInput provider shown in Goose, with a bug report form + + +```python +from typing import Literal + +from pydantic import BaseModel, Field +from fastmcp import FastMCP +from fastmcp.apps.form import FormInput + +class BugReport(BaseModel): + title: str = Field(description="Brief summary") + severity: Literal["low", "medium", "high", "critical"] + description: str = Field( + description="Detailed description", + json_schema_extra={"ui": {"type": "textarea"}}, + ) + +mcp = FastMCP("My Server") +mcp.add_provider(FormInput(model=BugReport)) +``` + +This registers two tools: + +| Tool | Visibility | Purpose | +|------|-----------|---------| +| `collect_bugreport` | Model | Opens the form UI | +| `submit_form` | App only | Validates and processes the submission | + +The tool name is derived from the model class name, lowercased: `collect_{modelname}`. So `BugReport` becomes `collect_bugreport`, `ShippingAddress` becomes `collect_shippingaddress`. Use `tool_name` to override if needed. The LLM calls it with a prompt explaining what it needs, and the user gets a form with fields matching the model. + +## Field Mapping + +`FormInput` uses Prefab's `Form.from_model()`, which maps Pydantic types to form components: + +| Python type | Form component | +|------------|---------------| +| `str` | Text input | +| `int`, `float` | Number input | +| `bool` | Checkbox | +| `datetime.date` | Date picker | +| `Literal[...]` | Select dropdown | +| `SecretStr` | Password input | + +Use `Field()` metadata to control labels (`title`), placeholders (`description`), and validation (`min_length`, `max_length`, `ge`, `le`). Use `json_schema_extra={"ui": {"type": "textarea"}}` for multiline text. + +## Callback + +By default, the validated model is returned as JSON. Provide an `on_submit` callback to process the data server-side: + +```python +def save_report(report: BugReport) -> str: + db.insert(report.model_dump()) + return f"Bug #{db.last_id} filed: {report.title}" + +mcp.add_provider(FormInput(model=BugReport, on_submit=save_report)) +``` + +The callback receives a validated model instance and returns a string that becomes the tool result. + +## Configuration + +```python +FormInput( + model=BugReport, # Required: the Pydantic model + name="BugTracker", # App name (default: model name) + title="File a Bug", # Card heading (default: model name) + tool_name="file_bug", # Tool name (default: collect_{model}) + submit_text="Submit Report", # Button label (default: "Submit") + on_submit=save_report, # Optional callback + send_message=True, # Push result as a chat message +) +``` + +Set `send_message=True` to push the result back into the conversation via `SendMessage`, triggering the LLM's next turn. Without it, the result is just the tool return value. + +## Multiple Forms + +Add multiple providers for different models — each gets its own tool: + +```python +mcp = FastMCP( + "My Server", + providers=[ + FormInput(model=ShippingAddress), + FormInput(model=BugReport), + FormInput(model=ContactInfo), + ], +) +``` diff --git a/docs/apps/providers/generative.mdx b/docs/apps/providers/generative.mdx new file mode 100644 index 000000000..a0795c939 --- /dev/null +++ b/docs/apps/providers/generative.mdx @@ -0,0 +1,74 @@ +--- +title: Generative UI +sidebarTitle: Generative UI +description: Let the LLM generate custom UIs at runtime +icon: wand-magic-sparkles +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +`GenerativeUI` lets the LLM write Prefab Python code at runtime and render it as a streaming interactive UI. Instead of calling pre-built tools with fixed interfaces, the model creates tailored visualizations for whatever data it's working with. + +```python +from fastmcp import FastMCP +from fastmcp.apps.generative import GenerativeUI + +mcp = FastMCP("My Server") +mcp.add_provider(GenerativeUI()) +``` + +This registers: + +| Component | Type | Purpose | +|-----------|------|---------| +| `generate_prefab_ui` | Tool | Accepts Python code, executes in Pyodide sandbox, renders result | +| `search_prefab_components` | Tool | Lets the LLM discover available Prefab components | +| Generative renderer | Resource | `ui://` resource with browser-side Pyodide for streaming | + +The LLM writes real Python — loops, f-strings, computation — using Prefab's component library (charts, tables, forms, cards, layout primitives). As the model generates tokens, the host streams partial code to the renderer via `ontoolinputpartial`, so the user watches the UI build up in real time. + +## Configuration + +```python +GenerativeUI( + tool_name="generate_prefab_ui", # Rename the generation tool + components_tool_name="search_prefab_components", # Rename the search tool + include_components_tool=True, # Set False to omit the search tool +) +``` + +## What the LLM Sees + +The tool description includes code examples that teach the LLM the Prefab patterns. The LLM calls `generate_prefab_ui` with a `code` argument containing Prefab Python, and optionally a `data` argument to pass in real data from the conversation: + +```python +# The LLM generates something like: +generate_prefab_ui( + code=""" +from prefab_ui.components import Column, Heading +from prefab_ui.components.charts import BarChart, ChartSeries +from prefab_ui.app import PrefabApp + +with PrefabApp() as app: + with Column(gap=4): + Heading("Revenue") + BarChart(data=data, series=[ChartSeries(data_key="revenue")], x_axis="quarter") +""", + data={"data": [{"quarter": "Q1", "revenue": 42000}, ...]} +) +``` + +The component search tool lets the LLM discover what's available before writing code — `search_prefab_components("Chart")` returns matching components with import paths. + +## Requirements + +Requires `fastmcp[apps]` (installs `prefab-ui`). The Pyodide sandbox for server-side validation requires Deno, which installs automatically on first use. The streaming renderer loads Pyodide from CDN in the browser — CSP is configured automatically. + +The sandbox includes the Python standard library and Prefab. External packages (NumPy, pandas, etc.) are not available. + +## Learn More + +The full **[Generative UI guide](/apps/generative)** covers the streaming mechanics in detail, how to pass data, the component search tool, and sandbox limitations. diff --git a/docs/apps/quickstart.mdx b/docs/apps/quickstart.mdx new file mode 100644 index 000000000..91265f884 --- /dev/null +++ b/docs/apps/quickstart.mdx @@ -0,0 +1,208 @@ +--- +title: Quickstart +sidebarTitle: Quickstart +description: Build your first MCP app in under a minute. +icon: rocket +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +MCP tools normally return text. FastMCP apps return interactive UIs rendered directly in the conversation: charts, tables, forms, dashboards. The easiest way to build one is with [Prefab UI](https://prefab.prefect.io), a Python component library designed for exactly this. You describe the UI in Python; Prefab compiles it to something the host can render. + +This tutorial builds a working app from scratch. Here's what you'll have in about a minute: + + + A team directory app with a pie chart and sortable data table, rendered inside a conversation in Goose + + +## Setup + +Install FastMCP with the `apps` extra, which pulls in Prefab UI: + +```bash +pip install "fastmcp[apps]" +``` + +## A Tool That Returns a UI + +When your tool has something to *show* (a table of results, a chart, a status dashboard) you can return an interactive UI instead of text. Build the visualization with Prefab components, return it from your tool, and set `app=True` so FastMCP knows to render it. The user sees a live, interactive widget right in the conversation instead of a wall of JSON. + +Create `server.py`: + +```python server.py expandable +from collections import Counter + +from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, Grid, Heading, DataTable, DataTableColumn +from prefab_ui.components.charts import PieChart +from fastmcp import FastMCP + +mcp = FastMCP("My First App") + + +@mcp.tool(app=True) +def team_directory() -> PrefabApp: + """Browse the team directory.""" + members = [ + {"name": "Alice Chen", "role": "Staff Engineer", "office": "San Francisco"}, + {"name": "Bob Martinez", "role": "Lead Designer", "office": "New York"}, + {"name": "Carol Johnson", "role": "Senior Engineer", "office": "London"}, + {"name": "David Kim", "role": "Product Manager", "office": "San Francisco"}, + {"name": "Eva Mueller", "role": "Engineer", "office": "Berlin"}, + {"name": "Frank Lee", "role": "Data Scientist", "office": "San Francisco"}, + {"name": "Grace Park", "role": "Engineering Manager", "office": "New York"}, + ] + + office_counts = [ + {"office": office, "count": count} + for office, count in Counter(m["office"] for m in members).items() + ] + + with PrefabApp() as app: + with Column(gap=4, css_class="p-6"): + Heading("Team Directory") + with Grid(columns=[1, 2], gap=4): + PieChart( + data=office_counts, + data_key="count", + name_key="office", + show_legend=True, + ) + DataTable( + columns=[ + DataTableColumn(key="name", header="Name", sortable=True), + DataTableColumn(key="role", header="Role", sortable=True), + DataTableColumn(key="office", header="Office", sortable=True), + ], + rows=members, + search=True, + ) + + return app +``` + +That `app=True` is doing a lot behind the scenes. It tells FastMCP to set up everything the MCP Apps protocol requires: the renderer resource, the content security policy, the metadata that tells the host "this tool returns a UI." Without it, you'd wire all of that up by hand. With it, you just return Prefab components and FastMCP handles the rest. The host (Claude Desktop, Goose, etc.) loads the result in a sandboxed iframe where the user can sort columns, search, and interact, all client-side with no round-trips to your server. + +The Prefab code itself reads top-to-bottom like a document. `PrefabApp()` is the root container and everything inside its `with` block becomes the app's UI. `Column` arranges children vertically. `Heading` renders a title. `DataTable` takes rows of data and column definitions, and gives you sorting and search for free. The `with` blocks establish parent-child relationships: nesting components inside each other builds the layout tree. + +## Running It + +FastMCP includes a dev server that renders your app tools in a browser, no MCP host needed: + +```bash +fastmcp dev apps server.py +``` + +This opens `http://localhost:8080` where you can pick a tool and see the rendered UI. Try sorting the table columns and typing in the search box. + +## Making It Interactive + +The table above is a static snapshot that renders once from the data your Python code provides. But Prefab apps can also respond to user interaction in real time, without any server round-trips. + +The key concept is **state**: a client-side key-value store that components read from and write to. When the user interacts with a component, it updates state. Other components that reference that state re-render instantly. See the [Prefab state docs](https://prefab.prefect.io/docs/concepts/state) for the full guide. + +Here's the same directory, but now clicking a row shows that person's details in a card: + + + The team directory with a detail card showing after clicking Bob Martinez + + +```python expandable server.py +from collections import Counter + +from prefab_ui.actions import SetState +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Card, CardContent, CardHeader, Column, Grid, H3, Heading, Muted, + Row, DataTable, DataTableColumn, Badge, Small, Text, +) +from prefab_ui.components.charts import PieChart +from prefab_ui.components.control_flow import If +from prefab_ui.rx import Rx, STATE +from fastmcp import FastMCP + +mcp = FastMCP("My First App") + +MEMBERS = [ + {"name": "Alice Chen", "role": "Staff Engineer", "office": "San Francisco", "email": "alice@company.com", "projects": 3}, + {"name": "Bob Martinez", "role": "Lead Designer", "office": "New York", "email": "bob@company.com", "projects": 5}, + {"name": "Carol Johnson", "role": "Senior Engineer", "office": "London", "email": "carol@company.com", "projects": 2}, + {"name": "David Kim", "role": "Product Manager", "office": "San Francisco", "email": "david@company.com", "projects": 7}, + {"name": "Eva Mueller", "role": "Engineer", "office": "Berlin", "email": "eva@company.com", "projects": 1}, + {"name": "Frank Lee", "role": "Data Scientist", "office": "San Francisco", "email": "frank@company.com", "projects": 4}, + {"name": "Grace Park", "role": "Engineering Manager", "office": "New York", "email": "grace@company.com", "projects": 6}, +] + +OFFICE_COUNTS = [ + {"office": office, "count": count} + for office, count in Counter(m["office"] for m in MEMBERS).items() +] + + +@mcp.tool(app=True) +def team_directory() -> PrefabApp: + """Browse the team directory.""" + with PrefabApp(state={"selected": None}) as app: + with Column(gap=4, css_class="p-6"): + Heading("Team Directory") + with Grid(columns=[1, 2], gap=4): + PieChart( + data=OFFICE_COUNTS, + data_key="count", + name_key="office", + show_legend=True, + ) + DataTable( + columns=[ + DataTableColumn(key="name", header="Name", sortable=True), + DataTableColumn(key="role", header="Role", sortable=True), + DataTableColumn(key="office", header="Office", sortable=True), + ], + rows=MEMBERS, + search=True, + on_row_click=SetState("selected", Rx("$event")), + ) + + with If(STATE.selected): + with Card(): + with CardHeader(): + with Row(gap=2, align="center"): + H3(Rx("selected.name")) + Badge(Rx("selected.office")) + with CardContent(): + with Grid(columns=3, gap=4): + with Column(gap=0): + Small("Role") + Text(Rx("selected.role")) + with Column(gap=0): + Small("Email") + Text(Rx("selected.email")) + with Column(gap=0): + Small("Active Projects") + Text(Rx("selected.projects")) + + return app +``` + +Three new ideas here: + +**`SetState` + `on_row_click`** is the interaction. When the user clicks a table row, `SetState("selected", Rx("$event"))` writes the clicked row's data into the `selected` state key. `$event` is a special variable that contains the event payload (in this case, the row dict). + +**`Rx("selected.name")`** reads from state reactively. It doesn't hold a Python value. It compiles to a browser-side expression that re-evaluates live whenever `selected` changes. So `Text(Rx("selected.name"))` always shows the name of whoever was last clicked. + +**`If(STATE.selected)`** conditionally renders the detail card only when something has been selected. Before any click, `selected` is `None` and the card is hidden. + +The `state` dict on `PrefabApp` sets initial values when the app loads. Run `fastmcp dev apps server.py` again and try clicking a row. + +## Next Steps + +You've built a tool that returns an interactive, reactive UI. This pattern covers a huge range of use cases: build a visualization in Prefab, return it from a tool, and the user gets dashboards, charts, data tables, and status displays right in the conversation. + +When you need the UI to talk back to your server (forms that save data, buttons that trigger actions, search that queries a database) you promote the tool to a **[FastMCPApp](/apps/interactive-apps)**. That gives you managed backend tools, automatic visibility control, and stable routing so your UI's button clicks reach the right server-side code. + +- **[Prefab UI](/apps/prefab)** covers the full component library: charts, forms, badges, progress bars, and the [reactive state system](https://prefab.prefect.io/docs/concepts/state) in depth. +- **[FastMCPApp](/apps/interactive-apps)** is the next step when your UI needs to interact with backend logic. +- **[App Providers](/apps/providers/approval)** are ready-made capabilities you can add with a single `add_provider()` call. diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 3e21089af..949a11659 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -9,10 +9,10 @@ tag: NEW **[v3.1.1: 'Tis But a Patch](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.1.1)** -Pins `pydantic-monty<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. +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<0.0.8 to fix code mode by [@jlowin](https://github.com/jlowin) in [#3497](https://github.com/PrefectHQ/fastmcp/pull/3497) +* 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) @@ -1947,7 +1947,7 @@ FastMCP 2.8.0 introduces powerful new ways to customize and control your MCP ser ### Tool Transformation -The highlight of this release is first-class [**Tool Transformation**](/patterns/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. +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. diff --git a/docs/clients/sampling.mdx b/docs/clients/sampling.mdx index a2e0e9942..1c7f37d1d 100644 --- a/docs/clients/sampling.mdx +++ b/docs/clients/sampling.mdx @@ -155,11 +155,11 @@ Install the Anthropic handler with `pip install fastmcp[anthropic]`. ```python from fastmcp import Client -from fastmcp.client.sampling.handlers.google_genai import GoogleGenAISamplingHandler +from fastmcp.client.sampling.handlers.google_genai import GoogleGenaiSamplingHandler client = Client( "my_mcp_server.py", - sampling_handler=GoogleGenAISamplingHandler(default_model="gemini-2.0-flash"), + sampling_handler=GoogleGenaiSamplingHandler(default_model="gemini-2.0-flash"), ) ``` diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx index caba09c47..54132eb0e 100644 --- a/docs/deployment/http.mdx +++ b/docs/deployment/http.mdx @@ -115,6 +115,10 @@ async def health_check(request): This health endpoint will be available at `http://localhost:8000/health` and can be used by load balancers, monitoring systems, or deployment platforms to verify your server is running. + +Custom routes are never protected by the server's authentication middleware, even when an `AuthProvider` is configured. This is by design — the primary use case for custom routes is unauthenticated operational endpoints like health checks and readiness probes. If you need authenticated HTTP endpoints alongside your MCP server, [mount it in a FastAPI app](/integrations/fastapi) and use FastAPI's `Depends()` for auth on your routes. + + ### Custom Middleware @@ -656,17 +660,17 @@ FASTMCP_STATELESS_HTTP=true uvicorn app:app --host 0.0.0.0 --port 8000 --workers 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 bearer token authentication (though OAuth is recommended for production): +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 BearerTokenAuth +from fastmcp.server.auth import StaticTokenVerifier # Read configuration from environment auth_token = os.environ.get("MCP_AUTH_TOKEN") if auth_token: - auth = BearerTokenAuth(token=auth_token) + auth = StaticTokenVerifier(tokens={auth_token: {"sub": "admin", "client_id": "cli"}}) mcp = FastMCP("Production Server", auth=auth) else: mcp = FastMCP("Production Server") diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index da9fc87db..476349a01 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -29,7 +29,7 @@ FastMCP now includes a sampling handler for Google's Gemini models ([#2977](http ```python from fastmcp import Client -from fastmcp.client.sampling.handlers import GoogleGenaiSamplingHandler +from fastmcp.client.sampling.handlers.google_genai import GoogleGenaiSamplingHandler from google.genai import Client as GoogleGenaiClient # Initialize the handler @@ -386,7 +386,7 @@ Support for [MCP Apps](https://modelcontextprotocol.io/specification/2025-06-18/ ```python from fastmcp import FastMCP -from fastmcp.server.apps import AppConfig, ResourceCSP, ResourcePermissions +from fastmcp.apps import AppConfig, ResourceCSP, ResourcePermissions mcp = FastMCP("My Server") @@ -421,7 +421,7 @@ The `app=` parameter accepts `True` (enable with defaults), an `AppConfig` insta ```python from fastmcp import Context -from fastmcp.server.apps import AppConfig, UI_EXTENSION_ID +from fastmcp.apps import AppConfig, UI_EXTENSION_ID @mcp.tool(app=AppConfig(resource_uri="ui://dashboard")) async def dashboard(ctx: Context) -> dict: @@ -575,15 +575,14 @@ provider.add_transform(ToolTransform({ ```python from collections.abc import Sequence -from fastmcp.server.transforms import Transform, ListToolsNext, GetToolNext +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, call_next: ListToolsNext) -> Sequence[Tool]: - tools = await call_next() # Get tools from downstream + 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: diff --git a/docs/docs.json b/docs/docs.json index 5e08f3069..1b36b5efb 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -28,7 +28,7 @@ "description": "The fast, Pythonic way to build MCP servers and clients.", "errors": { "404": { - "description": "You\u2019ve wandered outside the context.", + "description": "You’ve wandered outside the context.", "redirect": false, "title": "Don't panic." } @@ -159,19 +159,26 @@ }, { "collapsed": true, - "group": "Authentication", - "icon": "key", + "group": "Auth", + "icon": "shield-check", "pages": [ - "servers/auth/authentication", - "servers/auth/token-verification", - "servers/auth/remote-oauth", - "servers/auth/oauth-proxy", - "servers/auth/oidc-proxy", - "servers/auth/full-oauth-server", - "servers/auth/multi-auth" + { + "collapsed": true, + "group": "Authentication", + "icon": "key", + "pages": [ + "servers/auth/authentication", + "servers/auth/token-verification", + "servers/auth/remote-oauth", + "servers/auth/oauth-proxy", + "servers/auth/oidc-proxy", + "servers/auth/full-oauth-server", + "servers/auth/multi-auth" + ] + }, + "servers/authorization" ] }, - "servers/authorization", { "collapsed": true, "group": "Deployment", @@ -189,16 +196,39 @@ "group": "Apps", "pages": [ "apps/overview", - "apps/prefab", - "apps/interactive-apps", - "apps/generative", - "apps/development", + "apps/quickstart", + "apps/examples", { "collapsed": true, - "group": "Reference", - "icon": "book", + "group": "Building Apps", + "icon": "hammer", "pages": [ - "apps/patterns", + "apps/prefab", + "apps/interactive-apps", + "apps/generative", + "apps/patterns" + ], + "tag": "NEW" + }, + { + "collapsed": true, + "group": "Providers", + "icon": "layer-group", + "pages": [ + "apps/providers/approval", + "apps/providers/choice", + "apps/providers/file-upload", + "apps/providers/form", + "apps/providers/generative" + ], + "tag": "NEW" + }, + { + "collapsed": true, + "group": "Advanced", + "icon": "gear", + "pages": [ + "apps/development", "apps/architecture", "apps/low-level" ], @@ -266,6 +296,7 @@ "integrations/eunomia-authorization", "integrations/github", "integrations/google", + "integrations/keycloak", "integrations/oci", "integrations/permit", "integrations/propelauth", @@ -365,422 +396,9 @@ { "anchor": "Python SDK", "icon": "python", - "pages": [ - "python-sdk/fastmcp-decorators", - "python-sdk/fastmcp-dependencies", - "python-sdk/fastmcp-exceptions", - "python-sdk/fastmcp-mcp_config", - "python-sdk/fastmcp-settings", - "python-sdk/fastmcp-telemetry", - "python-sdk/fastmcp-types", - { - "group": "fastmcp.apps", - "pages": [ - "python-sdk/fastmcp-apps-__init__", - "python-sdk/fastmcp-apps-app", - "python-sdk/fastmcp-apps-config", - "python-sdk/fastmcp-apps-generative" - ] - }, - { - "group": "fastmcp.cli", - "pages": [ - "python-sdk/fastmcp-cli-__init__", - "python-sdk/fastmcp-cli-apps_dev", - "python-sdk/fastmcp-cli-auth", - "python-sdk/fastmcp-cli-cimd", - "python-sdk/fastmcp-cli-cli", - "python-sdk/fastmcp-cli-client", - "python-sdk/fastmcp-cli-discovery", - "python-sdk/fastmcp-cli-generate", - { - "group": "install", - "pages": [ - "python-sdk/fastmcp-cli-install-__init__", - "python-sdk/fastmcp-cli-install-claude_code", - "python-sdk/fastmcp-cli-install-claude_desktop", - "python-sdk/fastmcp-cli-install-cursor", - "python-sdk/fastmcp-cli-install-gemini_cli", - "python-sdk/fastmcp-cli-install-goose", - "python-sdk/fastmcp-cli-install-mcp_json", - "python-sdk/fastmcp-cli-install-shared", - "python-sdk/fastmcp-cli-install-stdio" - ] - }, - "python-sdk/fastmcp-cli-run", - "python-sdk/fastmcp-cli-tasks" - ] - }, - { - "group": "fastmcp.client", - "pages": [ - "python-sdk/fastmcp-client-__init__", - { - "group": "auth", - "pages": [ - "python-sdk/fastmcp-client-auth-__init__", - "python-sdk/fastmcp-client-auth-bearer", - "python-sdk/fastmcp-client-auth-oauth" - ] - }, - "python-sdk/fastmcp-client-client", - "python-sdk/fastmcp-client-elicitation", - "python-sdk/fastmcp-client-logging", - "python-sdk/fastmcp-client-messages", - { - "group": "mixins", - "pages": [ - "python-sdk/fastmcp-client-mixins-__init__", - "python-sdk/fastmcp-client-mixins-prompts", - "python-sdk/fastmcp-client-mixins-resources", - "python-sdk/fastmcp-client-mixins-task_management", - "python-sdk/fastmcp-client-mixins-tools" - ] - }, - "python-sdk/fastmcp-client-oauth_callback", - "python-sdk/fastmcp-client-progress", - "python-sdk/fastmcp-client-roots", - { - "group": "sampling", - "pages": [ - "python-sdk/fastmcp-client-sampling-__init__", - { - "group": "handlers", - "pages": [ - "python-sdk/fastmcp-client-sampling-handlers-__init__", - "python-sdk/fastmcp-client-sampling-handlers-anthropic", - "python-sdk/fastmcp-client-sampling-handlers-google_genai", - "python-sdk/fastmcp-client-sampling-handlers-openai" - ] - } - ] - }, - "python-sdk/fastmcp-client-tasks", - "python-sdk/fastmcp-client-telemetry", - { - "group": "transports", - "pages": [ - "python-sdk/fastmcp-client-transports-__init__", - "python-sdk/fastmcp-client-transports-base", - "python-sdk/fastmcp-client-transports-config", - "python-sdk/fastmcp-client-transports-http", - "python-sdk/fastmcp-client-transports-inference", - "python-sdk/fastmcp-client-transports-memory", - "python-sdk/fastmcp-client-transports-sse", - "python-sdk/fastmcp-client-transports-stdio" - ] - } - ] - }, - { - "group": "fastmcp.experimental", - "pages": [ - "python-sdk/fastmcp-experimental-__init__", - { - "group": "sampling", - "pages": [ - "python-sdk/fastmcp-experimental-sampling-__init__", - "python-sdk/fastmcp-experimental-sampling-handlers" - ] - }, - { - "group": "transforms", - "pages": [ - "python-sdk/fastmcp-experimental-transforms-__init__", - "python-sdk/fastmcp-experimental-transforms-code_mode" - ] - } - ] - }, - { - "group": "fastmcp.prompts", - "pages": [ - "python-sdk/fastmcp-prompts-__init__", - "python-sdk/fastmcp-prompts-base", - "python-sdk/fastmcp-prompts-function_prompt" - ] - }, - { - "group": "fastmcp.resources", - "pages": [ - "python-sdk/fastmcp-resources-__init__", - "python-sdk/fastmcp-resources-base", - "python-sdk/fastmcp-resources-function_resource", - "python-sdk/fastmcp-resources-template", - "python-sdk/fastmcp-resources-types" - ] - }, - { - "group": "fastmcp.server", - "pages": [ - "python-sdk/fastmcp-server-__init__", - "python-sdk/fastmcp-server-app", - "python-sdk/fastmcp-server-apps", - { - "group": "auth", - "pages": [ - "python-sdk/fastmcp-server-auth-__init__", - "python-sdk/fastmcp-server-auth-auth", - "python-sdk/fastmcp-server-auth-authorization", - "python-sdk/fastmcp-server-auth-cimd", - "python-sdk/fastmcp-server-auth-jwt_issuer", - "python-sdk/fastmcp-server-auth-middleware", - { - "group": "oauth_proxy", - "pages": [ - "python-sdk/fastmcp-server-auth-oauth_proxy-__init__", - "python-sdk/fastmcp-server-auth-oauth_proxy-consent", - "python-sdk/fastmcp-server-auth-oauth_proxy-models", - "python-sdk/fastmcp-server-auth-oauth_proxy-proxy", - "python-sdk/fastmcp-server-auth-oauth_proxy-ui" - ] - }, - "python-sdk/fastmcp-server-auth-oidc_proxy", - { - "group": "providers", - "pages": [ - "python-sdk/fastmcp-server-auth-providers-__init__", - "python-sdk/fastmcp-server-auth-providers-auth0", - "python-sdk/fastmcp-server-auth-providers-aws", - "python-sdk/fastmcp-server-auth-providers-azure", - "python-sdk/fastmcp-server-auth-providers-debug", - "python-sdk/fastmcp-server-auth-providers-descope", - "python-sdk/fastmcp-server-auth-providers-discord", - "python-sdk/fastmcp-server-auth-providers-github", - "python-sdk/fastmcp-server-auth-providers-google", - "python-sdk/fastmcp-server-auth-providers-in_memory", - "python-sdk/fastmcp-server-auth-providers-introspection", - "python-sdk/fastmcp-server-auth-providers-jwt", - "python-sdk/fastmcp-server-auth-providers-oci", - "python-sdk/fastmcp-server-auth-providers-propelauth", - "python-sdk/fastmcp-server-auth-providers-scalekit", - "python-sdk/fastmcp-server-auth-providers-supabase", - "python-sdk/fastmcp-server-auth-providers-workos" - ] - }, - "python-sdk/fastmcp-server-auth-redirect_validation", - "python-sdk/fastmcp-server-auth-ssrf" - ] - }, - "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-http", - "python-sdk/fastmcp-server-lifespan", - "python-sdk/fastmcp-server-low_level", - { - "group": "middleware", - "pages": [ - "python-sdk/fastmcp-server-middleware-__init__", - "python-sdk/fastmcp-server-middleware-authorization", - "python-sdk/fastmcp-server-middleware-caching", - "python-sdk/fastmcp-server-middleware-dereference", - "python-sdk/fastmcp-server-middleware-error_handling", - "python-sdk/fastmcp-server-middleware-logging", - "python-sdk/fastmcp-server-middleware-middleware", - "python-sdk/fastmcp-server-middleware-ping", - "python-sdk/fastmcp-server-middleware-rate_limiting", - "python-sdk/fastmcp-server-middleware-response_limiting", - "python-sdk/fastmcp-server-middleware-timing", - "python-sdk/fastmcp-server-middleware-tool_injection" - ] - }, - { - "group": "mixins", - "pages": [ - "python-sdk/fastmcp-server-mixins-__init__", - "python-sdk/fastmcp-server-mixins-lifespan", - "python-sdk/fastmcp-server-mixins-mcp_operations", - "python-sdk/fastmcp-server-mixins-transport" - ] - }, - { - "group": "openapi", - "pages": [ - "python-sdk/fastmcp-server-openapi-__init__", - "python-sdk/fastmcp-server-openapi-components", - "python-sdk/fastmcp-server-openapi-routing", - "python-sdk/fastmcp-server-openapi-server" - ] - }, - { - "group": "providers", - "pages": [ - "python-sdk/fastmcp-server-providers-__init__", - "python-sdk/fastmcp-server-providers-aggregate", - "python-sdk/fastmcp-server-providers-base", - "python-sdk/fastmcp-server-providers-fastmcp_provider", - "python-sdk/fastmcp-server-providers-filesystem", - "python-sdk/fastmcp-server-providers-filesystem_discovery", - { - "group": "local_provider", - "pages": [ - "python-sdk/fastmcp-server-providers-local_provider-__init__", - { - "group": "decorators", - "pages": [ - "python-sdk/fastmcp-server-providers-local_provider-decorators-__init__", - "python-sdk/fastmcp-server-providers-local_provider-decorators-prompts", - "python-sdk/fastmcp-server-providers-local_provider-decorators-resources", - "python-sdk/fastmcp-server-providers-local_provider-decorators-tools" - ] - }, - "python-sdk/fastmcp-server-providers-local_provider-local_provider" - ] - }, - { - "group": "openapi", - "pages": [ - "python-sdk/fastmcp-server-providers-openapi-__init__", - "python-sdk/fastmcp-server-providers-openapi-components", - "python-sdk/fastmcp-server-providers-openapi-provider", - "python-sdk/fastmcp-server-providers-openapi-routing" - ] - }, - "python-sdk/fastmcp-server-providers-proxy", - { - "group": "skills", - "pages": [ - "python-sdk/fastmcp-server-providers-skills-__init__", - "python-sdk/fastmcp-server-providers-skills-claude_provider", - "python-sdk/fastmcp-server-providers-skills-directory_provider", - "python-sdk/fastmcp-server-providers-skills-skill_provider", - "python-sdk/fastmcp-server-providers-skills-vendor_providers" - ] - }, - "python-sdk/fastmcp-server-providers-wrapped_provider" - ] - }, - "python-sdk/fastmcp-server-proxy", - { - "group": "sampling", - "pages": [ - "python-sdk/fastmcp-server-sampling-__init__", - "python-sdk/fastmcp-server-sampling-run", - "python-sdk/fastmcp-server-sampling-sampling_tool" - ] - }, - "python-sdk/fastmcp-server-server", - { - "group": "tasks", - "pages": [ - "python-sdk/fastmcp-server-tasks-__init__", - "python-sdk/fastmcp-server-tasks-capabilities", - "python-sdk/fastmcp-server-tasks-config", - "python-sdk/fastmcp-server-tasks-elicitation", - "python-sdk/fastmcp-server-tasks-handlers", - "python-sdk/fastmcp-server-tasks-keys", - "python-sdk/fastmcp-server-tasks-notifications", - "python-sdk/fastmcp-server-tasks-requests", - "python-sdk/fastmcp-server-tasks-routing", - "python-sdk/fastmcp-server-tasks-subscriptions" - ] - }, - "python-sdk/fastmcp-server-telemetry", - { - "group": "transforms", - "pages": [ - "python-sdk/fastmcp-server-transforms-__init__", - "python-sdk/fastmcp-server-transforms-catalog", - "python-sdk/fastmcp-server-transforms-namespace", - "python-sdk/fastmcp-server-transforms-prompts_as_tools", - "python-sdk/fastmcp-server-transforms-resources_as_tools", - { - "group": "search", - "pages": [ - "python-sdk/fastmcp-server-transforms-search-__init__", - "python-sdk/fastmcp-server-transforms-search-base", - "python-sdk/fastmcp-server-transforms-search-bm25", - "python-sdk/fastmcp-server-transforms-search-regex" - ] - }, - "python-sdk/fastmcp-server-transforms-tool_transform", - "python-sdk/fastmcp-server-transforms-version_filter", - "python-sdk/fastmcp-server-transforms-visibility" - ] - } - ] - }, - { - "group": "fastmcp.tools", - "pages": [ - "python-sdk/fastmcp-tools-__init__", - "python-sdk/fastmcp-tools-base", - "python-sdk/fastmcp-tools-function_parsing", - "python-sdk/fastmcp-tools-function_tool", - "python-sdk/fastmcp-tools-tool_transform" - ] - }, - { - "group": "fastmcp.utilities", - "pages": [ - "python-sdk/fastmcp-utilities-__init__", - "python-sdk/fastmcp-utilities-async_utils", - "python-sdk/fastmcp-utilities-auth", - "python-sdk/fastmcp-utilities-cli", - "python-sdk/fastmcp-utilities-components", - "python-sdk/fastmcp-utilities-exceptions", - "python-sdk/fastmcp-utilities-http", - "python-sdk/fastmcp-utilities-inspect", - "python-sdk/fastmcp-utilities-json_schema", - "python-sdk/fastmcp-utilities-json_schema_type", - "python-sdk/fastmcp-utilities-lifespan", - "python-sdk/fastmcp-utilities-logging", - { - "group": "mcp_server_config", - "pages": [ - "python-sdk/fastmcp-utilities-mcp_server_config-__init__", - { - "group": "v1", - "pages": [ - "python-sdk/fastmcp-utilities-mcp_server_config-v1-__init__", - { - "group": "environments", - "pages": [ - "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-__init__", - "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base", - "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv" - ] - }, - "python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config", - { - "group": "sources", - "pages": [ - "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-__init__", - "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base", - "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem" - ] - } - ] - } - ] - }, - "python-sdk/fastmcp-utilities-mime", - { - "group": "openapi", - "pages": [ - "python-sdk/fastmcp-utilities-openapi-__init__", - "python-sdk/fastmcp-utilities-openapi-director", - "python-sdk/fastmcp-utilities-openapi-formatters", - "python-sdk/fastmcp-utilities-openapi-json_schema_converter", - "python-sdk/fastmcp-utilities-openapi-models", - "python-sdk/fastmcp-utilities-openapi-parser", - "python-sdk/fastmcp-utilities-openapi-schemas" - ] - }, - "python-sdk/fastmcp-utilities-pagination", - "python-sdk/fastmcp-utilities-skills", - "python-sdk/fastmcp-utilities-tests", - "python-sdk/fastmcp-utilities-timeout", - "python-sdk/fastmcp-utilities-token_cache", - "python-sdk/fastmcp-utilities-types", - "python-sdk/fastmcp-utilities-ui", - "python-sdk/fastmcp-utilities-version_check", - "python-sdk/fastmcp-utilities-versions" - ] - } - ] + "pages": { + "$ref": "./python-sdk-pages.json" + } } ], "dropdown": "SDK Reference", @@ -790,201 +408,7 @@ "version": "v3" }, { - "dropdowns": [ - { - "dropdown": "Documentation", - "groups": [ - { - "group": "Get Started", - "pages": [ - "v2/getting-started/welcome", - "v2/getting-started/installation", - "v2/getting-started/quickstart", - "v2/updates" - ] - }, - { - "group": "Servers", - "pages": [ - "v2/servers/server", - { - "group": "Core Components", - "icon": "toolbox", - "pages": [ - "v2/servers/tools", - "v2/servers/resources", - "v2/servers/prompts" - ] - }, - { - "group": "Advanced Features", - "icon": "stars", - "pages": [ - "v2/servers/composition", - "v2/servers/context", - "v2/servers/elicitation", - "v2/servers/icons", - "v2/servers/logging", - "v2/servers/middleware", - "v2/servers/progress", - "v2/servers/proxy", - "v2/servers/sampling", - "v2/servers/storage-backends", - "v2/servers/tasks" - ] - }, - { - "group": "Authentication", - "icon": "shield-check", - "pages": [ - "v2/servers/auth/authentication", - "v2/servers/auth/token-verification", - "v2/servers/auth/remote-oauth", - "v2/servers/auth/oauth-proxy", - "v2/servers/auth/oidc-proxy", - "v2/servers/auth/full-oauth-server" - ] - }, - { - "group": "Deployment", - "icon": "rocket", - "pages": [ - "v2/deployment/running-server", - "v2/deployment/http", - "deployment/prefect-horizon", - "v2/deployment/server-configuration" - ] - } - ] - }, - { - "group": "Clients", - "pages": [ - { - "group": "Essentials", - "icon": "cube", - "pages": [ - "v2/clients/client", - "v2/clients/transports" - ] - }, - { - "group": "Core Operations", - "icon": "handshake", - "pages": [ - "v2/clients/tools", - "v2/clients/resources", - "v2/clients/prompts" - ] - }, - { - "group": "Advanced Features", - "icon": "stars", - "pages": [ - "v2/clients/elicitation", - "v2/clients/logging", - "v2/clients/progress", - "v2/clients/sampling", - "v2/clients/tasks", - "v2/clients/messages", - "v2/clients/roots" - ] - }, - { - "group": "Authentication", - "icon": "user-shield", - "pages": [ - "v2/clients/auth/oauth", - "v2/clients/auth/bearer" - ] - } - ] - }, - { - "group": "Integrations", - "pages": [ - { - "group": "Authentication", - "icon": "key", - "pages": [ - "v2/integrations/auth0", - "v2/integrations/authkit", - "v2/integrations/aws-cognito", - "v2/integrations/azure", - "v2/integrations/descope", - "v2/integrations/discord", - "v2/integrations/github", - "v2/integrations/google", - "v2/integrations/oci", - "v2/integrations/scalekit", - "v2/integrations/supabase", - "v2/integrations/workos" - ] - }, - { - "group": "Authorization", - "icon": "shield-check", - "pages": [ - "v2/integrations/eunomia-authorization", - "v2/integrations/permit" - ] - }, - { - "group": "AI Assistants", - "icon": "robot", - "pages": [ - "v2/integrations/chatgpt", - "v2/integrations/claude-code", - "v2/integrations/claude-desktop", - "v2/integrations/cursor", - "v2/integrations/gemini-cli", - "v2/integrations/mcp-json-configuration" - ] - }, - { - "group": "AI SDKs", - "icon": "code", - "pages": [ - "v2/integrations/anthropic", - "v2/integrations/gemini", - "v2/integrations/openai" - ] - }, - { - "group": "API Integration", - "icon": "globe", - "pages": [ - "v2/integrations/fastapi", - "v2/integrations/openapi" - ] - } - ] - }, - { - "group": "Patterns", - "pages": [ - "v2/patterns/tool-transformation", - "v2/patterns/decorating-methods", - "v2/patterns/cli", - "v2/patterns/contrib", - "v2/patterns/testing" - ] - }, - { - "group": "Development", - "pages": [ - "v2/development/contributing", - "v2/development/tests", - "v2/development/releases", - "v2/development/upgrade-guide", - "v2/changelog" - ] - } - ], - "icon": "book" - } - ], - "version": "v2.14.5" + "$ref": "./v2-navigation.json" } ] }, @@ -1074,4 +498,4 @@ "appearance": "light", "background": "/assets/brand/thumbnail-background-4.jpeg" } -} \ No newline at end of file +} diff --git a/docs/fastmcp-analytics.js b/docs/fastmcp-analytics.js new file mode 100644 index 000000000..07be00534 --- /dev/null +++ b/docs/fastmcp-analytics.js @@ -0,0 +1,257 @@ +(function () { + if (typeof window === "undefined") return; + + // Public browser key for the shared Prefect Amplitude project. + // This is intentionally client-side; the secret key must never ship to the browser. + var AMPLITUDE_API_KEY = "c361ed56e7bdc1a48a38773c40120b39"; + var AMPLITUDE_SCRIPT_URL = + "https://cdn.amplitude.com/libs/analytics-browser-2.8.1-min.js.gz"; + var AMPLITUDE_SERVER_URL = "https://api2.amplitude.com/2/httpapi"; + var PAGE_VIEW_EVENT = "Page View: FastMCP Docs"; + var OUTBOUND_CLICK_EVENT = "Docs Outbound Clicked"; + var SOURCE = "docs"; + var SOURCE_DETAIL = "fastmcp"; + var SURFACE = "fastmcp_docs"; + var DEVICE_ID_PARAM = "deviceId"; + var routeListenersInstalled = false; + var amplitudeInitialized = false; + var lastTrackedUrl = null; + + var PREFECT_DESTINATION_HOSTNAMES = [ + "www.prefect.io", + "prefect.io", + "horizon.prefect.io", + "app.prefect.cloud", + ]; + + var routeChangeCallbacks = []; + + function loadScript(src, onload) { + var script = document.createElement("script"); + script.src = src; + script.async = true; + + if (typeof onload === "function") { + script.addEventListener("load", onload); + } + + document.head.appendChild(script); + return script; + } + + function getAmplitude() { + return window.amplitude || window.amplitudeAnalytics; + } + + function normalizePathname(pathname) { + if (pathname === "/") return pathname; + return pathname.replace(/\/+$/, ""); + } + + function observeRouteChanges(callback) { + routeChangeCallbacks.push(callback); + + if (!routeListenersInstalled) { + var fireCallbacks = function () { + routeChangeCallbacks.forEach(function (cb) { + window.setTimeout(cb, 0); + }); + }; + + var wrapHistoryMethod = function (methodName) { + var original = window.history[methodName]; + window.history[methodName] = function () { + var result = original.apply(this, arguments); + fireCallbacks(); + return result; + }; + }; + + wrapHistoryMethod("pushState"); + wrapHistoryMethod("replaceState"); + window.addEventListener("popstate", fireCallbacks); + window.addEventListener("hashchange", fireCallbacks); + routeListenersInstalled = true; + } + + callback(); + } + + function buildPageViewProperties() { + return { + url: window.location.href, + title: document.title, + referrer: document.referrer || null, + path: normalizePathname(window.location.pathname), + source: SOURCE, + source_detail: SOURCE_DETAIL, + surface: SURFACE, + }; + } + + function trackPageView() { + var amplitude = getAmplitude(); + if (!amplitude || typeof amplitude.track !== "function") { + return; + } + + var url = window.location.href; + if (url === lastTrackedUrl) { + return; + } + + amplitude.track(PAGE_VIEW_EVENT, buildPageViewProperties()); + lastTrackedUrl = url; + } + + function parseUrl(href) { + try { + return new URL(href, window.location.origin); + } catch (error) { + return null; + } + } + + function isPrefectDestination(url) { + return PREFECT_DESTINATION_HOSTNAMES.indexOf(url.hostname) !== -1; + } + + function addDeviceIdToLink(event) { + var amplitude = getAmplitude(); + if (!amplitude || typeof amplitude.getDeviceId !== "function") { + return; + } + + var link = event.currentTarget; + var href = link.getAttribute("href") || ""; + + var url = parseUrl(href); + if (!url || !isPrefectDestination(url)) { + return; + } + + url.searchParams.set(DEVICE_ID_PARAM, amplitude.getDeviceId()); + link.href = url.toString(); + } + + function removeDeviceIdFromLink(event) { + var link = event.currentTarget; + var href = link.getAttribute("href") || ""; + + var url = parseUrl(href); + if (!url || !isPrefectDestination(url)) { + return; + } + + url.searchParams.delete(DEVICE_ID_PARAM); + link.href = url.toString(); + } + + function attachDeviceIdForwarding() { + var elements = document.querySelectorAll("a[href]"); + elements.forEach(function (element) { + if (element.dataset.fastmcpDeviceIdBound === "true") { + return; + } + + var url = parseUrl(element.getAttribute("href") || ""); + if (!url || !isPrefectDestination(url)) { + return; + } + + element.addEventListener("mouseenter", addDeviceIdToLink); + element.addEventListener("mouseleave", removeDeviceIdFromLink); + element.addEventListener("focus", addDeviceIdToLink); + element.addEventListener("blur", removeDeviceIdFromLink); + element.addEventListener("touchstart", addDeviceIdToLink); + element.addEventListener("touchcancel", removeDeviceIdFromLink); + element.dataset.fastmcpDeviceIdBound = "true"; + }); + } + + function trackOutboundClick(event) { + var link = event.target && event.target.closest + ? event.target.closest("a[href]") + : null; + + if (!link) { + return; + } + + var href = link.getAttribute("href"); + if (!href || href[0] === "#") { + return; + } + + var destination; + destination = parseUrl(href); + if (!destination) { + return; + } + + if (destination.hostname === window.location.hostname) { + return; + } + + var amplitude = getAmplitude(); + if (!amplitude || typeof amplitude.track !== "function") { + return; + } + + amplitude.track(OUTBOUND_CLICK_EVENT, { + path: normalizePathname(window.location.pathname), + url: window.location.href, + title: document.title, + source: SOURCE, + source_detail: SOURCE_DETAIL, + surface: SURFACE, + destination: destination.href, + destination_domain: destination.hostname, + link_text: (link.textContent || "").trim().slice(0, 200), + is_prefect_destination: isPrefectDestination(destination), + }); + } + + function initializeAmplitude() { + var amplitude = getAmplitude(); + if ( + amplitudeInitialized || + !amplitude || + typeof amplitude.init !== "function" + ) { + return; + } + + amplitude.init(AMPLITUDE_API_KEY, undefined, { + useBatch: true, + serverUrl: AMPLITUDE_SERVER_URL, + attribution: { + disabled: false, + trackNewCampaigns: true, + trackPageViews: true, + resetSessionOnNewCampaign: true, + }, + defaultTracking: { + pageViews: false, + sessions: false, + formInteractions: true, + fileDownloads: true, + }, + }); + + amplitudeInitialized = true; + observeRouteChanges(trackPageView); + observeRouteChanges(attachDeviceIdForwarding); + } + + function initialize() { + document.addEventListener("click", trackOutboundClick, true); + loadScript(AMPLITUDE_SCRIPT_URL, initializeAmplitude); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initialize); + } else { + initialize(); + } +})(); diff --git a/docs/getting-started/upgrading/from-fastmcp-2.mdx b/docs/getting-started/upgrading/from-fastmcp-2.mdx index 47baa5655..1e659e76a 100644 --- a/docs/getting-started/upgrading/from-fastmcp-2.mdx +++ b/docs/getting-started/upgrading/from-fastmcp-2.mdx @@ -77,7 +77,7 @@ BREAKING CHANGES (will crash at import or runtime): 10. DECORATORS: @mcp.tool, @mcp.resource, @mcp.prompt now return the original function, not a component object. Code that accesses .name, .description, or other component attributes on the decorated result will crash with AttributeError. Fix: set FASTMCP_DECORATOR_MODE=object for v2 compat (itself deprecated). -11. OAUTH STORAGE: Default OAuth client storage changed from DiskStore to FileTreeStore due to pickle deserialization vulnerability in diskcache (CVE-2025-69872). Clients using default storage will re-register automatically on first connection. If using DiskStore explicitly, switch to FileTreeStore or add pip install 'py-key-value-aio[disk]'. +11. OAUTH STORAGE: Default OAuth client storage changed from DiskStore to FileTreeStore due to pickle deserialization vulnerability in diskcache (CVE-2025-69872). Clients using default storage will re-register automatically on first connection. If using DiskStore explicitly, switch to FileTreeStore (with key/collection sanitization strategies) or add pip install 'py-key-value-aio[disk]'. 12. REPO MOVE: GitHub repository moved from jlowin/fastmcp to PrefectHQ/fastmcp. Update git remotes and dependency URLs that reference the old location. @@ -126,7 +126,11 @@ The default OAuth client storage has moved from `DiskStore` to `FileTreeStore` t If you were using the default storage (i.e., not passing an explicit `client_storage`), clients will need to re-register on their first connection after upgrading. This happens automatically — no user action required, and it's the same flow that already occurs whenever a server restarts with in-memory storage. -If you were passing a `DiskStore` explicitly, you can either [switch to `FileTreeStore`](/servers/storage-backends) (recommended) or keep using `DiskStore` by adding the dependency yourself: +If you were passing a `DiskStore` explicitly, you can either [switch to `FileTreeStore`](/servers/storage-backends) (recommended) or keep using `DiskStore` by adding the dependency yourself. + + +When switching to `FileTreeStore`, you **must** configure key and collection sanitization strategies. Without them, keys containing special characters (such as URL-based OAuth client IDs) will cause filesystem errors. See the [File Storage](/servers/storage-backends#file-storage) section for the recommended setup. + Keeping `DiskStore` requires `pip install 'py-key-value-aio[disk]'`, which re-introduces the vulnerable `diskcache` package into your dependency tree. @@ -260,7 +264,7 @@ auth = GitHubProvider( The deprecated WebSocket client transport has been removed. Use `StreamableHttpTransport` instead: -```python +```python test="skip" # Before from fastmcp.client.transports import WSTransport transport = WSTransport("ws://localhost:8000/ws") @@ -353,7 +357,7 @@ main.mount(subserver) The proxy and OpenAPI modules have moved under `providers` to reflect v3's provider-based architecture: -```python +```python test="skip" # Deprecated from fastmcp.server.proxy import FastMCPProxy from fastmcp.server.openapi import FastMCPOpenAPI @@ -365,7 +369,7 @@ from fastmcp.server.providers.openapi import OpenAPIProvider `FastMCPOpenAPI` itself is deprecated — use `FastMCP` with an `OpenAPIProvider` instead: -```python +```python test="skip" # Deprecated from fastmcp.server.openapi import FastMCPOpenAPI server = FastMCPOpenAPI(spec, client) @@ -404,7 +408,7 @@ proxy = create_proxy("http://example.com/mcp") The experimental OpenAPI parser is now standard. Update imports: -```python +```python test="skip" # Before from fastmcp.experimental.server.openapi import FastMCPOpenAPI diff --git a/docs/getting-started/welcome.mdx b/docs/getting-started/welcome.mdx index 28932284f..f7af6bb1d 100644 --- a/docs/getting-started/welcome.mdx +++ b/docs/getting-started/welcome.mdx @@ -105,7 +105,7 @@ from fastmcp import Client async def main(): async with Client("https://gofastmcp.com/mcp") as client: result = await client.call_tool( - name="SearchFastMcp", + name="search_fast_mcp", arguments={"query": "deploy a FastMCP server"} ) print(result) diff --git a/docs/integrations/anthropic.mdx b/docs/integrations/anthropic.mdx index b8156ac95..08b9b2c9c 100644 --- a/docs/integrations/anthropic.mdx +++ b/docs/integrations/anthropic.mdx @@ -181,7 +181,7 @@ if __name__ == "__main__": 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. -```python +```text Error code: 400 - { "type": "error", "error": { diff --git a/docs/integrations/authkit.mdx b/docs/integrations/authkit.mdx index e99f78351..c77175201 100644 --- a/docs/integrations/authkit.mdx +++ b/docs/integrations/authkit.mdx @@ -9,29 +9,32 @@ import { VersionBadge } from "/snippets/version-badge.mdx" -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, where AuthKit handles user login and your FastMCP server validates the tokens. - - -AuthKit 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. If you need resource-specific audience validation, consider using [WorkOSProvider](/integrations/workos) (OAuth proxy pattern) instead. - +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://localhost:8000`). +3. Your FastMCP server's URL (can be localhost for development, e.g., `http://127.0.0.1:8000`). -### Step 1: AuthKit Configuration +### Step 1: WorkOS Dashboard -In your WorkOS Dashboard, enable AuthKit and configure the following settings: +In the WorkOS Dashboard, go to **Connect → Configuration** and configure: - - Go to **Applications → Configuration** and enable **Dynamic Client Registration**. This allows MCP clients register with your application automatically. + + Enable **Dynamic Client Registration** (DCR) so MCP clients can register themselves. Alternatively, enable **Client ID Metadata Document** (CIMD) if your clients support it. + - ![Enable Dynamic Client Registration](./images/authkit/enable_dcr.png) + + 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. @@ -47,16 +50,18 @@ Create your FastMCP server file and use the `AuthKitProvider` to handle all the from fastmcp import FastMCP from fastmcp.server.auth.providers.workos import AuthKitProvider -# The AuthKitProvider automatically discovers WorkOS endpoints -# and configures JWT token validation +# 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://localhost:8000" # Use your actual server URL + 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: @@ -75,7 +80,7 @@ import asyncio auth = OAuth(additional_client_metadata={"token_endpoint_auth_method": "none"}) async def main(): - async with Client("http://localhost:8000/mcp", auth=auth) as client: + async with Client("http://127.0.0.1:8000/mcp", auth=auth) as client: assert await client.ping() if __name__ == "__main__": @@ -94,7 +99,7 @@ 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") + base_url=os.environ.get("BASE_URL", "https://your-server.com"), ) mcp = FastMCP(name="AuthKit Secured App", auth=auth) diff --git a/docs/integrations/descope.mdx b/docs/integrations/descope.mdx index dba9b3509..bfb6cd9c8 100644 --- a/docs/integrations/descope.mdx +++ b/docs/integrations/descope.mdx @@ -63,8 +63,8 @@ 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 + 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 diff --git a/docs/integrations/fastapi.mdx b/docs/integrations/fastapi.mdx index abdc1d0e7..83aa924f8 100644 --- a/docs/integrations/fastapi.mdx +++ b/docs/integrations/fastapi.mdx @@ -220,7 +220,7 @@ Because FastMCP's FastAPI integration is based on its [OpenAPI integration](/int ```python # Assumes the FastAPI app from above is already defined from fastmcp import FastMCP -from fastmcp.server.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType # Custom mapping rules mcp = FastMCP.from_fastapi( diff --git a/docs/integrations/github.mdx b/docs/integrations/github.mdx index 72e65d3ce..d493eb1ef 100644 --- a/docs/integrations/github.mdx +++ b/docs/integrations/github.mdx @@ -119,7 +119,7 @@ async def main(): # Test the protected tool result = await client.call_tool("get_user_info") - print(f"GitHub user: {result['github_user']}") + print(f"GitHub user: {result.data['github_user']}") if __name__ == "__main__": asyncio.run(main()) diff --git a/docs/integrations/keycloak.mdx b/docs/integrations/keycloak.mdx new file mode 100644 index 000000000..22d61f132 --- /dev/null +++ b/docs/integrations/keycloak.mdx @@ -0,0 +1,141 @@ +--- +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" + + + +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. + + +**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. + + +## 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"), + } +``` + + +**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. + + +## 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/integrations/mcp-json-configuration.mdx b/docs/integrations/mcp-json-configuration.mdx index b44b15e76..fec8ffc01 100644 --- a/docs/integrations/mcp-json-configuration.mdx +++ b/docs/integrations/mcp-json-configuration.mdx @@ -357,6 +357,98 @@ 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" + ] + } + } +} +``` + + +`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. + + ## Integration with MCP Clients The generated configuration works with any MCP-compatible application: diff --git a/docs/integrations/openai.mdx b/docs/integrations/openai.mdx index 6f88193f5..94ca82b40 100644 --- a/docs/integrations/openai.mdx +++ b/docs/integrations/openai.mdx @@ -178,8 +178,8 @@ if __name__ == "__main__": If you try to call the authenticated server with the same OpenAI code we wrote earlier, you'll get an error like this: -```python -pythonAPIStatusError: Error code: 424 - { +```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", diff --git a/docs/integrations/openapi.mdx b/docs/integrations/openapi.mdx index 88dc19b14..f5f2b3dfa 100644 --- a/docs/integrations/openapi.mdx +++ b/docs/integrations/openapi.mdx @@ -85,7 +85,7 @@ Each `RouteMap` specifies a combination of methods, patterns, and tags, as well Here is FastMCP's default rule: ```python -from fastmcp.server.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType DEFAULT_ROUTE_MAPPINGS = [ # All routes become tools @@ -101,7 +101,7 @@ For example, prior to FastMCP 2.8.0, GET requests were automatically mapped to ` ```python from fastmcp import FastMCP -from fastmcp.server.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType # Restore pre-2.8.0 semantic mapping semantic_maps = [ @@ -124,7 +124,7 @@ Here is a more complete example that uses custom route maps to convert all `GET` ```python from fastmcp import FastMCP -from fastmcp.server.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType mcp = FastMCP.from_openapi( openapi_spec=spec, @@ -164,7 +164,7 @@ You can use this to remove sensitive or internal routes by targeting them specif ```python from fastmcp import FastMCP -from fastmcp.server.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType mcp = FastMCP.from_openapi( openapi_spec=spec, @@ -180,7 +180,7 @@ Or you can use a catch-all rule to exclude everything that your maps don't handl ```python from fastmcp import FastMCP -from fastmcp.server.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType mcp = FastMCP.from_openapi( openapi_spec=spec, @@ -212,7 +212,8 @@ The `route_map_fn` is called on all routes, even those that matched `MCPType.EXC ```python from fastmcp import FastMCP -from fastmcp.server.openapi import RouteMap, MCPType, HTTPRoute +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.""" @@ -277,7 +278,7 @@ FastMCP provides several ways to add tags to your MCP components, allowing you t 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.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType mcp = FastMCP.from_openapi( openapi_spec=spec, @@ -368,12 +369,12 @@ Your `mcp_component_fn` is expected to modify the component in-place, not to ret ```python -from fastmcp.server.openapi import ( - HTTPRoute, +from fastmcp.server.providers.openapi import ( OpenAPITool, OpenAPIResource, OpenAPIResourceTemplate, ) +from fastmcp.utilities.openapi import HTTPRoute def customize_components( route: HTTPRoute, diff --git a/docs/patterns/contrib.mdx b/docs/patterns/contrib.mdx index d2f812f52..da757662e 100644 --- a/docs/patterns/contrib.mdx +++ b/docs/patterns/contrib.mdx @@ -18,7 +18,7 @@ The available modules can be viewed in the [contrib directory](https://github.co To use a contrib module, import it from the `fastmcp.contrib` package: -```python +```python test="skip" from fastmcp.contrib import my_module ``` diff --git a/docs/python-sdk-pages.json b/docs/python-sdk-pages.json new file mode 100644 index 000000000..86cfabc6b --- /dev/null +++ b/docs/python-sdk-pages.json @@ -0,0 +1,433 @@ +[ + "python-sdk/fastmcp-decorators", + "python-sdk/fastmcp-dependencies", + "python-sdk/fastmcp-exceptions", + "python-sdk/fastmcp-mcp_config", + "python-sdk/fastmcp-settings", + "python-sdk/fastmcp-telemetry", + "python-sdk/fastmcp-types", + { + "group": "fastmcp.apps", + "pages": [ + "python-sdk/fastmcp-apps-__init__", + "python-sdk/fastmcp-apps-app", + "python-sdk/fastmcp-apps-approval", + "python-sdk/fastmcp-apps-choice", + "python-sdk/fastmcp-apps-config", + "python-sdk/fastmcp-apps-file_upload", + "python-sdk/fastmcp-apps-form", + "python-sdk/fastmcp-apps-generative" + ] + }, + { + "group": "fastmcp.cli", + "pages": [ + "python-sdk/fastmcp-cli-__init__", + "python-sdk/fastmcp-cli-apps_dev", + "python-sdk/fastmcp-cli-auth", + "python-sdk/fastmcp-cli-cimd", + "python-sdk/fastmcp-cli-cli", + "python-sdk/fastmcp-cli-client", + "python-sdk/fastmcp-cli-discovery", + "python-sdk/fastmcp-cli-generate", + { + "group": "install", + "pages": [ + "python-sdk/fastmcp-cli-install-__init__", + "python-sdk/fastmcp-cli-install-claude_code", + "python-sdk/fastmcp-cli-install-claude_desktop", + "python-sdk/fastmcp-cli-install-cursor", + "python-sdk/fastmcp-cli-install-gemini_cli", + "python-sdk/fastmcp-cli-install-goose", + "python-sdk/fastmcp-cli-install-mcp_json", + "python-sdk/fastmcp-cli-install-shared", + "python-sdk/fastmcp-cli-install-stdio" + ] + }, + "python-sdk/fastmcp-cli-run", + "python-sdk/fastmcp-cli-tasks" + ] + }, + { + "group": "fastmcp.client", + "pages": [ + "python-sdk/fastmcp-client-__init__", + { + "group": "auth", + "pages": [ + "python-sdk/fastmcp-client-auth-__init__", + "python-sdk/fastmcp-client-auth-bearer", + "python-sdk/fastmcp-client-auth-oauth" + ] + }, + "python-sdk/fastmcp-client-client", + "python-sdk/fastmcp-client-elicitation", + "python-sdk/fastmcp-client-logging", + "python-sdk/fastmcp-client-messages", + { + "group": "mixins", + "pages": [ + "python-sdk/fastmcp-client-mixins-__init__", + "python-sdk/fastmcp-client-mixins-prompts", + "python-sdk/fastmcp-client-mixins-resources", + "python-sdk/fastmcp-client-mixins-task_management", + "python-sdk/fastmcp-client-mixins-tools" + ] + }, + "python-sdk/fastmcp-client-oauth_callback", + "python-sdk/fastmcp-client-progress", + "python-sdk/fastmcp-client-roots", + { + "group": "sampling", + "pages": [ + "python-sdk/fastmcp-client-sampling-__init__", + { + "group": "handlers", + "pages": [ + "python-sdk/fastmcp-client-sampling-handlers-__init__", + "python-sdk/fastmcp-client-sampling-handlers-anthropic", + "python-sdk/fastmcp-client-sampling-handlers-google_genai", + "python-sdk/fastmcp-client-sampling-handlers-openai" + ] + } + ] + }, + "python-sdk/fastmcp-client-tasks", + "python-sdk/fastmcp-client-telemetry", + { + "group": "transports", + "pages": [ + "python-sdk/fastmcp-client-transports-__init__", + "python-sdk/fastmcp-client-transports-base", + "python-sdk/fastmcp-client-transports-config", + "python-sdk/fastmcp-client-transports-http", + "python-sdk/fastmcp-client-transports-inference", + "python-sdk/fastmcp-client-transports-memory", + "python-sdk/fastmcp-client-transports-sse", + "python-sdk/fastmcp-client-transports-stdio" + ] + } + ] + }, + { + "group": "fastmcp.experimental", + "pages": [ + "python-sdk/fastmcp-experimental-__init__", + { + "group": "sampling", + "pages": [ + "python-sdk/fastmcp-experimental-sampling-__init__", + "python-sdk/fastmcp-experimental-sampling-handlers" + ] + }, + { + "group": "transforms", + "pages": [ + "python-sdk/fastmcp-experimental-transforms-__init__", + "python-sdk/fastmcp-experimental-transforms-code_mode" + ] + } + ] + }, + { + "group": "fastmcp.prompts", + "pages": [ + "python-sdk/fastmcp-prompts-__init__", + "python-sdk/fastmcp-prompts-base", + "python-sdk/fastmcp-prompts-function_prompt" + ] + }, + { + "group": "fastmcp.resources", + "pages": [ + "python-sdk/fastmcp-resources-__init__", + "python-sdk/fastmcp-resources-base", + "python-sdk/fastmcp-resources-function_resource", + "python-sdk/fastmcp-resources-template", + "python-sdk/fastmcp-resources-types" + ] + }, + { + "group": "fastmcp.server", + "pages": [ + "python-sdk/fastmcp-server-__init__", + "python-sdk/fastmcp-server-app", + "python-sdk/fastmcp-server-apps", + { + "group": "auth", + "pages": [ + "python-sdk/fastmcp-server-auth-__init__", + "python-sdk/fastmcp-server-auth-auth", + "python-sdk/fastmcp-server-auth-authorization", + "python-sdk/fastmcp-server-auth-cimd", + { + "group": "handlers", + "pages": [ + "python-sdk/fastmcp-server-auth-handlers-__init__", + "python-sdk/fastmcp-server-auth-handlers-authorize" + ] + }, + "python-sdk/fastmcp-server-auth-jwt_issuer", + "python-sdk/fastmcp-server-auth-middleware", + { + "group": "oauth_proxy", + "pages": [ + "python-sdk/fastmcp-server-auth-oauth_proxy-__init__", + "python-sdk/fastmcp-server-auth-oauth_proxy-consent", + "python-sdk/fastmcp-server-auth-oauth_proxy-models", + "python-sdk/fastmcp-server-auth-oauth_proxy-proxy", + "python-sdk/fastmcp-server-auth-oauth_proxy-ui" + ] + }, + "python-sdk/fastmcp-server-auth-oidc_proxy", + { + "group": "providers", + "pages": [ + "python-sdk/fastmcp-server-auth-providers-__init__", + "python-sdk/fastmcp-server-auth-providers-auth0", + "python-sdk/fastmcp-server-auth-providers-aws", + "python-sdk/fastmcp-server-auth-providers-azure", + "python-sdk/fastmcp-server-auth-providers-clerk", + "python-sdk/fastmcp-server-auth-providers-debug", + "python-sdk/fastmcp-server-auth-providers-descope", + "python-sdk/fastmcp-server-auth-providers-discord", + "python-sdk/fastmcp-server-auth-providers-github", + "python-sdk/fastmcp-server-auth-providers-google", + "python-sdk/fastmcp-server-auth-providers-in_memory", + "python-sdk/fastmcp-server-auth-providers-introspection", + "python-sdk/fastmcp-server-auth-providers-jwt", + "python-sdk/fastmcp-server-auth-providers-keycloak", + "python-sdk/fastmcp-server-auth-providers-oci", + "python-sdk/fastmcp-server-auth-providers-propelauth", + "python-sdk/fastmcp-server-auth-providers-scalekit", + "python-sdk/fastmcp-server-auth-providers-supabase", + "python-sdk/fastmcp-server-auth-providers-workos" + ] + }, + "python-sdk/fastmcp-server-auth-redirect_validation", + "python-sdk/fastmcp-server-auth-ssrf" + ] + }, + "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-http", + "python-sdk/fastmcp-server-lifespan", + "python-sdk/fastmcp-server-low_level", + { + "group": "middleware", + "pages": [ + "python-sdk/fastmcp-server-middleware-__init__", + "python-sdk/fastmcp-server-middleware-authorization", + "python-sdk/fastmcp-server-middleware-caching", + "python-sdk/fastmcp-server-middleware-dereference", + "python-sdk/fastmcp-server-middleware-error_handling", + "python-sdk/fastmcp-server-middleware-logging", + "python-sdk/fastmcp-server-middleware-middleware", + "python-sdk/fastmcp-server-middleware-ping", + "python-sdk/fastmcp-server-middleware-rate_limiting", + "python-sdk/fastmcp-server-middleware-response_limiting", + "python-sdk/fastmcp-server-middleware-timing", + "python-sdk/fastmcp-server-middleware-tool_injection" + ] + }, + { + "group": "mixins", + "pages": [ + "python-sdk/fastmcp-server-mixins-__init__", + "python-sdk/fastmcp-server-mixins-lifespan", + "python-sdk/fastmcp-server-mixins-mcp_operations", + "python-sdk/fastmcp-server-mixins-transport" + ] + }, + { + "group": "openapi", + "pages": [ + "python-sdk/fastmcp-server-openapi-__init__", + "python-sdk/fastmcp-server-openapi-components", + "python-sdk/fastmcp-server-openapi-routing", + "python-sdk/fastmcp-server-openapi-server" + ] + }, + { + "group": "providers", + "pages": [ + "python-sdk/fastmcp-server-providers-__init__", + "python-sdk/fastmcp-server-providers-addressing", + "python-sdk/fastmcp-server-providers-aggregate", + "python-sdk/fastmcp-server-providers-base", + "python-sdk/fastmcp-server-providers-fastmcp_provider", + "python-sdk/fastmcp-server-providers-filesystem", + "python-sdk/fastmcp-server-providers-filesystem_discovery", + { + "group": "local_provider", + "pages": [ + "python-sdk/fastmcp-server-providers-local_provider-__init__", + { + "group": "decorators", + "pages": [ + "python-sdk/fastmcp-server-providers-local_provider-decorators-__init__", + "python-sdk/fastmcp-server-providers-local_provider-decorators-prompts", + "python-sdk/fastmcp-server-providers-local_provider-decorators-resources", + "python-sdk/fastmcp-server-providers-local_provider-decorators-tools" + ] + }, + "python-sdk/fastmcp-server-providers-local_provider-local_provider" + ] + }, + { + "group": "openapi", + "pages": [ + "python-sdk/fastmcp-server-providers-openapi-__init__", + "python-sdk/fastmcp-server-providers-openapi-components", + "python-sdk/fastmcp-server-providers-openapi-provider", + "python-sdk/fastmcp-server-providers-openapi-routing" + ] + }, + "python-sdk/fastmcp-server-providers-prefab_synthesis", + "python-sdk/fastmcp-server-providers-proxy", + { + "group": "skills", + "pages": [ + "python-sdk/fastmcp-server-providers-skills-__init__", + "python-sdk/fastmcp-server-providers-skills-claude_provider", + "python-sdk/fastmcp-server-providers-skills-directory_provider", + "python-sdk/fastmcp-server-providers-skills-skill_provider", + "python-sdk/fastmcp-server-providers-skills-vendor_providers" + ] + }, + "python-sdk/fastmcp-server-providers-wrapped_provider" + ] + }, + "python-sdk/fastmcp-server-proxy", + { + "group": "sampling", + "pages": [ + "python-sdk/fastmcp-server-sampling-__init__", + "python-sdk/fastmcp-server-sampling-run", + "python-sdk/fastmcp-server-sampling-sampling_tool" + ] + }, + "python-sdk/fastmcp-server-server", + { + "group": "tasks", + "pages": [ + "python-sdk/fastmcp-server-tasks-__init__", + "python-sdk/fastmcp-server-tasks-capabilities", + "python-sdk/fastmcp-server-tasks-config", + "python-sdk/fastmcp-server-tasks-context", + "python-sdk/fastmcp-server-tasks-elicitation", + "python-sdk/fastmcp-server-tasks-handlers", + "python-sdk/fastmcp-server-tasks-keys", + "python-sdk/fastmcp-server-tasks-notifications", + "python-sdk/fastmcp-server-tasks-requests", + "python-sdk/fastmcp-server-tasks-routing", + "python-sdk/fastmcp-server-tasks-subscriptions" + ] + }, + "python-sdk/fastmcp-server-telemetry", + { + "group": "transforms", + "pages": [ + "python-sdk/fastmcp-server-transforms-__init__", + "python-sdk/fastmcp-server-transforms-catalog", + "python-sdk/fastmcp-server-transforms-namespace", + "python-sdk/fastmcp-server-transforms-prompts_as_tools", + "python-sdk/fastmcp-server-transforms-resources_as_tools", + { + "group": "search", + "pages": [ + "python-sdk/fastmcp-server-transforms-search-__init__", + "python-sdk/fastmcp-server-transforms-search-base", + "python-sdk/fastmcp-server-transforms-search-bm25", + "python-sdk/fastmcp-server-transforms-search-regex" + ] + }, + "python-sdk/fastmcp-server-transforms-tool_transform", + "python-sdk/fastmcp-server-transforms-version_filter", + "python-sdk/fastmcp-server-transforms-visibility" + ] + } + ] + }, + { + "group": "fastmcp.tools", + "pages": [ + "python-sdk/fastmcp-tools-__init__", + "python-sdk/fastmcp-tools-base", + "python-sdk/fastmcp-tools-function_parsing", + "python-sdk/fastmcp-tools-function_tool", + "python-sdk/fastmcp-tools-tool_transform" + ] + }, + { + "group": "fastmcp.utilities", + "pages": [ + "python-sdk/fastmcp-utilities-__init__", + "python-sdk/fastmcp-utilities-async_utils", + "python-sdk/fastmcp-utilities-auth", + "python-sdk/fastmcp-utilities-cli", + "python-sdk/fastmcp-utilities-components", + "python-sdk/fastmcp-utilities-docstring_parsing", + "python-sdk/fastmcp-utilities-exceptions", + "python-sdk/fastmcp-utilities-http", + "python-sdk/fastmcp-utilities-inspect", + "python-sdk/fastmcp-utilities-json_schema", + "python-sdk/fastmcp-utilities-json_schema_type", + "python-sdk/fastmcp-utilities-lifespan", + "python-sdk/fastmcp-utilities-logging", + { + "group": "mcp_server_config", + "pages": [ + "python-sdk/fastmcp-utilities-mcp_server_config-__init__", + { + "group": "v1", + "pages": [ + "python-sdk/fastmcp-utilities-mcp_server_config-v1-__init__", + { + "group": "environments", + "pages": [ + "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-__init__", + "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base", + "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv" + ] + }, + "python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config", + { + "group": "sources", + "pages": [ + "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-__init__", + "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base", + "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem" + ] + } + ] + } + ] + }, + "python-sdk/fastmcp-utilities-mime", + { + "group": "openapi", + "pages": [ + "python-sdk/fastmcp-utilities-openapi-__init__", + "python-sdk/fastmcp-utilities-openapi-director", + "python-sdk/fastmcp-utilities-openapi-formatters", + "python-sdk/fastmcp-utilities-openapi-json_schema_converter", + "python-sdk/fastmcp-utilities-openapi-models", + "python-sdk/fastmcp-utilities-openapi-parser", + "python-sdk/fastmcp-utilities-openapi-schemas" + ] + }, + "python-sdk/fastmcp-utilities-pagination", + "python-sdk/fastmcp-utilities-skills", + "python-sdk/fastmcp-utilities-tests", + "python-sdk/fastmcp-utilities-timeout", + "python-sdk/fastmcp-utilities-token_cache", + "python-sdk/fastmcp-utilities-types", + "python-sdk/fastmcp-utilities-ui", + "python-sdk/fastmcp-utilities-version_check", + "python-sdk/fastmcp-utilities-versions" + ] + } +] diff --git a/docs/python-sdk/fastmcp-apps-app.mdx b/docs/python-sdk/fastmcp-apps-app.mdx index 759e487ae..e6053277e 100644 --- a/docs/python-sdk/fastmcp-apps-app.mdx +++ b/docs/python-sdk/fastmcp-apps-app.mdx @@ -35,7 +35,7 @@ Usage:: ## Classes -### `FastMCPApp` +### `FastMCPApp` 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` +#### `tool` ```python tool(self, name_or_fn: F) -> F ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[F], F] ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Any @@ -83,19 +83,19 @@ Supports multiple calling patterns:: def save(name: str): ... -#### `ui` +#### `ui` ```python ui(self, name_or_fn: F) -> F ``` -#### `ui` +#### `ui` ```python ui(self, name_or_fn: str | None = None) -> Callable[[F], F] ``` -#### `ui` +#### `ui` ```python ui(self, name_or_fn: str | AnyFunction | None = None) -> Any @@ -119,7 +119,7 @@ Supports multiple calling patterns:: def dashboard() -> Component: ... -#### `add_tool` +#### `add_tool` ```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` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] ``` -#### `run` +#### `run` ```python run(self, transport: Literal['stdio', 'http', 'sse', 'streamable-http'] | None = None, **kwargs: Any) -> None diff --git a/docs/python-sdk/fastmcp-apps-approval.mdx b/docs/python-sdk/fastmcp-apps-approval.mdx new file mode 100644 index 000000000..461a55c52 --- /dev/null +++ b/docs/python-sdk/fastmcp-apps-approval.mdx @@ -0,0 +1,58 @@ +--- +title: approval +sidebarTitle: approval +--- + +# `fastmcp.apps.approval` + + +Approval — a Provider that adds human-in-the-loop approval to any server. + +The LLM presents a summary of what it's about to do, and the user +approves or rejects via buttons. The result is sent back into the +conversation as a message, prompting the LLM's next turn. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps.approval import Approval + + mcp = FastMCP("My Server") + mcp.add_provider(Approval()) + + +## Classes + +### `Approval` + + +A Provider that adds human-in-the-loop approval to a server. + +The LLM calls the ``request_approval`` tool with a summary and +optional details. The user sees an approval card with Approve and +Reject buttons. Clicking either sends a message back into the +conversation (via ``SendMessage``), triggering the LLM's next turn. + +The message appears as if the user sent it, so the LLM sees +something like ``'"Deploy v3.2 to production" is APPROVED'``. + +Example:: + + from fastmcp import FastMCP + from fastmcp.apps.approval import Approval + + mcp = FastMCP("My Server") + mcp.add_provider(Approval()) + +Customized:: + + Approval( + title="Deploy Gate", + approve_text="Ship it", + approve_variant="default", + reject_text="Abort", + reject_variant="destructive", + ) + diff --git a/docs/python-sdk/fastmcp-apps-choice.mdx b/docs/python-sdk/fastmcp-apps-choice.mdx new file mode 100644 index 000000000..4f693f898 --- /dev/null +++ b/docs/python-sdk/fastmcp-apps-choice.mdx @@ -0,0 +1,44 @@ +--- +title: choice +sidebarTitle: choice +--- + +# `fastmcp.apps.choice` + + +Choice — a Provider that lets the user pick from a set of options. + +The LLM presents options, the user clicks one, and the selection +flows back into the conversation as a message. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps.choice import Choice + + mcp = FastMCP("My Server") + mcp.add_provider(Choice()) + + +## Classes + +### `Choice` + + +A Provider that lets the user choose from a set of options. + +The LLM calls ``choose`` with a prompt and a list of options. +The user sees a card with one button per option. Clicking a button +sends the selection back into the conversation via ``SendMessage``, +triggering the LLM's next turn. + +Example:: + + from fastmcp import FastMCP + from fastmcp.apps.choice import Choice + + mcp = FastMCP("My Server") + mcp.add_provider(Choice()) + diff --git a/docs/python-sdk/fastmcp-apps-file_upload.mdx b/docs/python-sdk/fastmcp-apps-file_upload.mdx new file mode 100644 index 000000000..a77d9fa6a --- /dev/null +++ b/docs/python-sdk/fastmcp-apps-file_upload.mdx @@ -0,0 +1,144 @@ +--- +title: file_upload +sidebarTitle: file_upload +--- + +# `fastmcp.apps.file_upload` + + +FileUpload — a Provider that adds drag-and-drop file upload to any server. + +Lets users upload files directly to the server through an interactive UI, +bypassing the LLM context window entirely. The LLM can then read and work +with uploaded files through model-visible tools. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps import FileUpload + + mcp = FastMCP("My Server") + mcp.add_provider(FileUpload()) + +For custom persistence, override the storage methods:: + + class S3Upload(FileUpload): + def on_store(self, files, ctx): + # write to S3, return summaries + ... + + def on_list(self, ctx): + # list from S3 + ... + + def on_read(self, name, ctx): + # read from S3 + ... + + +## Classes + +### `FileUpload` + + +A Provider that adds file upload capabilities to a server. + +Registers a drag-and-drop UI tool, a backend storage tool, and +model-visible tools for listing and reading uploaded files. + +Files are scoped by MCP session and stored in memory by default. +Override ``on_store``, ``on_list``, and ``on_read`` for custom +persistence (filesystem, S3, database, etc.). Each method receives +the current ``Context``, giving access to session ID, auth tokens, +and request metadata for partitioning and authorization. + +**Session scoping:** The default storage uses ``ctx.session_id`` to +isolate files by session. This works with stdio, SSE, and stateful +HTTP transports. In **stateless HTTP** mode, each request creates a +new session, so files won't persist across requests. For stateless +deployments, override the storage methods to partition by a stable +identifier from the auth context:: + + class UserScopedUpload(FileUpload): + def on_store(self, files, ctx): + user_id = ctx.access_token["sub"] + ... + +Example:: + + from fastmcp import FastMCP + from fastmcp.apps.file_upload import FileUpload + + mcp = FastMCP("My Server") + mcp.add_provider(FileUpload()) + + +**Methods:** + +#### `on_store` + +```python +on_store(self, files: list[dict[str, Any]], ctx: Context) -> list[dict[str, Any]] +``` + +Store uploaded files and return summaries. + +**Args:** +- `files`: List of file dicts, each with ``name``, ``size``, +``type``, and ``data`` (base64-encoded content). +- `ctx`: The current request context. Use for session ID, +auth tokens, or any metadata needed for partitioning. + +Override this method for custom persistence. The default +implementation stores files in memory, scoped by +``_get_scope_key(ctx)``. + +**Returns:** +- List of file summary dicts (``name``, ``type``, ``size``, +- ``size_display``, ``uploaded_at``). + + +#### `on_list` + +```python +on_list(self, ctx: Context) -> list[dict[str, Any]] +``` + +List all stored files. + +**Args:** +- `ctx`: The current request context. + +Override this method for custom persistence. The default +implementation returns files from the current scope. + +**Returns:** +- List of file summary dicts. + + +#### `on_read` + +```python +on_read(self, name: str, ctx: Context) -> dict[str, Any] +``` + +Read a file's contents by name. + +**Args:** +- `name`: The filename to read. +- `ctx`: The current request context. + +Override this method for custom persistence. The default +implementation reads from the current scope's in-memory store. +Text files are decoded from base64; binary files return a +truncated base64 preview. + +**Returns:** +- Dict with file metadata and ``content`` (text) or +- ``content_base64`` (binary preview). + +**Raises:** +- `ValueError`: If the file is not found. + diff --git a/docs/python-sdk/fastmcp-apps-form.mdx b/docs/python-sdk/fastmcp-apps-form.mdx new file mode 100644 index 000000000..183a2a04b --- /dev/null +++ b/docs/python-sdk/fastmcp-apps-form.mdx @@ -0,0 +1,69 @@ +--- +title: form +sidebarTitle: form +--- + +# `fastmcp.apps.form` + + +FormInput — a Provider that collects structured input from the user. + +Define a Pydantic model for the data you need, and ``FormInput`` +generates a form UI. The user fills it out, the submission is +validated, and an optional callback processes the result. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from pydantic import BaseModel + from fastmcp import FastMCP + from fastmcp.apps.form import FormInput + + class ShippingAddress(BaseModel): + street: str + city: str + state: str + zip_code: str + + mcp = FastMCP("My Server") + mcp.add_provider(FormInput(model=ShippingAddress)) + + +## Classes + +### `FormInput` + + +A Provider that collects structured input via a Pydantic model. + +Define a model for the data you need, and ``FormInput`` generates +a form from it using ``Form.from_model()``. Field types, labels, +descriptions, and validation are all derived from the model. + +Optionally provide an ``on_submit`` callback to process the +validated data. The callback receives a model instance and returns +a string that goes back to the LLM. Without a callback, the +validated JSON is sent directly. + +Example:: + + from pydantic import BaseModel + from fastmcp import FastMCP + from fastmcp.apps.form import FormInput + + class Contact(BaseModel): + name: str + email: str + + mcp = FastMCP("My Server") + mcp.add_provider(FormInput(model=Contact)) + +With a callback:: + + def save_contact(contact: Contact) -> str: + db.insert(contact.model_dump()) + return f"Saved {contact.name}" + + mcp.add_provider(FormInput(model=Contact, on_submit=save_contact)) + diff --git a/docs/python-sdk/fastmcp-cli-apps_dev.mdx b/docs/python-sdk/fastmcp-cli-apps_dev.mdx index 5ca1aa6ec..2cbe0fd3e 100644 --- a/docs/python-sdk/fastmcp-cli-apps_dev.mdx +++ b/docs/python-sdk/fastmcp-cli-apps_dev.mdx @@ -32,7 +32,7 @@ Startup sequence ## Functions -### `run_dev_apps` +### `run_dev_apps` ```python run_dev_apps(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx index b8bf7e0de..d396af571 100644 --- a/docs/python-sdk/fastmcp-cli-cli.mdx +++ b/docs/python-sdk/fastmcp-cli-cli.mdx @@ -91,7 +91,7 @@ fastmcp run server.py -- --config config.json --debug - `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect -### `inspect` +### `inspect` ```python inspect(server_spec: str | None = None) -> None @@ -122,7 +122,7 @@ fastmcp inspect # auto-detect fastmcp.json - `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json -### `prepare` +### `prepare` ```python prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None diff --git a/docs/python-sdk/fastmcp-cli-run.mdx b/docs/python-sdk/fastmcp-cli-run.mdx index 0b341c459..1b7af37a5 100644 --- a/docs/python-sdk/fastmcp-cli-run.mdx +++ b/docs/python-sdk/fastmcp-cli-run.mdx @@ -10,7 +10,7 @@ FastMCP run command implementation with enhanced type hints. ## Functions -### `is_url` +### `is_url` ```python is_url(path: str) -> bool @@ -20,7 +20,7 @@ is_url(path: str) -> bool Check if a string is a URL. -### `create_client_server` +### `create_client_server` ```python create_client_server(url: str) -> Any @@ -36,7 +36,7 @@ Create a FastMCP server from a client URL. - A FastMCP server instance -### `create_mcp_config_server` +### `create_mcp_config_server` ```python create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None] @@ -46,7 +46,7 @@ create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None] Create a FastMCP server from a MCPConfig. -### `load_mcp_server_config` +### `load_mcp_server_config` ```python load_mcp_server_config(config_path: Path) -> MCPServerConfig @@ -62,7 +62,7 @@ Load a FastMCP configuration from a fastmcp.json file. - MCPServerConfig object -### `run_command` +### `run_command` ```python run_command(server_spec: str, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, server_args: list[str] | None = None, show_banner: bool = True, use_direct_import: bool = False, skip_source: bool = False, stateless: bool = False) -> None @@ -85,7 +85,7 @@ Run a MCP server or connect to a remote one. - `stateless`: Whether to run in stateless mode (no session) -### `run_module_command` +### `run_module_command` ```python run_module_command(module_name: str) -> None @@ -104,7 +104,7 @@ with environment setup (e.g. ``UVEnvironment.build_command``). - `extra_args`: Extra arguments forwarded after the module name. -### `run_v1_server_async` +### `run_v1_server_async` ```python run_v1_server_async(server: FastMCP1x, host: str | None = None, port: int | None = None, transport: TransportType | None = None) -> None @@ -120,7 +120,7 @@ Run a FastMCP 1.x server using async methods. - `transport`: Transport protocol to use -### `run_with_reload` +### `run_with_reload` ```python run_with_reload(cmd: list[str], reload_dirs: list[Path] | None = None, is_stdio: bool = False) -> None diff --git a/docs/python-sdk/fastmcp-client-client.mdx b/docs/python-sdk/fastmcp-client-client.mdx index efbf56f08..f4285ebe9 100644 --- a/docs/python-sdk/fastmcp-client-client.mdx +++ b/docs/python-sdk/fastmcp-client-client.mdx @@ -7,7 +7,7 @@ sidebarTitle: client ## Classes -### `ClientSessionState` +### `ClientSessionState` Holds all session-related state for a Client instance. @@ -16,13 +16,13 @@ This allows clean separation of configuration (which is copied) from session state (which should be fresh for each new client instance). -### `CallToolResult` +### `CallToolResult` Parsed result from a tool call. -### `Client` +### `Client` MCP client that delegates connection management to a Transport instance. @@ -85,7 +85,7 @@ async with client: **Methods:** -#### `session` +#### `session` ```python session(self) -> ClientSession @@ -94,7 +94,7 @@ session(self) -> ClientSession Get the current active session. Raises RuntimeError if not connected. -#### `initialize_result` +#### `initialize_result` ```python initialize_result(self) -> mcp.types.InitializeResult | None @@ -103,7 +103,7 @@ initialize_result(self) -> mcp.types.InitializeResult | None Get the result of the initialization request. -#### `set_roots` +#### `set_roots` ```python set_roots(self, roots: RootsList | RootsHandler) -> None @@ -112,7 +112,7 @@ set_roots(self, roots: RootsList | RootsHandler) -> None Set the roots for the client. This does not automatically call `send_roots_list_changed`. -#### `set_sampling_callback` +#### `set_sampling_callback` ```python set_sampling_callback(self, sampling_callback: SamplingHandler, sampling_capabilities: mcp.types.SamplingCapability | None = None) -> None @@ -121,7 +121,7 @@ set_sampling_callback(self, sampling_callback: SamplingHandler, sampling_capabil Set the sampling callback for the client. -#### `set_elicitation_callback` +#### `set_elicitation_callback` ```python set_elicitation_callback(self, elicitation_callback: ElicitationHandler) -> None @@ -130,7 +130,7 @@ set_elicitation_callback(self, elicitation_callback: ElicitationHandler) -> None Set the elicitation callback for the client. -#### `is_connected` +#### `is_connected` ```python is_connected(self) -> bool @@ -139,7 +139,7 @@ is_connected(self) -> bool Check if the client is currently connected. -#### `new` +#### `new` ```python new(self) -> Client[ClientTransportT] @@ -155,7 +155,7 @@ share state with the original client. - A new Client instance with the same configuration but disconnected state. -#### `initialize` +#### `initialize` ```python initialize(self, timeout: datetime.timedelta | float | int | None = None) -> mcp.types.InitializeResult @@ -183,13 +183,13 @@ capabilities, protocol version, and optional instructions. - `RuntimeError`: If the client is not connected or initialization times out. -#### `close` +#### `close` ```python close(self) ``` -#### `ping` +#### `ping` ```python ping(self) -> bool @@ -198,7 +198,7 @@ ping(self) -> bool Send a ping request. -#### `cancel` +#### `cancel` ```python cancel(self, request_id: str | int, reason: str | None = None) -> None @@ -207,7 +207,7 @@ cancel(self, request_id: str | int, reason: str | None = None) -> None Send a cancellation notification for an in-progress request. -#### `progress` +#### `progress` ```python progress(self, progress_token: str | int, progress: float, total: float | None = None, message: str | None = None) -> None @@ -216,7 +216,7 @@ progress(self, progress_token: str | int, progress: float, total: float | None = Send a progress notification. -#### `set_logging_level` +#### `set_logging_level` ```python set_logging_level(self, level: mcp.types.LoggingLevel) -> None @@ -225,7 +225,7 @@ set_logging_level(self, level: mcp.types.LoggingLevel) -> None Send a logging/setLevel request. -#### `send_roots_list_changed` +#### `send_roots_list_changed` ```python send_roots_list_changed(self) -> None @@ -234,7 +234,7 @@ send_roots_list_changed(self) -> None Send a roots/list_changed notification. -#### `complete_mcp` +#### `complete_mcp` ```python complete_mcp(self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], context_arguments: dict[str, Any] | None = None) -> mcp.types.CompleteResult @@ -257,7 +257,7 @@ containing the completion and any additional metadata. - `McpError`: If the request results in a TimeoutError | JSONRPCError -#### `complete` +#### `complete` ```python complete(self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], context_arguments: dict[str, Any] | None = None) -> mcp.types.Completion @@ -279,7 +279,7 @@ include with the completion request. Defaults to None. - `McpError`: If the request results in a TimeoutError | JSONRPCError -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str diff --git a/docs/python-sdk/fastmcp-client-tasks.mdx b/docs/python-sdk/fastmcp-client-tasks.mdx index 547d9bc92..874089b71 100644 --- a/docs/python-sdk/fastmcp-client-tasks.mdx +++ b/docs/python-sdk/fastmcp-client-tasks.mdx @@ -114,8 +114,8 @@ with fallback to polling (reliable). Optimally wakes up immediately on status changes when server sends notifications/tasks/status. **Args:** -- `state`: Desired state ('submitted', 'working', 'completed', 'failed'). - If None, waits for any terminal state (completed/failed) +- `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:** @@ -125,7 +125,7 @@ on status changes when server sends notifications/tasks/status. - `TimeoutError`: If desired state not reached within timeout -#### `cancel` +#### `cancel` ```python cancel(self) -> None @@ -140,7 +140,7 @@ Note: If server executed immediately (graceful degradation), this is a no-op as there's no server-side task to cancel. -### `ToolTask` +### `ToolTask` Represents a tool call that may execute in background or immediately. @@ -151,7 +151,7 @@ or executes synchronously (graceful degradation per SEP-1686). **Methods:** -#### `result` +#### `result` ```python result(self) -> CallToolResult @@ -166,7 +166,7 @@ Otherwise waits for background task to complete and retrieves result. - The parsed tool result (same as call_tool returns) -### `PromptTask` +### `PromptTask` Represents a prompt call that may execute in background or immediately. @@ -177,7 +177,7 @@ or executes synchronously (graceful degradation per SEP-1686). **Methods:** -#### `result` +#### `result` ```python result(self) -> mcp.types.GetPromptResult @@ -192,7 +192,7 @@ Otherwise waits for background task to complete and retrieves result. - The prompt result with messages and description -### `ResourceTask` +### `ResourceTask` Represents a resource read that may execute in background or immediately. @@ -203,7 +203,7 @@ or executes synchronously (graceful degradation per SEP-1686). **Methods:** -#### `result` +#### `result` ```python result(self) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] diff --git a/docs/python-sdk/fastmcp-client-transports-http.mdx b/docs/python-sdk/fastmcp-client-transports-http.mdx index a0db1401d..48a9559e3 100644 --- a/docs/python-sdk/fastmcp-client-transports-http.mdx +++ b/docs/python-sdk/fastmcp-client-transports-http.mdx @@ -18,19 +18,19 @@ Transport implementation that connects to an MCP server via Streamable HTTP Requ **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` -#### `get_session_id` +#### `get_session_id` ```python get_session_id(self) -> str | None ``` -#### `close` +#### `close` ```python close(self) diff --git a/docs/python-sdk/fastmcp-client-transports-sse.mdx b/docs/python-sdk/fastmcp-client-transports-sse.mdx index a65dace46..6a78d9325 100644 --- a/docs/python-sdk/fastmcp-client-transports-sse.mdx +++ b/docs/python-sdk/fastmcp-client-transports-sse.mdx @@ -18,7 +18,7 @@ Transport implementation that connects to an MCP server via Server-Sent Events. **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] diff --git a/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx b/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx index 9dc66b06a..4b954336b 100644 --- a/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx +++ b/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx @@ -7,7 +7,7 @@ sidebarTitle: code_mode ## Classes -### `SandboxProvider` +### `SandboxProvider` Interface for executing LLM-generated Python code in a sandbox. @@ -20,13 +20,13 @@ sandbox — never with plain ``exec()``. Use ``MontySandboxProvider`` **Methods:** -#### `run` +#### `run` ```python run(self, code: str) -> Any ``` -### `MontySandboxProvider` +### `MontySandboxProvider` Sandbox provider backed by `pydantic-monty`. @@ -41,13 +41,13 @@ leave that limit uncapped. **Methods:** -#### `run` +#### `run` ```python run(self, code: str) -> Any ``` -### `Search` +### `Search` Discovery tool factory that searches the catalog by query. @@ -64,7 +64,7 @@ Defaults to BM25 ranking. The LLM can override this per call. ``None`` means no limit. -### `GetSchemas` +### `GetSchemas` Discovery tool factory that returns schemas for tools by name. @@ -78,7 +78,7 @@ types, and required markers. ``"full"`` returns the complete JSON schema. -### `GetTags` +### `GetTags` Discovery tool factory that lists tool tags from the catalog. @@ -93,7 +93,7 @@ without tags appear under ``"untagged"``. ``"full"`` lists all tools under each tag. -### `ListTools` +### `ListTools` Discovery tool factory that lists all tools in the catalog. @@ -106,7 +106,7 @@ Discovery tool factory that lists all tools in the catalog. ``"full"`` returns the complete JSON schema. -### `CodeMode` +### `CodeMode` Transform that collapses all tools into discovery + execute meta-tools. @@ -123,13 +123,13 @@ environment with ``call_tool(name, params)`` in scope. **Methods:** -#### `transform_tools` +#### `transform_tools` ```python transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` -#### `get_tool` +#### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None diff --git a/docs/python-sdk/fastmcp-prompts-function_prompt.mdx b/docs/python-sdk/fastmcp-prompts-function_prompt.mdx index 92f369d78..82b55e6dd 100644 --- a/docs/python-sdk/fastmcp-prompts-function_prompt.mdx +++ b/docs/python-sdk/fastmcp-prompts-function_prompt.mdx @@ -10,7 +10,7 @@ Standalone @prompt decorator for FastMCP. ## Functions -### `prompt` +### `prompt` ```python prompt(name_or_fn: str | Callable[..., Any] | None = None) -> Any @@ -25,19 +25,19 @@ using mcp.add_prompt(). ## Classes -### `DecoratedPrompt` +### `DecoratedPrompt` Protocol for functions decorated with @prompt. -### `PromptMeta` +### `PromptMeta` Metadata attached to functions by the @prompt decorator. -### `FunctionPrompt` +### `FunctionPrompt` A prompt that is a function. @@ -45,7 +45,7 @@ A prompt that is a function. **Methods:** -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionPrompt @@ -66,7 +66,7 @@ The function can return: - PromptResult: used directly -#### `render` +#### `render` ```python render(self, arguments: dict[str, Any] | None = None) -> PromptResult @@ -75,7 +75,7 @@ render(self, arguments: dict[str, Any] | None = None) -> PromptResult Render the prompt with arguments. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -83,11 +83,8 @@ register_with_docket(self, docket: Docket) -> None Register this prompt with docket for background execution. -FunctionPrompt registers the underlying function, which has the user's -Depends parameters for docket to resolve. - -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any] | None, **kwargs: Any) -> Execution diff --git a/docs/python-sdk/fastmcp-resources-base.mdx b/docs/python-sdk/fastmcp-resources-base.mdx index aab4a1dd7..d9f76a057 100644 --- a/docs/python-sdk/fastmcp-resources-base.mdx +++ b/docs/python-sdk/fastmcp-resources-base.mdx @@ -10,7 +10,7 @@ Base classes and interfaces for FastMCP resources. ## Classes -### `ResourceContent` +### `ResourceContent` Wrapper for resource content with optional MIME type and metadata. @@ -21,7 +21,7 @@ other types (dict, list, BaseModel, etc.) are automatically JSON-serialized. **Methods:** -#### `to_mcp_resource_contents` +#### `to_mcp_resource_contents` ```python to_mcp_resource_contents(self, uri: AnyUrl | str) -> mcp.types.TextResourceContents | mcp.types.BlobResourceContents @@ -36,7 +36,7 @@ Convert to MCP resource contents type. - TextResourceContents for str content, BlobResourceContents for bytes -### `ResourceResult` +### `ResourceResult` Canonical result type for resource reads. @@ -47,7 +47,7 @@ per-item MIME types, and metadata at both the item and result level. **Methods:** -#### `to_mcp_result` +#### `to_mcp_result` ```python to_mcp_result(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult @@ -62,7 +62,7 @@ Convert to MCP ReadResourceResult. - MCP ReadResourceResult with converted contents -### `Resource` +### `Resource` Base class for all resources. @@ -70,13 +70,13 @@ Base class for all resources. **Methods:** -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any], uri: str | AnyUrl) -> FunctionResource ``` -#### `set_default_mime_type` +#### `set_default_mime_type` ```python set_default_mime_type(cls, mime_type: str | None) -> str @@ -85,7 +85,7 @@ set_default_mime_type(cls, mime_type: str | None) -> str Set default MIME type if not provided. -#### `set_default_name` +#### `set_default_name` ```python set_default_name(self) -> Self @@ -94,7 +94,7 @@ set_default_name(self) -> Self Set default name from URI if not provided. -#### `read` +#### `read` ```python read(self) -> str | bytes | ResourceResult @@ -108,7 +108,7 @@ Subclasses implement this to return resource data. Supported return types: - ResourceResult: Full control over contents and result-level meta -#### `convert_result` +#### `convert_result` ```python convert_result(self, raw_value: Any) -> ResourceResult @@ -131,7 +131,7 @@ MCP Apps CSP/permissions) is propagated to each content item so that hosts can read it from the ``resources/read`` response. -#### `to_mcp_resource` +#### `to_mcp_resource` ```python to_mcp_resource(self, **overrides: Any) -> SDKResource @@ -140,7 +140,7 @@ to_mcp_resource(self, **overrides: Any) -> SDKResource Convert the resource to an SDKResource. -#### `key` +#### `key` ```python key(self) -> str @@ -149,7 +149,7 @@ key(self) -> str The globally unique lookup key for this resource. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -158,7 +158,7 @@ register_with_docket(self, docket: Docket) -> None Register this resource with docket for background execution. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, **kwargs: Any) -> Execution @@ -173,7 +173,7 @@ Schedule this resource for background execution via docket. - `**kwargs`: Additional kwargs passed to docket.add() -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-resources-function_resource.mdx b/docs/python-sdk/fastmcp-resources-function_resource.mdx index 4977f0d58..24aa2052c 100644 --- a/docs/python-sdk/fastmcp-resources-function_resource.mdx +++ b/docs/python-sdk/fastmcp-resources-function_resource.mdx @@ -10,7 +10,7 @@ Standalone @resource decorator for FastMCP. ## Functions -### `resource` +### `resource` ```python resource(uri: str) -> Callable[[F], F] @@ -71,7 +71,7 @@ individual parameters must not be passed. Cannot be used together with metadata parameter. -#### `read` +#### `read` ```python read(self) -> str | bytes | ResourceResult @@ -80,7 +80,7 @@ read(self) -> str | bytes | ResourceResult Read the resource by calling the wrapped function. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -88,6 +88,3 @@ register_with_docket(self, docket: Docket) -> None Register this resource with docket for background execution. -FunctionResource registers the underlying function, which has the user's -Depends parameters for docket to resolve. - diff --git a/docs/python-sdk/fastmcp-resources-template.mdx b/docs/python-sdk/fastmcp-resources-template.mdx index 06d8ef8ed..7fc0cf305 100644 --- a/docs/python-sdk/fastmcp-resources-template.mdx +++ b/docs/python-sdk/fastmcp-resources-template.mdx @@ -52,9 +52,23 @@ Supports RFC 6570 URI templates: - Query params: `{?var1,var2}` +### `expand_uri_template` + +```python +expand_uri_template(uri_template: str, params: dict[str, Any]) -> str +``` + + +Expand a URI template with parameters — inverse of `match_uri_template`. + +Supports the same RFC 6570 subset: +- Path params: `{var}`, `{var*}` +- Query params: `{?var1,var2}` + + ## Classes -### `ResourceTemplate` +### `ResourceTemplate` A template for dynamically creating resources. @@ -62,13 +76,13 @@ A template for dynamically creating resources. **Methods:** -#### `from_function` +#### `from_function` ```python from_function(fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None) -> FunctionResourceTemplate ``` -#### `set_default_mime_type` +#### `set_default_mime_type` ```python set_default_mime_type(cls, mime_type: str | None) -> str @@ -77,7 +91,7 @@ set_default_mime_type(cls, mime_type: str | None) -> str Set default MIME type if not provided. -#### `matches` +#### `matches` ```python matches(self, uri: str) -> dict[str, Any] | None @@ -86,7 +100,7 @@ matches(self, uri: str) -> dict[str, Any] | None Check if URI matches template and extract parameters. -#### `read` +#### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult @@ -95,7 +109,7 @@ read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult Read the resource content. -#### `convert_result` +#### `convert_result` ```python convert_result(self, raw_value: Any) -> ResourceResult @@ -111,7 +125,7 @@ Handles ResourceResult passthrough and converts raw values using ResourceResult's normalization. -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource @@ -123,7 +137,7 @@ The base implementation does not support background tasks. Use FunctionResourceTemplate for task support. -#### `to_mcp_template` +#### `to_mcp_template` ```python to_mcp_template(self, **overrides: Any) -> SDKResourceTemplate @@ -132,7 +146,7 @@ to_mcp_template(self, **overrides: Any) -> SDKResourceTemplate Convert the resource template to an SDKResourceTemplate. -#### `from_mcp_template` +#### `from_mcp_template` ```python from_mcp_template(cls, mcp_template: SDKResourceTemplate) -> ResourceTemplate @@ -141,7 +155,7 @@ from_mcp_template(cls, mcp_template: SDKResourceTemplate) -> ResourceTemplate Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object. -#### `key` +#### `key` ```python key(self) -> str @@ -150,7 +164,7 @@ key(self) -> str The globally unique lookup key for this template. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -159,7 +173,7 @@ register_with_docket(self, docket: Docket) -> None Register this template with docket for background execution. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, params: dict[str, Any], **kwargs: Any) -> Execution @@ -175,13 +189,13 @@ Schedule this template for background execution via docket. - `**kwargs`: Additional kwargs passed to docket.add() -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FunctionResourceTemplate` +### `FunctionResourceTemplate` A template for dynamically creating resources. @@ -189,7 +203,7 @@ A template for dynamically creating resources. **Methods:** -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource @@ -198,7 +212,7 @@ create_resource(self, uri: str, params: dict[str, Any]) -> Resource Create a resource from the template with the given parameters. -#### `read` +#### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult @@ -207,7 +221,7 @@ read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult Read the resource content. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -215,11 +229,8 @@ register_with_docket(self, docket: Docket) -> None Register this template with docket for background execution. -FunctionResourceTemplate registers the underlying function, which has the -user's Depends parameters for docket to resolve. - -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, params: dict[str, Any], **kwargs: Any) -> Execution @@ -237,7 +248,7 @@ FunctionResourceTemplate splats the params dict since .fn expects **kwargs. - `**kwargs`: Additional kwargs passed to docket.add() -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None) -> FunctionResourceTemplate diff --git a/docs/python-sdk/fastmcp-server-auth-auth.mdx b/docs/python-sdk/fastmcp-server-auth-auth.mdx index 2186df875..69f63aeb7 100644 --- a/docs/python-sdk/fastmcp-server-auth-auth.mdx +++ b/docs/python-sdk/fastmcp-server-auth-auth.mdx @@ -85,7 +85,7 @@ custom authentication routes. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -102,7 +102,7 @@ All auth providers must implement token verification. - AccessToken object if valid, None if invalid or expired -#### `set_mcp_path` +#### `set_mcp_path` ```python set_mcp_path(self, mcp_path: str | None) -> None @@ -119,7 +119,7 @@ MCP endpoint path. - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -143,7 +143,7 @@ provider does not create the actual MCP endpoint route. - List of all routes for this provider (excluding the MCP endpoint itself) -#### `get_well_known_routes` +#### `get_well_known_routes` ```python get_well_known_routes(self, mcp_path: str | None = None) -> list[Route] @@ -171,7 +171,7 @@ This is used to construct path-scoped well-known URLs. - List of well-known discovery routes (typically mounted at root level) -#### `get_middleware` +#### `get_middleware` ```python get_middleware(self) -> list @@ -183,7 +183,7 @@ Get HTTP application-level middleware for this auth provider. - List of Starlette Middleware instances to apply to the HTTP app -### `TokenVerifier` +### `TokenVerifier` Base class for token verifiers (Resource Servers). @@ -194,7 +194,7 @@ Token verifiers typically don't provide authentication routes by default. **Methods:** -#### `scopes_supported` +#### `scopes_supported` ```python scopes_supported(self) -> list[str] @@ -208,7 +208,7 @@ where tokens contain short-form scopes but clients request full URI scopes). -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -217,7 +217,7 @@ verify_token(self, token: str) -> AccessToken | None Verify a bearer token and return access info if valid. -### `RemoteAuthProvider` +### `RemoteAuthProvider` Authentication provider for resource servers that verify tokens from known authorization servers. @@ -234,7 +234,7 @@ the authorization servers that issue valid tokens. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -243,7 +243,7 @@ verify_token(self, token: str) -> AccessToken | None Verify token using the configured token verifier. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -254,7 +254,7 @@ Get routes for this provider. Creates protected resource metadata routes (RFC 9728). -### `MultiAuth` +### `MultiAuth` Composes an optional auth server with additional token verifiers. @@ -270,7 +270,7 @@ come from the server; verifiers contribute only token verification. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -283,7 +283,7 @@ it is logged and treated as a non-match so that remaining sources still get a chance to verify the token. -#### `set_mcp_path` +#### `set_mcp_path` ```python set_mcp_path(self, mcp_path: str | None) -> None @@ -292,7 +292,7 @@ set_mcp_path(self, mcp_path: str | None) -> None Propagate MCP path to the server and all verifiers. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -301,7 +301,7 @@ get_routes(self, mcp_path: str | None = None) -> list[Route] Delegate route creation to the server. -#### `get_well_known_routes` +#### `get_well_known_routes` ```python get_well_known_routes(self, mcp_path: str | None = None) -> list[Route] @@ -313,7 +313,7 @@ This ensures that server-specific well-known route logic (e.g., OAuthProvider's RFC 8414 path-aware discovery) is preserved. -### `OAuthProvider` +### `OAuthProvider` OAuth Authorization Server provider. @@ -324,7 +324,7 @@ authorization flows, token issuance, and token verification. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -342,7 +342,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] @@ -358,7 +358,7 @@ This method creates the full set of OAuth routes including: - List of OAuth routes -#### `get_well_known_routes` +#### `get_well_known_routes` ```python get_well_known_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-handlers-__init__.mdx b/docs/python-sdk/fastmcp-server-auth-handlers-__init__.mdx new file mode 100644 index 000000000..7593775fb --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-handlers-__init__.mdx @@ -0,0 +1,8 @@ +--- +title: __init__ +sidebarTitle: __init__ +--- + +# `fastmcp.server.auth.handlers` + +*This module is empty or contains only private/internal implementations.* diff --git a/docs/python-sdk/fastmcp-server-auth-handlers-authorize.mdx b/docs/python-sdk/fastmcp-server-auth-handlers-authorize.mdx new file mode 100644 index 000000000..f3f583027 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-handlers-authorize.mdx @@ -0,0 +1,83 @@ +--- +title: authorize +sidebarTitle: authorize +--- + +# `fastmcp.server.auth.handlers.authorize` + + +Enhanced authorization handler with improved error responses. + +This module provides an enhanced authorization handler that wraps the MCP SDK's +AuthorizationHandler to provide better error messages when clients attempt to +authorize with unregistered client IDs. + +The enhancement adds: +- Content negotiation: HTML for browsers, JSON for API clients +- Enhanced JSON responses with registration endpoint hints +- Styled HTML error pages with registration links/forms +- Link headers pointing to registration endpoints + + +## Functions + +### `create_unregistered_client_html` + +```python +create_unregistered_client_html(client_id: str, registration_endpoint: str, discovery_endpoint: str, server_name: str | None = None, server_icon_url: str | None = None, title: str = 'Client Not Registered') -> str +``` + + +Create styled HTML error page for unregistered client attempts. + +**Args:** +- `client_id`: The unregistered client ID that was provided +- `registration_endpoint`: URL of the registration endpoint +- `discovery_endpoint`: URL of the OAuth metadata discovery endpoint +- `server_name`: Optional server name for branding +- `server_icon_url`: Optional server icon URL +- `title`: Page title + +**Returns:** +- HTML string for the error page + + +## Classes + +### `AuthorizationHandler` + + +Authorization handler with enhanced error responses for unregistered clients. + +This handler extends the MCP SDK's AuthorizationHandler to provide better UX +when clients attempt to authorize without being registered. It implements +content negotiation to return: + +- HTML error pages for browser requests +- Enhanced JSON with registration hints for API clients +- Link headers pointing to registration endpoints + +This maintains OAuth 2.1 compliance (returns 400 for invalid client_id) +while providing actionable guidance to fix the error. + + +**Methods:** + +#### `handle` + +```python +handle(self, request: Request) -> Response +``` + +Handle authorization request with enhanced error responses. + +This method extends the SDK's authorization handler and intercepts +errors for unregistered clients to provide better error responses +based on the client's Accept header. + +**Args:** +- `request`: The authorization request + +**Returns:** +- Response (redirect on success, error response on failure) + diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-consent.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-consent.mdx index 67c514ee7..72549626d 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-consent.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-consent.mdx @@ -15,7 +15,7 @@ cookie management, and consent page rendering. ## Classes -### `ConsentMixin` +### `ConsentMixin` Mixin class providing consent management functionality for OAuthProxy. diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx index 2f7742f26..bb5e3a314 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx @@ -140,7 +140,7 @@ Handles provider-specific requirements: **Methods:** -#### `set_mcp_path` +#### `set_mcp_path` ```python set_mcp_path(self, mcp_path: str | None) -> None @@ -157,7 +157,7 @@ this specific MCP endpoint. - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") -#### `jwt_issuer` +#### `jwt_issuer` ```python jwt_issuer(self) -> JWTIssuer @@ -169,7 +169,7 @@ The JWT issuer is created when set_mcp_path() is called (via get_routes()). This property ensures a clear error if used before initialization. -#### `get_client` +#### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None @@ -182,7 +182,7 @@ For unregistered clients, returns None (which will raise an error in the SDK). CIMD clients (URL-based client IDs) are looked up and cached automatically. -#### `register_client` +#### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None @@ -196,7 +196,7 @@ redirect URI will likely be localhost or unknown to the proxied IDP. The proxied IDP only knows about this server's fixed redirect URI. -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -214,7 +214,7 @@ If consent is disabled (require_authorization_consent=False), skip the consent s and redirect directly to the upstream IdP. -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None @@ -226,7 +226,7 @@ Look up our client code and return authorization code object with PKCE challenge for validation. -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken @@ -244,7 +244,7 @@ Implements the token factory pattern: PKCE validation is handled by the MCP framework before this method is called. -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None @@ -256,7 +256,7 @@ Looks up by token hash and reconstructs the RefreshToken object. Validates that the token belongs to the requesting client. -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken @@ -273,7 +273,7 @@ Implements two-tier refresh: 6. Keep same FastMCP refresh token (unless upstream rotates) -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -293,7 +293,7 @@ The FastMCP JWT is a reference token - all authorization data comes from validating the upstream token via the TokenVerifier. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -306,7 +306,7 @@ For all tokens, attempts upstream revocation if endpoint is configured. Access token JTI mappings expire via TTL. -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx index a9160db17..da088e129 100644 --- a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx @@ -52,7 +52,7 @@ that is OIDC compliant. **Methods:** -#### `get_oidc_configuration` +#### `get_oidc_configuration` ```python get_oidc_configuration(self, config_url: AnyHttpUrl, strict: bool | None, timeout_seconds: int | None) -> OIDCConfiguration @@ -66,7 +66,7 @@ Gets the OIDC configuration for the specified configuration URL. - `timeout_seconds`: HTTP request timeout in seconds -#### `get_token_verifier` +#### `get_token_verifier` ```python get_token_verifier(self) -> TokenVerifier diff --git a/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx index c8d9ea795..e0f46689c 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx @@ -34,12 +34,17 @@ Example: ### `AWSCognitoTokenVerifier` -Token verifier that filters claims to Cognito-specific subset. +Token verifier for Cognito access tokens. + +Cognito access tokens use a ``client_id`` claim instead of the +standard ``aud`` claim. This subclass passes ``audience=None`` +to the parent (skipping the ``aud`` check) and validates the +``client_id`` claim directly. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -48,7 +53,7 @@ verify_token(self, token: str) -> AccessToken | None Verify token and filter claims to Cognito-specific subset. -### `AWSCognitoProvider` +### `AWSCognitoProvider` Complete AWS Cognito OAuth provider for FastMCP. @@ -66,7 +71,7 @@ Features: **Methods:** -#### `get_token_verifier` +#### `get_token_verifier` ```python get_token_verifier(self) -> AWSCognitoTokenVerifier diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx index 6301e4c28..e65cc6119 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx @@ -14,7 +14,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. ## Functions -### `EntraOBOToken` +### `EntraOBOToken` ```python EntraOBOToken(scopes: list[str]) -> str @@ -43,7 +43,7 @@ or OBO exchange fails ## Classes -### `AzureProvider` +### `AzureProvider` Azure (Microsoft Entra) OAuth provider for FastMCP. @@ -78,7 +78,7 @@ Setup: **Methods:** -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -98,7 +98,7 @@ scopes to determine the resource/audience instead of a separate parameter. - Authorization URL to redirect the user to Azure AD -#### `get_obo_credential` +#### `get_obo_credential` ```python get_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential @@ -120,7 +120,7 @@ calls multiple tools with the same scopes. - `ImportError`: If azure-identity is not installed (requires fastmcp[azure]). -#### `close_obo_credentials` +#### `close_obo_credentials` ```python close_obo_credentials(self) -> None @@ -129,7 +129,7 @@ close_obo_credentials(self) -> None Close all cached OBO credentials. -### `AzureJWTVerifier` +### `AzureJWTVerifier` JWT verifier pre-configured for Azure AD / Microsoft Entra ID. @@ -166,7 +166,7 @@ Example:: **Methods:** -#### `scopes_supported` +#### `scopes_supported` ```python scopes_supported(self) -> list[str] diff --git a/docs/python-sdk/fastmcp-server-auth-providers-clerk.mdx b/docs/python-sdk/fastmcp-server-auth-providers-clerk.mdx new file mode 100644 index 000000000..5add0fff2 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-providers-clerk.mdx @@ -0,0 +1,94 @@ +--- +title: clerk +sidebarTitle: clerk +--- + +# `fastmcp.server.auth.providers.clerk` + + +Clerk OAuth provider for FastMCP. + +This module provides a complete Clerk OAuth integration that's ready to use +with a Clerk domain, client ID, and client secret. It handles all the complexity +of Clerk's OAuth/OIDC flow, token validation, and user management. + +Clerk uses standard OIDC endpoints derived from the instance domain +(e.g., ``https://.clerk.accounts.dev``). Token verification is +performed via the introspection endpoint (RFC 7662) for security-critical +checks (active status, audience, scopes), followed by the userinfo endpoint +for profile enrichment. Userinfo failure is non-fatal. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.clerk import ClerkProvider + + auth = ClerkProvider( + domain="saving-primate-16.clerk.accounts.dev", + client_id="your-clerk-client-id", + client_secret="your-clerk-client-secret", + base_url="https://my-server.com", + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` + + +## Classes + +### `ClerkTokenVerifier` + + +Token verifier for Clerk OAuth tokens. + +Clerk issues standard OIDC tokens. Verification uses the introspection +endpoint (RFC 7662) as the primary security gate — it confirms the token +is active and provides metadata (scopes, expiry, audience). The userinfo +endpoint is called second for profile enrichment (name, email, picture) +and its failure is non-fatal. + +When a ``client_id`` is configured, the audience from introspection is +validated against it. When ``required_scopes`` are configured, +introspection must return the token's scopes — the verifier will not +assume scopes when introspection is unavailable. + + +**Methods:** + +#### `verify_token` + +```python +verify_token(self, token: str) -> AccessToken | None +``` + +Verify a Clerk OAuth token via introspection and userinfo. + +Calls the introspection endpoint first to validate the token and +retrieve auth metadata (active status, scopes, expiry, audience). +If the token passes security checks, the userinfo endpoint is called +for profile enrichment. Userinfo failure is non-fatal. + +When a ``client_id`` is configured, the token's audience must match it. +When ``required_scopes`` are configured, introspection must confirm +them; tokens are rejected if scope information is unavailable. + + +### `ClerkProvider` + + +Complete Clerk OAuth provider for FastMCP. + +This provider makes it trivial to add Clerk OAuth protection to any +FastMCP server. Provide your Clerk instance domain, OAuth app credentials, +and a base URL, and you're ready to go. + +Clerk uses standard OIDC endpoints derived from the instance domain. +All endpoint URLs are constructed automatically from the domain parameter. + +Features: +- Transparent OAuth proxy to Clerk +- Automatic token validation via Clerk's userinfo & introspection APIs +- User information extraction from Clerk's OIDC claims +- PKCE support (S256) +- Minimal configuration required + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx b/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx index c4ee4cc14..0e1e0d94e 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx @@ -16,19 +16,19 @@ It simulates the OAuth 2.1 flow locally without external calls. **Methods:** -#### `get_client` +#### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None ``` -#### `register_client` +#### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None ``` -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -38,37 +38,37 @@ Simulates user authorization and generates an authorization code. Returns a redirect URI with the code and state. -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None ``` -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken ``` -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None ``` -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken ``` -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None ``` -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -86,7 +86,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None diff --git a/docs/python-sdk/fastmcp-server-auth-providers-keycloak.mdx b/docs/python-sdk/fastmcp-server-auth-providers-keycloak.mdx new file mode 100644 index 000000000..e3cde2e60 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-providers-keycloak.mdx @@ -0,0 +1,20 @@ +--- +title: keycloak +sidebarTitle: keycloak +--- + +# `fastmcp.server.auth.providers.keycloak` + + +Keycloak authentication provider for FastMCP. + +## Classes + +### `KeycloakAuthProvider` + + +Keycloak authentication provider using Dynamic Client Registration (DCR). + +Requires Keycloak 26.6.0 or later, which includes the fix for DCR compatibility +with MCP clients (https://github.com/keycloak/keycloak/pull/45309). + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx index f93a9f0c6..8d8c80061 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx @@ -59,7 +59,7 @@ Setup Requirements: 4. Note your Client ID and Client Secret -### `AuthKitProvider` +### `AuthKitProvider` AuthKit metadata provider for DCR (Dynamic Client Registration). @@ -85,7 +85,7 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx index 78371e393..396e16c50 100644 --- a/docs/python-sdk/fastmcp-server-context.mdx +++ b/docs/python-sdk/fastmcp-server-context.mdx @@ -145,7 +145,7 @@ fastmcp(self) -> FastMCP Get the FastMCP instance. -#### `request_context` +#### `request_context` ```python request_context(self) -> RequestContext[ServerSession, Any, Request] | None @@ -174,7 +174,7 @@ async def on_request(self, context, call_next): ``` -#### `lifespan_context` +#### `lifespan_context` ```python lifespan_context(self) -> dict[str, Any] @@ -201,7 +201,7 @@ def my_tool(ctx: Context) -> str: ``` -#### `report_progress` +#### `report_progress` ```python report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None @@ -218,7 +218,7 @@ Works in both foreground (MCP progress notifications) and background - `message`: Optional status message describing current progress -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> list[SDKResource] @@ -230,7 +230,7 @@ List all available resources from the server. - List of Resource objects available on the server -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> list[SDKPrompt] @@ -242,7 +242,7 @@ List all available prompts from the server. - List of Prompt objects available on the server -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult @@ -258,7 +258,7 @@ Get a prompt by name with optional arguments. - The prompt result -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str | AnyUrl) -> ResourceResult @@ -273,7 +273,7 @@ Read a resource by URI. - ResourceResult with contents -#### `log` +#### `log` ```python log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -291,7 +291,7 @@ Messages sent to Clients are also logged to the `fastmcp.server.context.to_clien - `extra`: Optional mapping for additional arguments -#### `transport` +#### `transport` ```python transport(self) -> TransportType | None @@ -303,7 +303,7 @@ 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` +#### `client_supports_extension` ```python client_supports_extension(self, extension_id: str) -> bool @@ -328,7 +328,7 @@ Example:: return "text-only client" -#### `client_id` +#### `client_id` ```python client_id(self) -> str | None @@ -337,7 +337,7 @@ client_id(self) -> str | None Get the client ID if available. -#### `request_id` +#### `request_id` ```python request_id(self) -> str @@ -348,7 +348,7 @@ Get the unique ID for this request. Raises RuntimeError if MCP request context is not available. -#### `session_id` +#### `session_id` ```python session_id(self) -> str @@ -365,7 +365,7 @@ the same client session. - for other transports. -#### `session` +#### `session` ```python session(self) -> ServerSession @@ -379,7 +379,7 @@ In background task mode: Returns the session stored at Context creation. Raises RuntimeError if no session is available. -#### `debug` +#### `debug` ```python debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -390,7 +390,7 @@ 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` +#### `info` ```python info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -401,7 +401,7 @@ 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` +#### `warning` ```python warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -412,7 +412,7 @@ 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` +#### `error` ```python error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -423,7 +423,7 @@ 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`. -#### `list_roots` +#### `list_roots` ```python list_roots(self) -> list[Root] @@ -432,7 +432,7 @@ list_roots(self) -> list[Root] List the roots available to the server, as indicated by the client. -#### `send_notification` +#### `send_notification` ```python send_notification(self, notification: mcp.types.ServerNotificationType) -> None @@ -444,7 +444,7 @@ Send a notification to the client immediately. - `notification`: An MCP notification instance (e.g., ToolListChangedNotification()) -#### `close_sse_stream` +#### `close_sse_stream` ```python close_sse_stream(self) -> None @@ -462,7 +462,7 @@ Instead of holding a connection open for minutes, you can periodically close and let the client reconnect. -#### `sample_step` +#### `sample_step` ```python sample_step(self, messages: str | Sequence[str | SamplingMessage]) -> SampleStep @@ -505,7 +505,7 @@ regardless of this setting. - - .text: The text content (if any) -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] @@ -514,7 +514,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ Overload: With result_type, returns SamplingResult[ResultT]. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[str] @@ -523,7 +523,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ Overload: Without result_type, returns SamplingResult[str]. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] | SamplingResult[str] @@ -571,43 +571,43 @@ regardless of this setting. - - .history: All messages exchanged during sampling -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: dict[str, dict[str, str]]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[list[str]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[dict[str, dict[str, str]]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```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]]] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation @@ -636,7 +636,7 @@ type or dataclass or BaseModel. If it is a primitive type, an object schema with a single "value" field will be generated. -#### `set_state` +#### `set_state` ```python set_state(self, key: str, value: Any) -> None @@ -657,7 +657,7 @@ requests. The key is automatically prefixed with the session identifier. -#### `get_state` +#### `get_state` ```python get_state(self, key: str) -> Any @@ -671,7 +671,7 @@ then falls back to the session-scoped state store. Returns None if the key is not found. -#### `delete_state` +#### `delete_state` ```python delete_state(self, key: str) -> None @@ -682,7 +682,7 @@ Delete a value from the state store. Removes from both request-scoped and session-scoped stores. -#### `enable_components` +#### `enable_components` ```python enable_components(self) -> None @@ -706,7 +706,7 @@ ResourceListChangedNotification, and PromptListChangedNotification. - `match_all`: If True, matches all components regardless of other criteria. -#### `disable_components` +#### `disable_components` ```python disable_components(self) -> None @@ -730,7 +730,7 @@ ResourceListChangedNotification, and PromptListChangedNotification. - `match_all`: If True, matches all components regardless of other criteria. -#### `reset_visibility` +#### `reset_visibility` ```python reset_visibility(self) -> None diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx index f23d5ec59..927434df2 100644 --- a/docs/python-sdk/fastmcp-server-dependencies.mdx +++ b/docs/python-sdk/fastmcp-server-dependencies.mdx @@ -15,84 +15,28 @@ CurrentWorker) and background task execution require fastmcp[tasks]. ## Functions -### `get_task_context` - -```python -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 session_id, or None if not in a task. - - -### `register_task_session` - -```python -register_task_session(session_id: str, session: ServerSession) -> None -``` - - -Register a session for Context access in background tasks. - -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. - -**Args:** -- `session_id`: The session identifier -- `session`: The ServerSession instance - - -### `get_task_session` - -```python -get_task_session(session_id: str) -> ServerSession | None -``` - - -Get a registered session by ID if still alive. - -**Args:** -- `session_id`: The session identifier - -**Returns:** -- The ServerSession if found and alive, None otherwise - - -### `register_task_server` - -```python -register_task_server(task_id: str, server: FastMCP) -> None -``` - - -Register the server for a background task. - -Called at task-submission time (inside the child server's call_tool -context) so that background workers can resolve CurrentFastMCP() and -ctx.fastmcp to the child server for mounted tasks. - -The map is bounded to avoid unbounded growth in long-lived servers. -Evicted entries fall back to the ContextVar (parent server). - - -### `is_docket_available` +### `is_docket_available` ```python is_docket_available() -> bool ``` -Check if pydocket is installed. +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. -### `require_docket` +### `require_docket` ```python require_docket(feature: str) -> None @@ -106,7 +50,7 @@ Raise ImportError with install instructions if docket not available. "CurrentDocket()"). Will be included in the error message. -### `transform_context_annotations` +### `transform_context_annotations` ```python transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any] @@ -132,7 +76,7 @@ allows them to have defaults in any order. - Function with modified signature (same function object, updated __signature__) -### `get_context` +### `get_context` ```python get_context() -> Context @@ -142,7 +86,7 @@ get_context() -> Context Get the current FastMCP Context instance directly. -### `get_server` +### `get_server` ```python get_server() -> FastMCP @@ -162,7 +106,7 @@ started the worker). - `RuntimeError`: If no server in context -### `get_http_request` +### `get_http_request` ```python get_http_request() -> Request @@ -172,9 +116,11 @@ get_http_request() -> Request Get the current HTTP request. Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context. +In background tasks, returns a synthetic request populated with the +snapshotted headers from the originating HTTP request. -### `get_http_headers` +### `get_http_headers` ```python get_http_headers(include_all: bool = False, include: set[str] | None = None) -> dict[str, str] @@ -195,7 +141,7 @@ normally be excluded. This is useful for proxy transports that need to forward authorization headers to upstream MCP servers. -### `get_access_token` +### `get_access_token` ```python get_access_token() -> AccessToken | None @@ -214,7 +160,7 @@ token snapshot stored in Redis at task submission time. - The access token if an authenticated user is available, None otherwise. -### `without_injected_parameters` +### `without_injected_parameters` ```python without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any] @@ -239,7 +185,7 @@ Handles: - Async wrapper function without injected parameters -### `resolve_dependencies` +### `resolve_dependencies` ```python resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None] @@ -265,7 +211,7 @@ time, so all injection goes through the unified DI system. which will be filtered out) -### `CurrentContext` +### `CurrentContext` ```python CurrentContext() -> Context @@ -284,7 +230,7 @@ current MCP operation (tool/resource/prompt call). - `RuntimeError`: If no active context found (during resolution) -### `OptionalCurrentContext` +### `OptionalCurrentContext` ```python OptionalCurrentContext() -> Context | None @@ -294,7 +240,7 @@ OptionalCurrentContext() -> Context | None Get the current FastMCP Context, or None when no context is active. -### `CurrentDocket` +### `CurrentDocket` ```python CurrentDocket() -> Docket @@ -314,7 +260,7 @@ automatically creates for background task scheduling. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentWorker` +### `CurrentWorker` ```python CurrentWorker() -> Worker @@ -334,7 +280,7 @@ automatically creates for background task processing. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentFastMCP` +### `CurrentFastMCP` ```python CurrentFastMCP() -> FastMCP @@ -352,7 +298,7 @@ This dependency provides access to the active FastMCP server. - `RuntimeError`: If no server in context (during resolution) -### `CurrentRequest` +### `CurrentRequest` ```python CurrentRequest() -> Request @@ -372,7 +318,7 @@ current HTTP request. Only available when running over HTTP transports - `RuntimeError`: If no HTTP request in context (e.g., STDIO transport) -### `CurrentHeaders` +### `CurrentHeaders` ```python CurrentHeaders() -> dict[str, str] @@ -390,7 +336,7 @@ transport. - A dependency that resolves to a dictionary of header name -> value -### `CurrentAccessToken` +### `CurrentAccessToken` ```python CurrentAccessToken() -> AccessToken @@ -409,7 +355,7 @@ authenticated request. Raises an error if no authentication is present. - `RuntimeError`: If no authenticated user (use get_access_token() for optional) -### `TokenClaim` +### `TokenClaim` ```python TokenClaim(name: str) -> str @@ -434,16 +380,7 @@ without needing the full token object. ## Classes -### `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. - - -### `ProgressLike` +### `ProgressLike` Protocol for progress tracking interface. @@ -454,7 +391,7 @@ and Docket's Progress (worker context). **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None @@ -463,7 +400,7 @@ current(self) -> int | None Current progress value. -#### `total` +#### `total` ```python total(self) -> int @@ -472,7 +409,7 @@ total(self) -> int Total/target progress value. -#### `message` +#### `message` ```python message(self) -> str | None @@ -481,7 +418,7 @@ message(self) -> str | None Current progress message. -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -490,7 +427,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -499,7 +436,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -508,7 +445,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `InMemoryProgress` +### `InMemoryProgress` In-memory progress tracker for immediate tool execution. @@ -520,25 +457,25 @@ progress doesn't need to be observable across processes. **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None ``` -#### `total` +#### `total` ```python total(self) -> int ``` -#### `message` +#### `message` ```python message(self) -> str | None ``` -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -547,7 +484,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -556,7 +493,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -565,24 +502,22 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `Progress` +### `Progress` -FastMCP Progress dependency that works in both server and worker contexts. +Progress dependency that works in both server and worker contexts. -Handles three execution modes: -- In Docket worker: Uses the execution's progress (observable via Redis) -- In FastMCP server with Docket: Falls back to in-memory progress -- In FastMCP server without Docket: Uses in-memory progress +In a Docket worker, delegates to the execution's Redis-backed progress +(observable across processes). Otherwise, uses in-memory tracking. -This allows tools to use Progress() regardless of whether they're called -immediately or as background tasks, and regardless of whether pydocket -is installed. +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` +#### `current` ```python current(self) -> int | None @@ -591,7 +526,7 @@ current(self) -> int | None Current progress value. -#### `total` +#### `total` ```python total(self) -> int @@ -600,7 +535,7 @@ total(self) -> int Total/target progress value. -#### `message` +#### `message` ```python message(self) -> str | None @@ -609,7 +544,7 @@ message(self) -> str | None Current progress message. -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -618,7 +553,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -627,7 +562,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None diff --git a/docs/python-sdk/fastmcp-server-http.mdx b/docs/python-sdk/fastmcp-server-http.mdx index 9b2fe758d..f4e5d45bc 100644 --- a/docs/python-sdk/fastmcp-server-http.mdx +++ b/docs/python-sdk/fastmcp-server-http.mdx @@ -7,13 +7,13 @@ sidebarTitle: http ## Functions -### `set_http_request` +### `set_http_request` ```python set_http_request(request: Request) -> Generator[Request, None, None] ``` -### `create_base_app` +### `create_base_app` ```python create_base_app(routes: list[BaseRoute], middleware: list[Middleware], debug: bool = False, lifespan: Callable | None = None) -> StarletteWithLifespan @@ -32,7 +32,7 @@ Create a base Starlette app with common middleware and routes. - A Starlette application -### `create_sse_app` +### `create_sse_app` ```python create_sse_app(server: FastMCP[LifespanResultT], message_path: str, sse_path: str, auth: AuthProvider | None = None, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None) -> StarletteWithLifespan @@ -54,7 +54,7 @@ Returns: A Starlette application with RequestContextMiddleware -### `create_streamable_http_app` +### `create_streamable_http_app` ```python create_streamable_http_app(server: FastMCP[LifespanResultT], streamable_http_path: str, event_store: EventStore | None = None, retry_interval: int | None = None, auth: AuthProvider | None = None, json_response: bool = False, stateless_http: bool = False, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None) -> StarletteWithLifespan @@ -89,17 +89,17 @@ disconnections. Requires event_store to be set. Defaults to SDK default. ASGI application wrapper for Streamable HTTP server transport. -### `StarletteWithLifespan` +### `StarletteWithLifespan` **Methods:** -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> Lifespan[Starlette] ``` -### `RequestContextMiddleware` +### `RequestContextMiddleware` Middleware that stores each request in a ContextVar and sets transport type. diff --git a/docs/python-sdk/fastmcp-server-middleware-caching.mdx b/docs/python-sdk/fastmcp-server-middleware-caching.mdx index c2a353968..fbf9bba15 100644 --- a/docs/python-sdk/fastmcp-server-middleware-caching.mdx +++ b/docs/python-sdk/fastmcp-server-middleware-caching.mdx @@ -211,7 +211,7 @@ Get a prompt from the cache, if caching is enabled, and the result is in the cac otherwise call the next middleware and store the result in the cache if caching is enabled. -#### `statistics` +#### `statistics` ```python statistics(self) -> ResponseCachingStatistics diff --git a/docs/python-sdk/fastmcp-server-middleware-response_limiting.mdx b/docs/python-sdk/fastmcp-server-middleware-response_limiting.mdx index 0f344100b..b897f16c3 100644 --- a/docs/python-sdk/fastmcp-server-middleware-response_limiting.mdx +++ b/docs/python-sdk/fastmcp-server-middleware-response_limiting.mdx @@ -10,7 +10,7 @@ Response limiting middleware for controlling tool response sizes. ## Classes -### `ResponseLimitingMiddleware` +### `ResponseLimitingMiddleware` Middleware that limits the response size of tool calls. @@ -22,7 +22,7 @@ a single TextContent block. **Methods:** -#### `on_call_tool` +#### `on_call_tool` ```python on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult]) -> ToolResult diff --git a/docs/python-sdk/fastmcp-server-mixins-transport.mdx b/docs/python-sdk/fastmcp-server-mixins-transport.mdx index ad4e0b60e..0d29ed280 100644 --- a/docs/python-sdk/fastmcp-server-mixins-transport.mdx +++ b/docs/python-sdk/fastmcp-server-mixins-transport.mdx @@ -104,7 +104,7 @@ Run the server using HTTP transport. - `stateless`: Alias for stateless_http for CLI consistency -#### `http_app` +#### `http_app` ```python http_app(self: FastMCP, path: str | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None, transport: Literal['http', 'streamable-http', 'sse'] = 'http', event_store: EventStore | None = None, retry_interval: int | None = None) -> StarletteWithLifespan diff --git a/docs/python-sdk/fastmcp-server-providers-addressing.mdx b/docs/python-sdk/fastmcp-server-providers-addressing.mdx new file mode 100644 index 000000000..48b953e87 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-providers-addressing.mdx @@ -0,0 +1,81 @@ +--- +title: addressing +sidebarTitle: addressing +--- + +# `fastmcp.server.providers.addressing` + + +Deterministic tool hashing for backend-tool routing and per-tool resources. + +Each FastMCPApp backend tool gets a deterministic hash computed from its +app name + tool name. The hash serves two purposes: + +1. **Backend-tool routing.** Tools with ``"app"`` in their visibility are + callable via ``_``. The dispatcher parses the prefix, + then walks providers recursively (same pattern as the old ``get_app_tool``) + to find a tool whose stored hash matches. + +2. **Per-tool Prefab renderer URIs.** Each prefab tool gets a unique renderer + resource at ``ui://prefab/tool//renderer.html``. ``list_resources`` + 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"]``. +Deterministic across replicas (same code → same hash), no registry walk +needed. + + +## Functions + +### `hash_tool` + +```python +hash_tool(app_name: str, tool_name: str) -> str +``` + + +Deterministic hex hash for a tool in an app. + +Same inputs on every replica produce the same output. + + +### `hashed_backend_name` + +```python +hashed_backend_name(app_name: str, tool_name: str) -> str +``` + + +Format the universal name for a backend tool: ``_``. + + +### `parse_hashed_backend_name` + +```python +parse_hashed_backend_name(name: str) -> tuple[str, str] | None +``` + + +Parse ``_`` → ``(hash, local_tool_name)`` or None. + + +### `hashed_resource_uri` + +```python +hashed_resource_uri(app_name: str, tool_name: str) -> str +``` + + +Per-tool Prefab renderer resource URI. + + +### `parse_hashed_resource_uri` + +```python +parse_hashed_resource_uri(uri: str) -> str | None +``` + + +Extract the hash from a Prefab renderer URI, or None. + diff --git a/docs/python-sdk/fastmcp-server-providers-aggregate.mdx b/docs/python-sdk/fastmcp-server-providers-aggregate.mdx index ffc3d8dbb..36feac09c 100644 --- a/docs/python-sdk/fastmcp-server-providers-aggregate.mdx +++ b/docs/python-sdk/fastmcp-server-providers-aggregate.mdx @@ -64,7 +64,7 @@ FastMCPProvider to ensure middleware is invoked correctly. - Prompts become "namespace_promptname" -#### `get_app_tool` +#### `get_app_tool` ```python get_app_tool(self, app_name: str, tool_name: str) -> Tool | None @@ -73,7 +73,16 @@ get_app_tool(self, app_name: str, tool_name: str) -> Tool | None Query all child providers for an app tool. -#### `get_tasks` +#### `get_tool_by_hash` + +```python +get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None +``` + +Query all child providers for a tool matching a hash. + + +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -82,7 +91,7 @@ get_tasks(self) -> Sequence[FastMCPComponent] Get all task-eligible components from all providers. -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] diff --git a/docs/python-sdk/fastmcp-server-providers-base.mdx b/docs/python-sdk/fastmcp-server-providers-base.mdx index 9f510456a..6d4774977 100644 --- a/docs/python-sdk/fastmcp-server-providers-base.mdx +++ b/docs/python-sdk/fastmcp-server-providers-base.mdx @@ -140,20 +140,27 @@ get_app_tool(self, app_name: str, tool_name: str) -> Tool | None Look up an app-visible tool by original name, bypassing transforms. -This is the routing path for tool calls from app UIs (identified by -``_meta.fastmcp.app`` on the request). It skips the transform chain -entirely — the tool is found by its registered name and matched -against the app identity in its metadata. - -The default implementation checks this provider's own storage via -``_get_tool``. Aggregate and wrapped providers override to -delegate to children. +Searches for a tool named ``tool_name`` tagged with the given app +name. Skips the transform chain entirely. **Returns:** - The tool if found and tagged with the given app name, else None. -#### `list_resources` +#### `get_tool_by_hash` + +```python +get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None +``` + +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. +Used by the dispatcher when receiving hashed backend-tool calls. + + +#### `list_resources` ```python list_resources(self) -> Sequence[Resource] @@ -164,7 +171,7 @@ List resources with all transforms applied. Components may be marked as disabled but are NOT filtered here. -#### `get_resource` +#### `get_resource` ```python get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None @@ -183,7 +190,7 @@ Note: This method does NOT filter disabled components. The Server - The resource if found (may be marked disabled), None if not found. -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self) -> Sequence[ResourceTemplate] @@ -194,7 +201,7 @@ List resource templates with all transforms applied. Components may be marked as disabled but are NOT filtered here. -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None @@ -213,7 +220,7 @@ Note: This method does NOT filter disabled components. The Server - The template if found (may be marked disabled), None if not found. -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> Sequence[Prompt] @@ -224,7 +231,7 @@ List prompts with all transforms applied. Components may be marked as disabled but are NOT filtered here. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None @@ -243,7 +250,7 @@ Note: This method does NOT filter disabled components. The Server - The prompt if found (may be marked disabled), None if not found. -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -258,7 +265,7 @@ for components with task_config.mode != 'forbidden'. Used by the server during startup to register functions with Docket. -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] @@ -274,7 +281,7 @@ The lifespan scope matches the server's lifespan - code before yield runs at startup, code after yield runs at shutdown. -#### `enable` +#### `enable` ```python enable(self) -> Self @@ -302,7 +309,7 @@ VersionSpec(gte="v2")). Unversioned components will not match. - Self for method chaining. -#### `disable` +#### `disable` ```python disable(self) -> Self diff --git a/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx b/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx index e3e70093e..585c9b376 100644 --- a/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx +++ b/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx @@ -18,7 +18,7 @@ executed. ## Classes -### `FastMCPProviderTool` +### `FastMCPProviderTool` Tool that delegates execution to a wrapped server's middleware. @@ -30,7 +30,7 @@ chain is executed. **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, server: Any, tool: Tool) -> FastMCPProviderTool @@ -39,7 +39,7 @@ wrap(cls, server: Any, tool: Tool) -> FastMCPProviderTool Wrap a Tool to delegate execution to the server's middleware. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -51,13 +51,13 @@ This is called when the tool is used within a TransformedTool forwarding function or other contexts where task_meta is not available. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProviderResource` +### `FastMCPProviderResource` Resource that delegates reading to a wrapped server's read_resource(). @@ -68,7 +68,7 @@ When `read()` is called, this resource invokes the wrapped server's **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, server: Any, resource: Resource) -> FastMCPProviderResource @@ -77,13 +77,13 @@ wrap(cls, server: Any, resource: Resource) -> FastMCPProviderResource Wrap a Resource to delegate reading to the server's middleware. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProviderPrompt` +### `FastMCPProviderPrompt` Prompt that delegates rendering to a wrapped server's render_prompt(). @@ -94,7 +94,7 @@ When `render()` is called, this prompt invokes the wrapped server's **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, server: Any, prompt: Prompt) -> FastMCPProviderPrompt @@ -103,7 +103,7 @@ wrap(cls, server: Any, prompt: Prompt) -> FastMCPProviderPrompt Wrap a Prompt to delegate rendering to the server's middleware. -#### `render` +#### `render` ```python render(self, arguments: dict[str, Any] | None = None) -> PromptResult @@ -115,13 +115,13 @@ This is called when the prompt is used within a transformed context or other contexts where task_meta is not available. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProviderResourceTemplate` +### `FastMCPProviderResourceTemplate` Resource template that creates FastMCPProviderResources. @@ -133,7 +133,7 @@ when read. **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, server: Any, template: ResourceTemplate) -> FastMCPProviderResourceTemplate @@ -142,7 +142,7 @@ wrap(cls, server: Any, template: ResourceTemplate) -> FastMCPProviderResourceTem Wrap a ResourceTemplate to create FastMCPProviderResources. -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource @@ -155,7 +155,7 @@ We use `_original_uri_template` with `params` to construct the internal URI that the nested server understands. -#### `read` +#### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult @@ -167,7 +167,7 @@ Reads the resource via the wrapped server and returns the ResourceResult. This method is called by Docket during background task execution. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -176,7 +176,7 @@ register_with_docket(self, docket: Docket) -> None No-op: the child's actual template is registered via get_tasks(). -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, params: dict[str, Any], **kwargs: Any) -> Execution @@ -188,13 +188,13 @@ The child's FunctionResourceTemplate.fn is registered (via get_tasks), and it expects splatted **kwargs, so we splat params here. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProvider` +### `FastMCPProvider` Provider that wraps a FastMCP server. @@ -210,7 +210,7 @@ This ensures middleware runs when components are executed. **Methods:** -#### `get_app_tool` +#### `get_app_tool` ```python get_app_tool(self, app_name: str, tool_name: str) -> Tool | None @@ -219,7 +219,16 @@ get_app_tool(self, app_name: str, tool_name: str) -> Tool | None Delegate to nested server's get_app_tool, wrapping for middleware. -#### `get_tasks` +#### `get_tool_by_hash` + +```python +get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None +``` + +Delegate to nested server's get_tool_by_hash, wrapping for middleware. + + +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -233,7 +242,7 @@ server's transforms applied, then applies this provider's transforms for correct registration keys. -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] diff --git a/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx b/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx index 802adb6e0..afb6541d9 100644 --- a/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx +++ b/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx @@ -14,7 +14,7 @@ registration functionality to LocalProvider. ## Classes -### `ToolDecoratorMixin` +### `ToolDecoratorMixin` Mixin class providing tool decorator functionality for LocalProvider. @@ -26,7 +26,7 @@ This mixin contains all methods related to: **Methods:** -#### `add_tool` +#### `add_tool` ```python add_tool(self: LocalProvider, tool: Tool | Callable[..., Any]) -> Tool @@ -37,19 +37,19 @@ Add a tool to this provider's storage. Accepts either a Tool object or a decorated function with __fastmcp__ metadata. -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: F) -> F ``` -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: str | None = None) -> Callable[[F], F] ``` -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] diff --git a/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx b/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx index 5d74c19ae..037c4b7fc 100644 --- a/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx +++ b/docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx @@ -21,7 +21,7 @@ spec. Each component makes HTTP calls to the described API endpoints. **Methods:** -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] @@ -30,7 +30,7 @@ lifespan(self) -> AsyncIterator[None] Manage the lifecycle of the auto-created httpx client. -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] diff --git a/docs/python-sdk/fastmcp-server-providers-prefab_synthesis.mdx b/docs/python-sdk/fastmcp-server-providers-prefab_synthesis.mdx new file mode 100644 index 000000000..b3b2c9734 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-providers-prefab_synthesis.mdx @@ -0,0 +1,58 @@ +--- +title: prefab_synthesis +sidebarTitle: prefab_synthesis +--- + +# `fastmcp.server.providers.prefab_synthesis` + + +On-demand Prefab renderer resource synthesis. + +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 +resources on demand at ``list_resources`` and ``read_resource`` time +without storing or materializing anything. + +Each tool's resource URI is ``ui://prefab/tool//renderer.html`` +where the hash comes from the tool's own meta (set at registration from +the app name + tool name). CSP on the resource is the tool's +``meta.ui.csp`` merged with the renderer defaults across all four +``*_domains`` fields. + + +## Functions + +### `synthesize_prefab_resources` + +```python +synthesize_prefab_resources(server: FastMCP) -> list[Resource] +``` + + +Return fresh synthetic Prefab resources for all prefab tools. Pure. + + +### `synthesize_prefab_resource_by_uri` + +```python +synthesize_prefab_resource_by_uri(server: FastMCP, uri: str) -> Resource | None +``` + + +Intercept a Prefab renderer URI and synthesize on demand. + + +### `rewrite_tool_meta_for_wire` + +```python +rewrite_tool_meta_for_wire(tool: Tool) -> Tool +``` + + +Return a model_copy with the per-tool URI and CSP stripped. + +Reads the hash from the tool's own meta. If no hash is found, +returns the tool unchanged. Produces a fresh copy — the original +Tool object is untouched. + diff --git a/docs/python-sdk/fastmcp-server-providers-proxy.mdx b/docs/python-sdk/fastmcp-server-providers-proxy.mdx index d612a9f37..b53bf4de7 100644 --- a/docs/python-sdk/fastmcp-server-providers-proxy.mdx +++ b/docs/python-sdk/fastmcp-server-providers-proxy.mdx @@ -15,7 +15,7 @@ classes that forward execution to remote servers. ## Functions -### `default_proxy_roots_handler` +### `default_proxy_roots_handler` ```python default_proxy_roots_handler(context: RequestContext[ClientSession, LifespanContextT]) -> RootsList @@ -25,7 +25,7 @@ default_proxy_roots_handler(context: RequestContext[ClientSession, LifespanConte Forward list roots request from remote server to proxy's connected clients. -### `default_proxy_sampling_handler` +### `default_proxy_sampling_handler` ```python default_proxy_sampling_handler(messages: list[mcp.types.SamplingMessage], params: mcp.types.CreateMessageRequestParams, context: RequestContext[ClientSession, LifespanContextT]) -> mcp.types.CreateMessageResult @@ -35,7 +35,7 @@ default_proxy_sampling_handler(messages: list[mcp.types.SamplingMessage], params Forward sampling request from remote server to proxy's connected clients. -### `default_proxy_elicitation_handler` +### `default_proxy_elicitation_handler` ```python default_proxy_elicitation_handler(message: str, response_type: type, params: mcp.types.ElicitRequestParams, context: RequestContext[ClientSession, LifespanContextT]) -> ElicitResult @@ -45,7 +45,7 @@ default_proxy_elicitation_handler(message: str, response_type: type, params: mcp Forward elicitation request from remote server to proxy's connected clients. -### `default_proxy_log_handler` +### `default_proxy_log_handler` ```python default_proxy_log_handler(message: LogMessage) -> None @@ -55,7 +55,7 @@ default_proxy_log_handler(message: LogMessage) -> None Forward log notification from remote server to proxy's connected clients. -### `default_proxy_progress_handler` +### `default_proxy_progress_handler` ```python default_proxy_progress_handler(progress: float, total: float | None, message: str | None) -> None @@ -93,7 +93,7 @@ from_mcp_tool(cls, client_factory: ClientFactoryT, mcp_tool: mcp.types.Tool) -> Factory method to create a ProxyTool from a raw MCP tool schema. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any], context: Context | None = None) -> ToolResult @@ -102,13 +102,13 @@ run(self, arguments: dict[str, Any], context: Context | None = None) -> ToolResu Executes the tool by making a call through the client. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `ProxyResource` +### `ProxyResource` A Resource that represents and reads a resource from a remote server. @@ -116,7 +116,7 @@ A Resource that represents and reads a resource from a remote server. **Methods:** -#### `model_copy` +#### `model_copy` ```python model_copy(self, **kwargs: Any) -> ProxyResource @@ -125,7 +125,7 @@ model_copy(self, **kwargs: Any) -> ProxyResource Override to preserve _backend_uri when uri changes. -#### `from_mcp_resource` +#### `from_mcp_resource` ```python from_mcp_resource(cls, client_factory: ClientFactoryT, mcp_resource: mcp.types.Resource) -> ProxyResource @@ -134,7 +134,7 @@ from_mcp_resource(cls, client_factory: ClientFactoryT, mcp_resource: mcp.types.R Factory method to create a ProxyResource from a raw MCP resource schema. -#### `read` +#### `read` ```python read(self) -> ResourceResult @@ -143,13 +143,13 @@ read(self) -> ResourceResult Read the resource content from the remote server. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `ProxyTemplate` +### `ProxyTemplate` A ResourceTemplate that represents and creates resources from a remote server template. @@ -157,7 +157,7 @@ A ResourceTemplate that represents and creates resources from a remote server te **Methods:** -#### `model_copy` +#### `model_copy` ```python model_copy(self, **kwargs: Any) -> ProxyTemplate @@ -166,7 +166,7 @@ model_copy(self, **kwargs: Any) -> ProxyTemplate Override to preserve _backend_uri_template when uri_template changes. -#### `from_mcp_template` +#### `from_mcp_template` ```python from_mcp_template(cls, client_factory: ClientFactoryT, mcp_template: mcp.types.ResourceTemplate) -> ProxyTemplate @@ -175,7 +175,7 @@ from_mcp_template(cls, client_factory: ClientFactoryT, mcp_template: mcp.types.R Factory method to create a ProxyTemplate from a raw MCP template schema. -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> ProxyResource @@ -184,13 +184,13 @@ create_resource(self, uri: str, params: dict[str, Any], context: Context | None Create a resource from the template by calling the remote server. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `ProxyPrompt` +### `ProxyPrompt` A Prompt that represents and renders a prompt from a remote server. @@ -198,7 +198,7 @@ A Prompt that represents and renders a prompt from a remote server. **Methods:** -#### `model_copy` +#### `model_copy` ```python model_copy(self, **kwargs: Any) -> ProxyPrompt @@ -207,7 +207,7 @@ model_copy(self, **kwargs: Any) -> ProxyPrompt Override to preserve _backend_name when name changes. -#### `from_mcp_prompt` +#### `from_mcp_prompt` ```python from_mcp_prompt(cls, client_factory: ClientFactoryT, mcp_prompt: mcp.types.Prompt) -> ProxyPrompt @@ -216,7 +216,7 @@ from_mcp_prompt(cls, client_factory: ClientFactoryT, mcp_prompt: mcp.types.Promp Factory method to create a ProxyPrompt from a raw MCP prompt schema. -#### `render` +#### `render` ```python render(self, arguments: dict[str, Any]) -> PromptResult @@ -225,13 +225,13 @@ render(self, arguments: dict[str, Any]) -> PromptResult Render the prompt by making a call through the client. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `ProxyProvider` +### `ProxyProvider` Provider that proxies to a remote MCP server via a client factory. @@ -255,7 +255,7 @@ backends whose component lists change dynamically. **Methods:** -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -268,7 +268,7 @@ server lifespan initialization, which would open the client before any context is set. All Proxy* components have task_config.mode="forbidden". -### `FastMCPProxy` +### `FastMCPProxy` A FastMCP server that acts as a proxy to a remote MCP-compliant server. @@ -277,7 +277,7 @@ This is a convenience wrapper that creates a FastMCP server with a ProxyProvider. For more control, use FastMCP with add_provider(ProxyProvider(...)). -### `ProxyClient` +### `ProxyClient` A proxy client that forwards advanced interactions between a remote MCP server and the proxy's connected clients. @@ -285,7 +285,7 @@ A proxy client that forwards advanced interactions between a remote MCP server a Supports forwarding roots, sampling, elicitation, logging, and progress. -### `StatefulProxyClient` +### `StatefulProxyClient` A proxy client that provides a stateful client factory for the proxy server. @@ -306,7 +306,7 @@ it to detect (and correct) staleness. **Methods:** -#### `clear` +#### `clear` ```python clear(self) @@ -315,7 +315,7 @@ clear(self) Clear all cached clients and force disconnect them. -#### `new_stateful` +#### `new_stateful` ```python new_stateful(self) -> Client[ClientTransportT] diff --git a/docs/python-sdk/fastmcp-server-sampling-run.mdx b/docs/python-sdk/fastmcp-server-sampling-run.mdx index ea6d46104..5e145c099 100644 --- a/docs/python-sdk/fastmcp-server-sampling-run.mdx +++ b/docs/python-sdk/fastmcp-server-sampling-run.mdx @@ -10,7 +10,7 @@ Sampling types and helper functions for FastMCP servers. ## Functions -### `determine_handler_mode` +### `determine_handler_mode` ```python determine_handler_mode(context: Context, needs_tools: bool) -> bool @@ -30,7 +30,7 @@ Determine whether to use fallback handler or client for sampling. - `ValueError`: If client lacks required capability and no fallback configured. -### `call_sampling_handler` +### `call_sampling_handler` ```python call_sampling_handler(context: Context, messages: list[SamplingMessage]) -> CreateMessageResult | CreateMessageResultWithTools @@ -44,7 +44,7 @@ sampling_handler is set via determine_handler_mode(). The checks below are safeguards against internal misuse. -### `execute_tools` +### `execute_tools` ```python execute_tools(tool_calls: list[ToolUseContent], tool_map: dict[str, SamplingTool], mask_error_details: bool = False, tool_concurrency: int | None = None) -> list[ToolResultContent] @@ -71,7 +71,7 @@ regardless of this setting. - List of tool result content blocks in the same order as tool_calls. -### `prepare_messages` +### `prepare_messages` ```python prepare_messages(messages: str | Sequence[str | SamplingMessage]) -> list[SamplingMessage] @@ -81,7 +81,7 @@ prepare_messages(messages: str | Sequence[str | SamplingMessage]) -> list[Sampli Convert various message formats to a list of SamplingMessage objects. -### `prepare_tools` +### `prepare_tools` ```python prepare_tools(tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] | None) -> list[SamplingTool] | None @@ -102,7 +102,7 @@ TransformedTool, or plain callable functions. - List of SamplingTool instances, or None if tools is None. -### `extract_tool_calls` +### `extract_tool_calls` ```python extract_tool_calls(response: CreateMessageResult | CreateMessageResultWithTools) -> list[ToolUseContent] @@ -112,7 +112,7 @@ extract_tool_calls(response: CreateMessageResult | CreateMessageResultWithTools) Extract tool calls from a response. -### `create_final_response_tool` +### `create_final_response_tool` ```python create_final_response_tool(result_type: type) -> SamplingTool @@ -125,7 +125,7 @@ This tool is used to capture structured responses from the LLM. The tool's schema is derived from the result_type. -### `sample_step_impl` +### `sample_step_impl` ```python sample_step_impl(context: Context, messages: str | Sequence[str | SamplingMessage]) -> SampleStep @@ -138,7 +138,7 @@ Make a single LLM sampling call. This is a stateless function that makes exactly one LLM call and optionally executes any requested tools. -### `sample_impl` +### `sample_impl` ```python sample_impl(context: Context, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] @@ -154,7 +154,7 @@ provides a final text response. ## Classes -### `SamplingResult` +### `SamplingResult` Result of a sampling operation. @@ -165,7 +165,7 @@ Result of a sampling operation. - `history`: All messages exchanged during sampling. -### `SampleStep` +### `SampleStep` Result of a single sampling call. @@ -175,7 +175,7 @@ Represents what the LLM returned in this step plus the message history. **Methods:** -#### `is_tool_use` +#### `is_tool_use` ```python is_tool_use(self) -> bool @@ -184,7 +184,7 @@ is_tool_use(self) -> bool True if the LLM is requesting tool execution. -#### `text` +#### `text` ```python text(self) -> str | None @@ -193,7 +193,7 @@ text(self) -> str | None Extract text from the response, if available. -#### `tool_calls` +#### `tool_calls` ```python tool_calls(self) -> list[ToolUseContent] diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index 2e7832961..f68b94d3e 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -10,7 +10,7 @@ FastMCP - A more ergonomic interface for MCP servers. ## Functions -### `default_lifespan` +### `default_lifespan` ```python default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any] @@ -26,7 +26,7 @@ Default lifespan context manager that does nothing. - An empty dictionary as the lifespan result. -### `create_proxy` +### `create_proxy` ```python create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -54,53 +54,53 @@ use `FastMCPProxy` or `ProxyProvider` directly from `fastmcp.server.providers.pr ## Classes -### `StateValue` +### `StateValue` Wrapper for stored context state values. -### `FastMCP` +### `FastMCP` **Methods:** -#### `name` +#### `name` ```python name(self) -> str ``` -#### `instructions` +#### `instructions` ```python instructions(self) -> str | None ``` -#### `instructions` +#### `instructions` ```python instructions(self, value: str | None) -> None ``` -#### `version` +#### `version` ```python version(self) -> str | None ``` -#### `website_url` +#### `website_url` ```python website_url(self) -> str | None ``` -#### `icons` +#### `icons` ```python icons(self) -> list[mcp.types.Icon] ``` -#### `local_provider` +#### `local_provider` ```python local_provider(self) -> LocalProvider @@ -115,13 +115,13 @@ Use this to remove components: mcp.local_provider.remove_prompt("my_prompt") -#### `add_middleware` +#### `add_middleware` ```python add_middleware(self, middleware: Middleware) -> None ``` -#### `add_provider` +#### `add_provider` ```python add_provider(self, provider: Provider) -> None @@ -141,7 +141,7 @@ always take precedence over providers. - Prompts become "namespace_promptname" -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -153,7 +153,7 @@ Overrides AggregateProvider.get_tasks() to apply server-level transforms after aggregation. AggregateProvider handles provider-level namespacing. -#### `add_transform` +#### `add_transform` ```python add_transform(self, transform: Transform) -> None @@ -168,7 +168,7 @@ They transform tools, resources, and prompts from ALL providers. - `transform`: The transform to add. -#### `add_tool_transformation` +#### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None @@ -180,7 +180,7 @@ Add a tool transformation. Use ``add_transform(ToolTransform({...}))`` instead. -#### `remove_tool_transformation` +#### `remove_tool_transformation` ```python remove_tool_transformation(self, _tool_name: str) -> None @@ -192,7 +192,7 @@ Remove a tool transformation. Tool transformations are now immutable. Use enable/disable controls instead. -#### `list_tools` +#### `list_tools` ```python list_tools(self) -> Sequence[Tool] @@ -205,7 +205,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_tool` +#### `get_tool` ```python get_tool(self, name: str, version: VersionSpec | None = None) -> Tool | None @@ -228,7 +228,7 @@ requested, falls back to the next-highest enabled version. - The tool if found and enabled, None otherwise. -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> Sequence[Resource] @@ -241,7 +241,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_resource` +#### `get_resource` ```python get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None @@ -263,7 +263,7 @@ requested, falls back to the next-highest enabled version. - The resource if found and enabled, None otherwise. -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self) -> Sequence[ResourceTemplate] @@ -276,7 +276,7 @@ auth filtering, and middleware execution. Returns all versions (no deduplication Protocol handlers deduplicate for MCP wire format. -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None @@ -298,7 +298,7 @@ requested, falls back to the next-highest enabled version. - The template if found and enabled, None otherwise. -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> Sequence[Prompt] @@ -311,7 +311,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None @@ -333,19 +333,19 @@ requested, falls back to the next-highest enabled version. - The prompt if found and enabled, None otherwise. -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult | mcp.types.CreateTaskResult @@ -364,9 +364,6 @@ 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. -- `app_name`: If set (from ``_meta.fastmcp.app``), the call originated -from an app UI and should be routed directly to the named app's -tool registry, bypassing transforms. **Returns:** - ToolResult when task_meta is None. @@ -378,19 +375,19 @@ tool registry, bypassing transforms. - `ValidationError`: If arguments fail validation -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> mcp.types.CreateTaskResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult | mcp.types.CreateTaskResult @@ -419,19 +416,19 @@ return ResourceResult. - `ResourceError`: If resource read fails -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult | mcp.types.CreateTaskResult @@ -461,7 +458,7 @@ return PromptResult. - `PromptError`: If prompt rendering fails -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool | Callable[..., Any]) -> Tool @@ -479,7 +476,7 @@ with the Context type annotation. See the @tool decorator for examples. - The tool instance that was added to the server. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, name: str, version: str | None = None) -> None @@ -498,19 +495,19 @@ Remove tool(s) from the server. - `NotFoundError`: If no matching tool is found. -#### `tool` +#### `tool` ```python tool(self, name_or_fn: F) -> F ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[F], F] ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] @@ -566,7 +563,7 @@ server.tool(my_function, name="custom_name") ``` -#### `add_resource` +#### `add_resource` ```python add_resource(self, resource: Resource | Callable[..., Any]) -> Resource | ResourceTemplate @@ -581,7 +578,7 @@ Add a resource to the server. - The resource instance that was added to the server. -#### `add_template` +#### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate @@ -596,7 +593,7 @@ Add a resource template to the server. - The template instance that was added to the server. -#### `resource` +#### `resource` ```python resource(self, uri: str) -> Callable[[F], F] @@ -655,7 +652,7 @@ async def get_weather(city: str) -> str: ``` -#### `add_prompt` +#### `add_prompt` ```python add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt @@ -670,19 +667,19 @@ Add a prompt to the server. - The prompt instance that was added to the server. -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: F) -> F ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | None = None) -> Callable[[F], F] ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] @@ -759,7 +756,7 @@ Decorator to register a prompt. ``` -#### `mount` +#### `mount` ```python mount(self, server: FastMCP[LifespanResultT], namespace: str | None = None, as_proxy: bool | None = None, tool_names: dict[str, str] | None = None, prefix: str | None = None) -> None @@ -806,7 +803,7 @@ mounted server. - `prefix`: Deprecated. Use namespace instead. -#### `import_server` +#### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None) -> None @@ -847,7 +844,7 @@ templates, and prompts are imported with their original names. objects are imported with their original names. -#### `from_openapi` +#### `from_openapi` ```python from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, validate_output: bool = True, **settings: Any) -> Self @@ -876,7 +873,7 @@ response structure while still returning structured JSON. - A FastMCP server with an OpenAPIProvider attached. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> Self @@ -900,7 +897,7 @@ Use this to configure timeout and other client settings. - A FastMCP server with an OpenAPIProvider attached. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -918,7 +915,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str diff --git a/docs/python-sdk/fastmcp-server-tasks-capabilities.mdx b/docs/python-sdk/fastmcp-server-tasks-capabilities.mdx index 03b1102dd..8e6ada03a 100644 --- a/docs/python-sdk/fastmcp-server-tasks-capabilities.mdx +++ b/docs/python-sdk/fastmcp-server-tasks-capabilities.mdx @@ -10,7 +10,7 @@ SEP-1686 task capabilities declaration. ## Functions -### `get_task_capabilities` +### `get_task_capabilities` ```python get_task_capabilities() -> ServerTasksCapability | None @@ -22,7 +22,11 @@ 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 pydocket is not installed (no task support). +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. Note: prompts/resources are passed via extra_data since the SDK types don't include them yet (FastMCP supports them ahead of the spec). diff --git a/docs/python-sdk/fastmcp-server-tasks-context.mdx b/docs/python-sdk/fastmcp-server-tasks-context.mdx new file mode 100644 index 000000000..945180ca7 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-tasks-context.mdx @@ -0,0 +1,175 @@ +--- +title: context +sidebarTitle: context +--- + +# `fastmcp.server.tasks.context` + + +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. + + +## Functions + +### `get_task_scope` + +```python +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. + + +### `get_task_context` + +```python +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. + + +### `get_task_session_id` + +```python +get_task_session_id() -> str | None +``` + + +Get the session_id for the current background task, if available. + +Loads the task snapshot (from cache or Redis) and returns the session_id +that was captured at task submission time. Returns None if not in a task +context or if the snapshot isn't available. + + +### `register_task_session` + +```python +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. + + +### `get_task_session` + +```python +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. + + +### `register_task_server` + +```python +register_task_server(task_id: str, server: FastMCP) -> None +``` + + +Register the server for a background task. + +Called at task-submission time so that background workers can resolve +the correct (child) server for mounted tasks. + + +### `get_task_server` + +```python +get_task_server(task_id: str) -> FastMCP | None +``` + + +Get the registered server for a background task, if still alive. + + +## Classes + +### `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. + + +### `TaskContextSnapshot` + + +All context data snapshotted at task-submission time. + +Stored as a single Redis key per task, restored once in the worker. + + +**Methods:** + +#### `capture` + +```python +capture(cls) -> TaskContextSnapshot +``` + +Capture current context for background task execution. + + +#### `from_json` + +```python +from_json(cls, raw: str | bytes) -> TaskContextSnapshot +``` + +Deserialize from JSON stored in Redis. + + +#### `to_json` + +```python +to_json(self) -> str +``` + +Serialize to JSON for Redis storage. + + +#### `save` + +```python +save(self, docket: Docket, task_scope: str | None, task_id: str, ttl_seconds: int) -> None +``` + +Store this snapshot as a single Redis key. + diff --git a/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx b/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx index cc6f3dea2..3914d207c 100644 --- a/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx +++ b/docs/python-sdk/fastmcp-server-tasks-elicitation.mdx @@ -23,7 +23,7 @@ internal APIs for background task coordination. ## Functions -### `elicit_for_task` +### `elicit_for_task` ```python elicit_for_task(task_id: str, session: ServerSession | None, message: str, schema: dict[str, Any], fastmcp: FastMCP) -> mcp.types.ElicitResult @@ -50,10 +50,10 @@ in a Docket worker context where there's no active MCP request. - `McpError`: If the elicitation request fails -### `relay_elicitation` +### `relay_elicitation` ```python -relay_elicitation(session: ServerSession, session_id: str, task_id: str, elicitation: dict[str, Any], fastmcp: FastMCP) -> None +relay_elicitation(session: ServerSession, task_scope: str | None, task_id: str, elicitation: dict[str, Any], fastmcp: FastMCP) -> None ``` @@ -66,16 +66,16 @@ response to Redis so the blocked worker can resume. **Args:** - `session`: MCP ServerSession -- `session_id`: Session identifier +- `task_scope`: Authorization scope for Redis key construction - `task_id`: Background task ID - `elicitation`: Elicitation metadata (message, requestedSchema) - `fastmcp`: FastMCP server instance -### `handle_task_input` +### `handle_task_input` ```python -handle_task_input(task_id: str, session_id: str, action: str, content: dict[str, Any] | None, fastmcp: FastMCP) -> bool +handle_task_input(task_id: str, task_scope: str | None, action: str, content: dict[str, Any] | None, fastmcp: FastMCP) -> bool ``` @@ -86,7 +86,7 @@ request from a background task. **Args:** - `task_id`: The background task ID -- `session_id`: The MCP session 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 diff --git a/docs/python-sdk/fastmcp-server-tasks-handlers.mdx b/docs/python-sdk/fastmcp-server-tasks-handlers.mdx index 3493b752e..fd3659421 100644 --- a/docs/python-sdk/fastmcp-server-tasks-handlers.mdx +++ b/docs/python-sdk/fastmcp-server-tasks-handlers.mdx @@ -13,7 +13,7 @@ Handles queuing tool/prompt/resource executions to Docket as background tasks. ## Functions -### `submit_to_docket` +### `submit_to_docket` ```python submit_to_docket(task_type: Literal['tool', 'resource', 'template', 'prompt'], key: str, component: Tool | Resource | ResourceTemplate | Prompt, arguments: dict[str, Any] | None = None, task_meta: TaskMeta | None = None) -> mcp.types.CreateTaskResult diff --git a/docs/python-sdk/fastmcp-server-tasks-keys.mdx b/docs/python-sdk/fastmcp-server-tasks-keys.mdx index a274d3c1d..a852fadd9 100644 --- a/docs/python-sdk/fastmcp-server-tasks-keys.mdx +++ b/docs/python-sdk/fastmcp-server-tasks-keys.mdx @@ -6,34 +6,40 @@ sidebarTitle: keys # `fastmcp.server.tasks.keys` -Task key management for SEP-1686 background tasks. +Docket and Redis key encoding for background tasks. -Task keys encode security scoping and metadata in the Docket key format: - `{session_id}:{client_task_id}:{task_type}:{component_identifier}` +The compound Docket task key embeds the auth boundary so that the parser can +reject cross-scope access without consulting Redis. Authenticated and +anonymous tasks live in disjoint keyspaces: -This format provides: -- Session-based security scoping (prevents cross-session access) -- Task type identification (tool/prompt/resource) -- Component identification (name or URI for result conversion) + auth:{enc_scope}:{client_task_id}:{task_type}:{enc_identifier} + anon:{client_task_id}:{task_type}:{enc_identifier} + +The same `auth/anon` partition is used for the per-task Redis prefix +(``fastmcp:task:auth:{enc_scope}`` vs ``fastmcp:task:anon``) — see +``task_redis_prefix``. + +``task_scope`` is the raw scope identifier (typically derived from +``client_id`` or ``client_id|sub``); encoding happens once, at the boundary, +in this module. ## Functions -### `build_task_key` +### `build_task_key` ```python -build_task_key(session_id: str, client_task_id: str, task_type: str, component_identifier: str) -> str +build_task_key(task_scope: str | None, client_task_id: str, task_type: str, component_identifier: str) -> str ``` Build Docket task key with embedded metadata. -Format: `{session_id}:{client_task_id}:{task_type}:{component_identifier}` - -The component_identifier is URI-encoded to handle special characters (colons, slashes, etc.). +When ``task_scope`` is ``None`` the task is anonymous and lives in the +``anon`` keyspace. Otherwise it lives under ``auth:{enc_scope}``. **Args:** -- `session_id`: Session ID for security scoping +- `task_scope`: Raw authorization scope, or ``None`` for anonymous tasks - `client_task_id`: Client-provided task ID - `task_type`: Type of task ("tool", "prompt", "resource") - `component_identifier`: Tool name, prompt name, or resource URI @@ -43,16 +49,18 @@ The component_identifier is URI-encoded to handle special characters (colons, sl **Examples:** ->>> build_task_key("session123", "task456", "tool", "my_tool") -'session123:task456:tool:my_tool' ->>> build_task_key("session123", "task456", "resource", "file://data.txt") -'session123:task456:resource:file%3A%2F%2Fdata.txt' +>>> build_task_key("client-a", "task456", "tool", "my_tool") +'auth:client-a:task456:tool:my_tool' +>>> build_task_key(None, "task456", "tool", "my_tool") +'anon:task456:tool:my_tool' +>>> build_task_key("client-a", "task456", "resource", "file://data.txt") +'auth:client-a:task456:resource:file%3A%2F%2Fdata.txt' -### `parse_task_key` +### `parse_task_key` ```python -parse_task_key(task_key: str) -> dict[str, str] +parse_task_key(task_key: str) -> TaskKeyParts ``` @@ -62,17 +70,21 @@ Parse Docket task key to extract metadata. - `task_key`: Encoded task key from Docket **Returns:** -- Dict with keys: session_id, client_task_id, task_type, component_identifier +- Dict with keys: ``task_scope`` (``str | None``), ``client_task_id``, +- ``task_type``, ``component_identifier``. + +**Raises:** +- `ValueError`: If the key has an unrecognized tag or wrong segment count. **Examples:** ->>> parse_task_key("session123:task456:tool:my_tool") -`{'session_id': 'session123', 'client_task_id': 'task456', 'task_type': 'tool', 'component_identifier': 'my_tool'}` ->>> parse_task_key("session123:task456:resource:file%3A%2F%2Fdata.txt") -`{'session_id': 'session123', 'client_task_id': 'task456', 'task_type': 'resource', 'component_identifier': 'file://data.txt'}` +>>> parse_task_key("auth:client-a:task456:tool:my_tool") +`{'task_scope': 'client-a', 'client_task_id': 'task456', 'task_type': 'tool', 'component_identifier': 'my_tool'}` +>>> parse_task_key("anon:task456:tool:my_tool") +`{'task_scope': None, 'client_task_id': 'task456', 'task_type': 'tool', 'component_identifier': 'my_tool'}` -### `get_client_task_id_from_key` +### `get_client_task_id_from_key` ```python get_client_task_id_from_key(task_key: str) -> str @@ -85,5 +97,37 @@ Extract just the client task ID from a task key. - `task_key`: Full encoded task key **Returns:** -- Client-provided task ID (second segment) +- Client-provided task ID + +**Examples:** + +>>> get_client_task_id_from_key("auth:client-a:task456:tool:my_tool") +'task456' +>>> get_client_task_id_from_key("anon:task456:tool:my_tool") +'task456' + + +### `task_redis_prefix` + +```python +task_redis_prefix(task_scope: str | None) -> str +``` + + +Return the Redis key prefix that owns a given scope. + +Authenticated tasks live under ``fastmcp:task:auth:{enc_scope}``; +anonymous tasks live under ``fastmcp:task:anon``. Callers append +``f":{task_id}:..."`` to compose the final key. + + +## Classes + +### `TaskKeyParts` + + +Decoded segments of a Docket task key. + +``task_scope`` is ``None`` for anonymous tasks, the raw scope string +otherwise. diff --git a/docs/python-sdk/fastmcp-server-tasks-notifications.mdx b/docs/python-sdk/fastmcp-server-tasks-notifications.mdx index 217b4b6ac..441760f34 100644 --- a/docs/python-sdk/fastmcp-server-tasks-notifications.mdx +++ b/docs/python-sdk/fastmcp-server-tasks-notifications.mdx @@ -67,7 +67,7 @@ This loop: - `fastmcp`: FastMCP server instance (for elicitation relay) -### `ensure_subscriber_running` +### `ensure_subscriber_running` ```python ensure_subscriber_running(session_id: str, session: ServerSession, docket: Docket, fastmcp: FastMCP) -> None @@ -86,7 +86,7 @@ Safe to call multiple times for the same session. - `fastmcp`: FastMCP server instance (for elicitation relay) -### `stop_subscriber` +### `stop_subscriber` ```python stop_subscriber(session_id: str) -> None @@ -102,7 +102,7 @@ for delivery if client reconnects (with TTL expiration). - `session_id`: Session identifier -### `get_subscriber_count` +### `get_subscriber_count` ```python get_subscriber_count() -> int diff --git a/docs/python-sdk/fastmcp-server-tasks-requests.mdx b/docs/python-sdk/fastmcp-server-tasks-requests.mdx index a8b31a13d..64ac4a263 100644 --- a/docs/python-sdk/fastmcp-server-tasks-requests.mdx +++ b/docs/python-sdk/fastmcp-server-tasks-requests.mdx @@ -16,7 +16,7 @@ This module requires fastmcp[tasks] (pydocket). It is only imported when docket ## Functions -### `tasks_get_handler` +### `tasks_get_handler` ```python tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskResult @@ -33,7 +33,7 @@ Handle MCP 'tasks/get' request (SEP-1686). - Task status response with spec-compliant fields -### `tasks_result_handler` +### `tasks_result_handler` ```python tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any @@ -52,7 +52,7 @@ Converts raw task return values to MCP types based on task type. - MCP result (CallToolResult, GetPromptResult, or ReadResourceResult) -### `tasks_list_handler` +### `tasks_list_handler` ```python tasks_list_handler(server: FastMCP, params: dict[str, Any]) -> ListTasksResult @@ -71,7 +71,7 @@ Note: With client-side tracking, this returns minimal info. - Response with tasks list and pagination -### `tasks_cancel_handler` +### `tasks_cancel_handler` ```python tasks_cancel_handler(server: FastMCP, params: dict[str, Any]) -> CancelTaskResult diff --git a/docs/python-sdk/fastmcp-tools-base.mdx b/docs/python-sdk/fastmcp-tools-base.mdx index 4f8d362ea..e7bc30f5d 100644 --- a/docs/python-sdk/fastmcp-tools-base.mdx +++ b/docs/python-sdk/fastmcp-tools-base.mdx @@ -78,7 +78,7 @@ Handles ToolResult passthrough and converts raw values using the tool's attributes (serializer, output_schema) for proper conversion. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -87,7 +87,7 @@ register_with_docket(self, docket: Docket) -> None Register this tool with docket for background execution. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any], **kwargs: Any) -> Execution @@ -103,13 +103,13 @@ Schedule this tool for background execution via docket. - `**kwargs`: Additional kwargs passed to docket.add() -#### `from_tool` +#### `from_tool` ```python from_tool(cls, tool: Tool | Callable[..., Any]) -> TransformedTool ``` -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-tools-function_parsing.mdx b/docs/python-sdk/fastmcp-tools-function_parsing.mdx index 6264ef5d3..bfe78f10e 100644 --- a/docs/python-sdk/fastmcp-tools-function_parsing.mdx +++ b/docs/python-sdk/fastmcp-tools-function_parsing.mdx @@ -10,11 +10,11 @@ Function introspection and schema generation for FastMCP tools. ## Classes -### `ParsedFunction` +### `ParsedFunction` **Methods:** -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any], exclude_args: list[str] | None = None, validate: bool = True, wrap_non_object_output_schema: bool = True) -> ParsedFunction diff --git a/docs/python-sdk/fastmcp-tools-function_tool.mdx b/docs/python-sdk/fastmcp-tools-function_tool.mdx index 9bb157d22..5d16699b1 100644 --- a/docs/python-sdk/fastmcp-tools-function_tool.mdx +++ b/docs/python-sdk/fastmcp-tools-function_tool.mdx @@ -10,7 +10,7 @@ Standalone @tool decorator for FastMCP. ## Functions -### `tool` +### `tool` ```python tool(name_or_fn: str | Callable[..., Any] | None = None) -> Any @@ -57,7 +57,7 @@ individual parameters must not be passed. Cannot be used together with metadata parameter. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -66,7 +66,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult Run the tool with arguments. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -74,11 +74,12 @@ register_with_docket(self, docket: Docket) -> None Register this tool with docket for background execution. -FunctionTool registers the underlying function, which has the user's -Depends parameters for docket to resolve. +Registers the raw function so Docket sees and resolves ALL +dependencies — both FastMCP's (CurrentContext, Progress) and +Docket-native ones (Retry, Timeout, ConcurrencyLimit). -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any], **kwargs: Any) -> Execution diff --git a/docs/python-sdk/fastmcp-tools-tool_transform.mdx b/docs/python-sdk/fastmcp-tools-tool_transform.mdx index 24f6270aa..9ba17c0e6 100644 --- a/docs/python-sdk/fastmcp-tools-tool_transform.mdx +++ b/docs/python-sdk/fastmcp-tools-tool_transform.mdx @@ -7,7 +7,7 @@ sidebarTitle: tool_transform ## Functions -### `forward` +### `forward` ```python forward(**kwargs: Any) -> ToolResult @@ -36,7 +36,7 @@ tool has args `a` and `b`, and an `transform_args` was provided that maps `x` to - `TypeError`: If provided arguments don't match the transformed schema. -### `forward_raw` +### `forward_raw` ```python forward_raw(**kwargs: Any) -> ToolResult @@ -62,7 +62,7 @@ y=2)` will call the parent tool with `x=1` and `y=2`. - `RuntimeError`: If called outside a transformed tool context. -### `apply_transformations_to_tools` +### `apply_transformations_to_tools` ```python apply_transformations_to_tools(tools: dict[str, Tool], transformations: dict[str, ToolTransformConfig]) -> dict[str, Tool] @@ -78,7 +78,7 @@ but transformations are keyed by tool name (e.g., "my_tool"). ## Classes -### `ArgTransform` +### `ArgTransform` Configuration for transforming a parent tool's argument. @@ -150,7 +150,7 @@ ArgTransform(name="new_name", description="New desc", default=None, type=int) ``` -### `ArgTransformConfig` +### `ArgTransformConfig` A model for requesting a single argument transform. @@ -158,7 +158,7 @@ A model for requesting a single argument transform. **Methods:** -#### `to_arg_transform` +#### `to_arg_transform` ```python to_arg_transform(self) -> ArgTransform @@ -167,7 +167,7 @@ to_arg_transform(self) -> ArgTransform Convert the argument transform to a FastMCP argument transform. -### `TransformedTool` +### `TransformedTool` A tool that is transformed from another tool. @@ -191,7 +191,7 @@ validation when forward() is called from custom functions. **Methods:** -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -210,7 +210,7 @@ functions. - ToolResult object containing content and optional structured output. -#### `from_tool` +#### `from_tool` ```python from_tool(cls, tool: Tool | Callable[..., Any], name: str | None = None, version: str | NotSetT | None = NotSet, title: str | NotSetT | None = NotSet, description: str | NotSetT | None = NotSet, tags: set[str] | None = None, transform_fn: Callable[..., Any] | None = None, transform_args: dict[str, ArgTransform] | None = None, annotations: ToolAnnotations | NotSetT | None = NotSet, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: Callable[[Any], str] | NotSetT | None = NotSet, meta: dict[str, Any] | NotSetT | None = NotSet) -> TransformedTool @@ -227,17 +227,14 @@ argument names. - `version`: New version for the tool. Defaults to parent tool's version. - `title`: New title for the tool. Defaults to parent tool's title. - `transform_args`: Optional transformations for parent tool arguments. -Only specified arguments are transformed, others pass through unchanged\: -- Simple rename (str) -- Complex transformation (rename/description/default/drop) (ArgTransform) -- Drop the argument (None) +Only specified arguments are transformed, others pass through unchanged. +Use ArgTransform for rename, description, default, or hide operations. - `description`: New description. Defaults to parent's description. - `tags`: New tags. Defaults to parent's tags. - `annotations`: New annotations. Defaults to parent's annotations. - `output_schema`: Control output schema for structured outputs\: - None (default)\: Inherit from transform_fn if available, then parent tool - dict\: Use custom output schema -- False\: Disable output schema and structured outputs - `serializer`: Deprecated. Return ToolResult from your tools for full control over serialization. - `meta`: Control meta information\: - NotSet (default)\: Inherit from parent tool @@ -293,7 +290,7 @@ async def custom_output(**kwargs) -> ToolResult: ``` -### `ToolTransformConfig` +### `ToolTransformConfig` Provides a way to transform a tool. @@ -301,7 +298,7 @@ Provides a way to transform a tool. **Methods:** -#### `apply` +#### `apply` ```python apply(self, tool: Tool) -> TransformedTool diff --git a/docs/python-sdk/fastmcp-utilities-async_utils.mdx b/docs/python-sdk/fastmcp-utilities-async_utils.mdx index 75c6edd47..d91c4e9ba 100644 --- a/docs/python-sdk/fastmcp-utilities-async_utils.mdx +++ b/docs/python-sdk/fastmcp-utilities-async_utils.mdx @@ -10,7 +10,7 @@ Async utilities for FastMCP. ## Functions -### `is_coroutine_function` +### `is_coroutine_function` ```python is_coroutine_function(fn: Any) -> bool @@ -24,7 +24,7 @@ Check if a callable is a coroutine function, unwrapping functools.partial. This helper unwraps any layers of ``partial`` before checking. -### `call_sync_fn_in_threadpool` +### `call_sync_fn_in_threadpool` ```python call_sync_fn_in_threadpool(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any @@ -37,7 +37,7 @@ Uses anyio.to_thread.run_sync which properly propagates contextvars, making this safe for functions that depend on context (like dependency injection). -### `gather` +### `gather` ```python gather(*awaitables: Awaitable[T]) -> list[T] | list[T | BaseException] diff --git a/docs/python-sdk/fastmcp-utilities-components.mdx b/docs/python-sdk/fastmcp-utilities-components.mdx index e45129a28..ab05e9d29 100644 --- a/docs/python-sdk/fastmcp-utilities-components.mdx +++ b/docs/python-sdk/fastmcp-utilities-components.mdx @@ -64,8 +64,15 @@ The @ suffix is ALWAYS present to enable unambiguous parsing of keys Subclasses should override this to use their specific identifier. Base implementation uses name. +Prefer `.key` over ad-hoc `name or uri or uri_template` logic for any +cross-component identity work (dedupe, grouping, collision detection, +lookup tables). It encodes type, identifier, and version, so variants +of the same component don't falsely collide with each other, and +cross-type identifiers (e.g. a tool and a resource both named "foo") +can't clash. -#### `get_meta` + +#### `get_meta` ```python get_meta(self) -> dict[str, Any] @@ -80,7 +87,7 @@ Returns a dict that always includes a `fastmcp` key containing: Internal keys (prefixed with `_`) are stripped from the fastmcp namespace. -#### `enable` +#### `enable` ```python enable(self) -> None @@ -89,7 +96,7 @@ enable(self) -> None Removed in 3.0. Use server.enable(keys=[...]) instead. -#### `disable` +#### `disable` ```python disable(self) -> None @@ -98,7 +105,7 @@ disable(self) -> None Removed in 3.0. Use server.disable(keys=[...]) instead. -#### `copy` +#### `copy` ```python copy(self) -> Self @@ -107,7 +114,7 @@ copy(self) -> Self Create a copy of the component. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -119,7 +126,7 @@ No-ops if task_config.mode is "forbidden". Subclasses override to register their callable (self.run, self.read, self.render, or self.fn). -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, *args: Any, **kwargs: Any) -> Execution @@ -136,7 +143,7 @@ Subclasses override this to handle their specific calling conventions: The **kwargs are passed through to docket.add() (e.g., key=task_key). -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-utilities-docstring_parsing.mdx b/docs/python-sdk/fastmcp-utilities-docstring_parsing.mdx new file mode 100644 index 000000000..c3fff9eea --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-docstring_parsing.mdx @@ -0,0 +1,39 @@ +--- +title: docstring_parsing +sidebarTitle: docstring_parsing +--- + +# `fastmcp.utilities.docstring_parsing` + + +Extract descriptions from function docstrings. + +Uses griffelib to parse Google, NumPy, and Sphinx-style docstrings. The +interface is intentionally narrow — a single function returning a +`ParsedDocstring` — so the implementation can be swapped without touching +callers. + + +## Functions + +### `parse_docstring` + +```python +parse_docstring(fn: Callable[..., Any]) -> ParsedDocstring +``` + + +Parse a function's docstring into a summary and parameter descriptions. + +Tries Google, NumPy, and Sphinx parsers in order, using the first one that +successfully extracts parameter descriptions. If none do, returns the full +docstring as the description with no parameter descriptions. + + +## Classes + +### `ParsedDocstring` + + +The extracted description and per-parameter descriptions from a docstring. + diff --git a/docs/python-sdk/fastmcp-utilities-json_schema.mdx b/docs/python-sdk/fastmcp-utilities-json_schema.mdx index 83eeee2b3..a55f77b08 100644 --- a/docs/python-sdk/fastmcp-utilities-json_schema.mdx +++ b/docs/python-sdk/fastmcp-utilities-json_schema.mdx @@ -7,7 +7,7 @@ sidebarTitle: json_schema ## Functions -### `dereference_refs` +### `dereference_refs` ```python dereference_refs(schema: dict[str, Any]) -> dict[str, Any] @@ -40,7 +40,7 @@ schemas from untrusted servers. - when no longer needed -### `resolve_root_ref` +### `resolve_root_ref` ```python resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any] @@ -62,7 +62,7 @@ the referenced definition while preserving $defs for nested references. - if no resolution is needed -### `compress_schema` +### `compress_schema` ```python compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False, dereference: bool = False) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-utilities-json_schema_type.mdx b/docs/python-sdk/fastmcp-utilities-json_schema_type.mdx index b1137e7de..ca7e64853 100644 --- a/docs/python-sdk/fastmcp-utilities-json_schema_type.mdx +++ b/docs/python-sdk/fastmcp-utilities-json_schema_type.mdx @@ -42,17 +42,18 @@ Example: ## Functions -### `json_schema_to_type` +### `json_schema_to_type` ```python -json_schema_to_type(schema: Mapping[str, Any], name: str | None = None) -> type +json_schema_to_type(schema: Mapping[str, Any] | bool, name: str | None = None) -> type ``` Convert JSON schema to appropriate Python type with validation. **Args:** -- `schema`: A JSON Schema dictionary defining the type structure and validation rules +- `schema`: A JSON Schema dictionary defining the type structure and validation rules. +Boolean schemas are also accepted (``True`` = any type, ``False`` = unsatisfiable). - `name`: Optional name for object schemas. Only allowed when schema type is "object". If not provided for objects, name will be inferred from schema's "title" property or default to "Root". @@ -107,4 +108,4 @@ class Name: ## Classes -### `JSONSchema` +### `JSONSchema` diff --git a/docs/python-sdk/fastmcp-utilities-openapi-json_schema_converter.mdx b/docs/python-sdk/fastmcp-utilities-openapi-json_schema_converter.mdx index abd1d6869..ad596a5c9 100644 --- a/docs/python-sdk/fastmcp-utilities-openapi-json_schema_converter.mdx +++ b/docs/python-sdk/fastmcp-utilities-openapi-json_schema_converter.mdx @@ -16,7 +16,7 @@ for our specific use case. ## Functions -### `convert_openapi_schema_to_json_schema` +### `convert_openapi_schema_to_json_schema` ```python convert_openapi_schema_to_json_schema(schema: dict[str, Any], openapi_version: str | None = None, remove_read_only: bool = False, remove_write_only: bool = False, convert_one_of_to_any_of: bool = True) -> dict[str, Any] @@ -43,7 +43,7 @@ This is a clean, systematic approach that: - JSON Schema-compatible dictionary -### `convert_schema_definitions` +### `convert_schema_definitions` ```python convert_schema_definitions(schema_definitions: dict[str, Any] | None, openapi_version: str | None = None, **kwargs) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-utilities-openapi-schemas.mdx b/docs/python-sdk/fastmcp-utilities-openapi-schemas.mdx index fad88ee5f..47ee1e699 100644 --- a/docs/python-sdk/fastmcp-utilities-openapi-schemas.mdx +++ b/docs/python-sdk/fastmcp-utilities-openapi-schemas.mdx @@ -10,7 +10,7 @@ Schema manipulation utilities for OpenAPI operations. ## Functions -### `clean_schema_for_display` +### `clean_schema_for_display` ```python clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None @@ -20,7 +20,7 @@ clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None Clean up a schema dictionary for display by removing internal/complex fields. -### `extract_output_schema_from_responses` +### `extract_output_schema_from_responses` ```python extract_output_schema_from_responses(responses: dict[str, ResponseInfo], schema_definitions: dict[str, Any] | None = None, openapi_version: str | None = None) -> dict[str, Any] | None diff --git a/docs/servers/auth/authentication.mdx b/docs/servers/auth/authentication.mdx index 9a26df138..d37c57f36 100644 --- a/docs/servers/auth/authentication.mdx +++ b/docs/servers/auth/authentication.mdx @@ -161,7 +161,7 @@ The implementation provides all required OAuth endpoints including authorization ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.oauth import MyOAuthProvider +from fastmcp.server.auth import OAuthProvider auth = MyOAuthProvider( user_store=your_user_database, diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 19f5c2cee..1a67dea65 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -117,6 +117,12 @@ mcp = FastMCP(name="My Server", auth=auth) 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). + + 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. + + Path for OAuth callbacks. Must match the redirect URI configured in your OAuth application @@ -165,6 +171,14 @@ mcp = FastMCP(name="My Server", auth=auth) provider doesn't support PKCE + + 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. + + Token endpoint authentication method for the upstream OAuth server. Controls how the proxy authenticates when exchanging authorization codes and refresh @@ -386,7 +400,7 @@ auth = OAuthProxy( ) ``` -The proxy also automatically forwards RFC 8707 `resource` parameters from MCP clients to upstream providers that support them. +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 diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index 73ab43e73..d811d8583 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -79,6 +79,12 @@ mcp = FastMCP(name="My Server", auth=auth) Public URL of your FastMCP server (e.g., `https://your-server.com`) + + 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. + + Strict flag for configuration validation. When True, requires all OIDC mandatory fields. diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx index a10161baf..d442743ab 100644 --- a/docs/servers/context.mdx +++ b/docs/servers/context.mdx @@ -277,6 +277,43 @@ 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. diff --git a/docs/servers/dependency-injection.mdx b/docs/servers/dependency-injection.mdx index d13f43952..40fc7b65b 100644 --- a/docs/servers/dependency-injection.mdx +++ b/docs/servers/dependency-injection.mdx @@ -160,14 +160,20 @@ def get_client_ip() -> str: ``` -Both raise `RuntimeError` when called outside an HTTP context (e.g., STDIO transport). Use HTTP Headers if you need graceful fallback. +Both raise `RuntimeError` when called outside an HTTP context (e.g., STDIO transport). +For background tasks created from an HTTP request, FastMCP restores a minimal request +backed by the originating request's snapshotted headers. Use HTTP Headers if you need +graceful fallback. ### HTTP Headers -Access HTTP headers with graceful fallback—returns an empty dictionary when no HTTP request is available, making it safe for code that might run over any transport. +Access HTTP headers with graceful fallback. When a background task originates from an +HTTP request, FastMCP restores the originating headers inside the worker. When no HTTP +request is available, this returns an empty dictionary, making it safe for code that +might run over any transport. **Dependency injection:** Use `CurrentHeaders()`: diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx index 424f5afa7..37713a8d7 100644 --- a/docs/servers/middleware.mdx +++ b/docs/servers/middleware.mdx @@ -84,6 +84,8 @@ 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: @@ -421,11 +423,21 @@ Each settings class accepts: For persistence or distributed deployments, configure a different storage backend: ```python +from pathlib import Path from fastmcp.server.middleware.caching import ResponseCachingMiddleware -from key_value.aio.stores.disk import DiskStore +from key_value.aio.stores.filetree import ( + FileTreeStore, + FileTreeV1KeySanitizationStrategy, + FileTreeV1CollectionSanitizationStrategy, +) +cache_dir = Path("cache") mcp.add_middleware(ResponseCachingMiddleware( - cache_storage=DiskStore(directory="cache") + cache_storage=FileTreeStore( + data_directory=cache_dir, + key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(cache_dir), + collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(cache_dir), + ) )) ``` diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx index bdfefc24e..b5cf2f6e9 100644 --- a/docs/servers/prompts.mdx +++ b/docs/servers/prompts.mdx @@ -54,7 +54,7 @@ def generate_code_request(language: str, task_description: str) -> list[Message] * **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 function's docstring. + * 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)). 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. @@ -88,7 +88,7 @@ def data_analysis_prompt( - Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose + 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)). @@ -201,6 +201,28 @@ Good choices: `list[int]`, `dict[str, str]`, `float`, `bool` Avoid: Complex Pydantic models, deeply nested structures, custom classes +### Argument Descriptions + + + +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: @@ -262,7 +284,7 @@ Message(["item1", "item2"]) `PromptResult` gives you explicit control over prompt responses: multiple messages, roles, and metadata at both the message and result level. -```python +```python test="skip" from fastmcp import FastMCP from fastmcp.prompts import PromptResult, Message diff --git a/docs/servers/providers/filesystem.mdx b/docs/servers/providers/filesystem.mdx index a5b798faa..353a671d5 100644 --- a/docs/servers/providers/filesystem.mdx +++ b/docs/servers/providers/filesystem.mdx @@ -34,13 +34,13 @@ from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers import FileSystemProvider -mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp")]) +mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "components")]) ``` -In your `mcp/` directory, create Python files with decorated functions. +In your `components/` directory, create Python files with decorated functions. ```python -# mcp/tools/greet.py +# components/tools/greet.py from fastmcp.tools import tool @tool @@ -114,7 +114,7 @@ The decorator supports: `uri` (required), `name`, `title`, `description`, `icons Mark a function as a prompt template. -```python +```python test="skip" from fastmcp.prompts import prompt @prompt @@ -139,7 +139,7 @@ The decorator supports: `name`, `title`, `description`, `icons`, `tags`, and `me 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. ``` -mcp/ +components/ ├── tools/ │ ├── greeting.py # @tool functions │ └── calculator.py # @tool functions @@ -152,7 +152,7 @@ mcp/ You can also put all components in a single file or organize by feature rather than type. ``` -mcp/ +components/ ├── user_management.py # @tool, @resource, @prompt for users ├── billing.py # @tool, @resource for billing └── analytics.py # @tool for analytics @@ -176,9 +176,9 @@ The provider follows these rules when scanning: 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 -# mcp/__init__.py exists +# components/__init__.py exists -# mcp/tools/greeting.py +# components/tools/greeting.py from ..helpers import format_name # Relative imports work @tool @@ -197,7 +197,7 @@ from pathlib import Path from fastmcp.server.providers import FileSystemProvider -provider = FileSystemProvider(Path(__file__).parent / "mcp", reload=True) +provider = FileSystemProvider(Path(__file__).parent / "components", reload=True) ``` With `reload=True`, the provider: @@ -227,7 +227,7 @@ A complete example is available in the repository at `examples/filesystem-provid ``` examples/filesystem-provider/ ├── server.py # Server entry point -└── mcp/ +└── components/ ├── tools/ │ ├── greeting.py # greet, farewell tools │ └── calculator.py # add, multiply tools @@ -246,7 +246,7 @@ from fastmcp import FastMCP from fastmcp.server.providers import FileSystemProvider provider = FileSystemProvider( - root=Path(__file__).parent / "mcp", + root=Path(__file__).parent / "components", reload=True, ) diff --git a/docs/servers/storage-backends.mdx b/docs/servers/storage-backends.mdx index 1bdb19b20..d13ce176d 100644 --- a/docs/servers/storage-backends.mdx +++ b/docs/servers/storage-backends.mdx @@ -64,7 +64,9 @@ store = FileTreeStore( middleware = ResponseCachingMiddleware(cache_storage=store) ``` -The sanitization strategies ensure keys and collection names are safe for the filesystem — alphanumeric names pass through as-is for readability, while special characters are hashed to prevent path traversal. + +**Sanitization strategies are required** when using `FileTreeStore`. Without them, keys containing special characters (such as URL-based OAuth client IDs like `https://claude.ai/oauth/claude-code-client-metadata`) will be used as-is in filesystem paths, causing `FileNotFoundError` crashes. The V1 strategies shown above are safe defaults — alphanumeric names pass through as-is for readability, while special characters are hashed to prevent path errors and traversal attacks. Changing sanitization strategies after data has been written is a breaking change, so choose your strategy upfront. + **Characteristics:** - ✅ Data persists across restarts @@ -246,7 +248,7 @@ The [FastMCP Client](/clients/client) uses storage for persisting OAuth tokens l ```python from pathlib import Path -from fastmcp.client.auth import OAuthClientProvider +from fastmcp.client.auth import OAuth from key_value.aio.stores.filetree import ( FileTreeStore, FileTreeV1KeySanitizationStrategy, @@ -261,7 +263,7 @@ token_storage = FileTreeStore( collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(token_dir), ) -oauth_provider = OAuthClientProvider( +oauth_provider = OAuth( mcp_url="https://your-mcp-server.com/mcp/sse", token_storage=token_storage ) diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 91d96d1be..129d0b2dc 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -36,7 +36,7 @@ def add(a: int, b: int) -> int: When this tool is registered, FastMCP automatically: - Uses the function name (`add`) as the tool name. -- Uses the function's docstring (`Adds two integer numbers...`) as the tool description. +- 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. @@ -70,7 +70,7 @@ 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 this purpose + 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)). @@ -289,6 +289,33 @@ The default flexible validation mode is recommended for most use cases as it han You can provide additional metadata about parameters in several ways: +#### Docstring Descriptions + + + +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 diff --git a/docs/tutorials/rest-api.mdx b/docs/tutorials/rest-api.mdx index a0857aca9..90872c950 100644 --- a/docs/tutorials/rest-api.mdx +++ b/docs/tutorials/rest-api.mdx @@ -152,7 +152,7 @@ Here’s how you can add custom route maps to turn `GET` requests into `Resource ```python api_server_with_resources.py {3, 37-42} import httpx from fastmcp import FastMCP -from fastmcp.server.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType # Create an HTTP client for the target API diff --git a/docs/v2-navigation.json b/docs/v2-navigation.json new file mode 100644 index 000000000..17865edfd --- /dev/null +++ b/docs/v2-navigation.json @@ -0,0 +1,197 @@ +{ + "dropdowns": [ + { + "dropdown": "Documentation", + "groups": [ + { + "group": "Get Started", + "pages": [ + "v2/getting-started/welcome", + "v2/getting-started/installation", + "v2/getting-started/quickstart", + "v2/updates" + ] + }, + { + "group": "Servers", + "pages": [ + "v2/servers/server", + { + "group": "Core Components", + "icon": "toolbox", + "pages": [ + "v2/servers/tools", + "v2/servers/resources", + "v2/servers/prompts" + ] + }, + { + "group": "Advanced Features", + "icon": "stars", + "pages": [ + "v2/servers/composition", + "v2/servers/context", + "v2/servers/elicitation", + "v2/servers/icons", + "v2/servers/logging", + "v2/servers/middleware", + "v2/servers/progress", + "v2/servers/proxy", + "v2/servers/sampling", + "v2/servers/storage-backends", + "v2/servers/tasks" + ] + }, + { + "group": "Authentication", + "icon": "shield-check", + "pages": [ + "v2/servers/auth/authentication", + "v2/servers/auth/token-verification", + "v2/servers/auth/remote-oauth", + "v2/servers/auth/oauth-proxy", + "v2/servers/auth/oidc-proxy", + "v2/servers/auth/full-oauth-server" + ] + }, + { + "group": "Deployment", + "icon": "rocket", + "pages": [ + "v2/deployment/running-server", + "v2/deployment/http", + "deployment/prefect-horizon", + "v2/deployment/server-configuration" + ] + } + ] + }, + { + "group": "Clients", + "pages": [ + { + "group": "Essentials", + "icon": "cube", + "pages": [ + "v2/clients/client", + "v2/clients/transports" + ] + }, + { + "group": "Core Operations", + "icon": "handshake", + "pages": [ + "v2/clients/tools", + "v2/clients/resources", + "v2/clients/prompts" + ] + }, + { + "group": "Advanced Features", + "icon": "stars", + "pages": [ + "v2/clients/elicitation", + "v2/clients/logging", + "v2/clients/progress", + "v2/clients/sampling", + "v2/clients/tasks", + "v2/clients/messages", + "v2/clients/roots" + ] + }, + { + "group": "Authentication", + "icon": "user-shield", + "pages": [ + "v2/clients/auth/oauth", + "v2/clients/auth/bearer" + ] + } + ] + }, + { + "group": "Integrations", + "pages": [ + { + "group": "Authentication", + "icon": "key", + "pages": [ + "v2/integrations/auth0", + "v2/integrations/authkit", + "v2/integrations/aws-cognito", + "v2/integrations/azure", + "v2/integrations/descope", + "v2/integrations/discord", + "v2/integrations/github", + "v2/integrations/google", + "v2/integrations/oci", + "v2/integrations/scalekit", + "v2/integrations/supabase", + "v2/integrations/workos" + ] + }, + { + "group": "Authorization", + "icon": "shield-check", + "pages": [ + "v2/integrations/eunomia-authorization", + "v2/integrations/permit" + ] + }, + { + "group": "AI Assistants", + "icon": "robot", + "pages": [ + "v2/integrations/chatgpt", + "v2/integrations/claude-code", + "v2/integrations/claude-desktop", + "v2/integrations/cursor", + "v2/integrations/gemini-cli", + "v2/integrations/mcp-json-configuration" + ] + }, + { + "group": "AI SDKs", + "icon": "code", + "pages": [ + "v2/integrations/anthropic", + "v2/integrations/gemini", + "v2/integrations/openai" + ] + }, + { + "group": "API Integration", + "icon": "globe", + "pages": [ + "v2/integrations/fastapi", + "v2/integrations/openapi" + ] + } + ] + }, + { + "group": "Patterns", + "pages": [ + "v2/patterns/tool-transformation", + "v2/patterns/decorating-methods", + "v2/patterns/cli", + "v2/patterns/contrib", + "v2/patterns/testing" + ] + }, + { + "group": "Development", + "pages": [ + "v2/development/contributing", + "v2/development/tests", + "v2/development/releases", + "v2/development/upgrade-guide", + "v2/changelog" + ] + } + ], + "icon": "book" + } + ], + "version": "v2.14.5" +} diff --git a/docs/v2/deployment/http.mdx b/docs/v2/deployment/http.mdx index eafa773b3..fa91c65e0 100644 --- a/docs/v2/deployment/http.mdx +++ b/docs/v2/deployment/http.mdx @@ -650,17 +650,17 @@ FASTMCP_STATELESS_HTTP=true uvicorn app:app --host 0.0.0.0 --port 8000 --workers 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 bearer token authentication (though OAuth is recommended for production): +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 BearerTokenAuth +from fastmcp.server.auth import StaticTokenVerifier # Read configuration from environment auth_token = os.environ.get("MCP_AUTH_TOKEN") if auth_token: - auth = BearerTokenAuth(token=auth_token) + auth = StaticTokenVerifier(tokens={auth_token: {"sub": "admin", "client_id": "cli"}}) mcp = FastMCP("Production Server", auth=auth) else: mcp = FastMCP("Production Server") diff --git a/docs/v2/development/upgrade-guide.mdx b/docs/v2/development/upgrade-guide.mdx index 2e98b4a02..93b314829 100644 --- a/docs/v2/development/upgrade-guide.mdx +++ b/docs/v2/development/upgrade-guide.mdx @@ -19,11 +19,11 @@ The experimental OpenAPI parser is now the standard implementation. The legacy p **If you were using the experimental parser:** Update your imports from the experimental module to the standard location: -```python Before +```python test="skip" Before from fastmcp.experimental.server.openapi import FastMCPOpenAPI, RouteMap, MCPType ``` -```python After +```python test="skip" After from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, MCPType ``` @@ -36,7 +36,7 @@ The following deprecated features have been removed in v2.14.0: **BearerAuthProvider** (deprecated in v2.11): -```python Before +```python test="skip" Before from fastmcp.server.auth.providers.bearer import BearerAuthProvider ``` @@ -47,7 +47,7 @@ from fastmcp.server.auth.providers.jwt import JWTVerifier **Context.get_http_request()** (deprecated in v2.2.11): -```python Before +```python test="skip" Before request = context.get_http_request() ``` @@ -59,7 +59,7 @@ request = get_http_request() **Top-level Image import** (deprecated in v2.8.1): -```python Before +```python test="skip" Before from fastmcp import Image ``` diff --git a/docs/v2/getting-started/welcome.mdx b/docs/v2/getting-started/welcome.mdx index c00788f72..fae633f34 100644 --- a/docs/v2/getting-started/welcome.mdx +++ b/docs/v2/getting-started/welcome.mdx @@ -96,7 +96,7 @@ from fastmcp import Client async def main(): async with Client("https://gofastmcp.com/mcp") as client: result = await client.call_tool( - name="SearchFastMcp", + name="search_fast_mcp", arguments={"query": "deploy a FastMCP server"} ) print(result) diff --git a/docs/v2/integrations/anthropic.mdx b/docs/v2/integrations/anthropic.mdx index 7490bcf35..7d2d38dc1 100644 --- a/docs/v2/integrations/anthropic.mdx +++ b/docs/v2/integrations/anthropic.mdx @@ -181,7 +181,7 @@ if __name__ == "__main__": 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. -```python +```text Error code: 400 - { "type": "error", "error": { diff --git a/docs/v2/integrations/descope.mdx b/docs/v2/integrations/descope.mdx index abba9069d..14bade5f4 100644 --- a/docs/v2/integrations/descope.mdx +++ b/docs/v2/integrations/descope.mdx @@ -64,8 +64,8 @@ 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 + 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 diff --git a/docs/v2/integrations/fastapi.mdx b/docs/v2/integrations/fastapi.mdx index 3737345f1..67d5d06de 100644 --- a/docs/v2/integrations/fastapi.mdx +++ b/docs/v2/integrations/fastapi.mdx @@ -216,7 +216,7 @@ Because FastMCP's FastAPI integration is based on its [OpenAPI integration](/v2/ ```python # Assumes the FastAPI app from above is already defined from fastmcp import FastMCP -from fastmcp.server.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType # Custom mapping rules mcp = FastMCP.from_fastapi( diff --git a/docs/v2/integrations/mcp-json-configuration.mdx b/docs/v2/integrations/mcp-json-configuration.mdx index a9b758fb7..b85bf9e4f 100644 --- a/docs/v2/integrations/mcp-json-configuration.mdx +++ b/docs/v2/integrations/mcp-json-configuration.mdx @@ -357,6 +357,98 @@ 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" + ] + } + } +} +``` + + +`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. + + ## Integration with MCP Clients The generated configuration works with any MCP-compatible application: diff --git a/docs/v2/integrations/openai.mdx b/docs/v2/integrations/openai.mdx index 63b5e28b4..af41528f9 100644 --- a/docs/v2/integrations/openai.mdx +++ b/docs/v2/integrations/openai.mdx @@ -178,8 +178,8 @@ if __name__ == "__main__": If you try to call the authenticated server with the same OpenAI code we wrote earlier, you'll get an error like this: -```python -pythonAPIStatusError: Error code: 424 - { +```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", diff --git a/docs/v2/integrations/openapi.mdx b/docs/v2/integrations/openapi.mdx index 8662a0c05..0fd38cbd0 100644 --- a/docs/v2/integrations/openapi.mdx +++ b/docs/v2/integrations/openapi.mdx @@ -81,7 +81,7 @@ Each `RouteMap` specifies a combination of methods, patterns, and tags, as well Here is FastMCP's default rule: ```python -from fastmcp.server.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType DEFAULT_ROUTE_MAPPINGS = [ # All routes become tools @@ -97,7 +97,7 @@ For example, prior to FastMCP 2.8.0, GET requests were automatically mapped to ` ```python from fastmcp import FastMCP -from fastmcp.server.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType # Restore pre-2.8.0 semantic mapping semantic_maps = [ @@ -120,7 +120,7 @@ Here is a more complete example that uses custom route maps to convert all `GET` ```python from fastmcp import FastMCP -from fastmcp.server.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType mcp = FastMCP.from_openapi( openapi_spec=spec, @@ -160,7 +160,7 @@ You can use this to remove sensitive or internal routes by targeting them specif ```python from fastmcp import FastMCP -from fastmcp.server.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType mcp = FastMCP.from_openapi( openapi_spec=spec, @@ -176,7 +176,7 @@ Or you can use a catch-all rule to exclude everything that your maps don't handl ```python from fastmcp import FastMCP -from fastmcp.server.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType mcp = FastMCP.from_openapi( openapi_spec=spec, @@ -208,7 +208,8 @@ The `route_map_fn` is called on all routes, even those that matched `MCPType.EXC ```python from fastmcp import FastMCP -from fastmcp.server.openapi import RouteMap, MCPType, HTTPRoute +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.""" @@ -273,7 +274,7 @@ FastMCP provides several ways to add tags to your MCP components, allowing you t 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.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType mcp = FastMCP.from_openapi( openapi_spec=spec, @@ -364,12 +365,12 @@ Your `mcp_component_fn` is expected to modify the component in-place, not to ret ```python -from fastmcp.server.openapi import ( - HTTPRoute, +from fastmcp.server.providers.openapi import ( OpenAPITool, OpenAPIResource, OpenAPIResourceTemplate, ) +from fastmcp.utilities.openapi import HTTPRoute def customize_components( route: HTTPRoute, diff --git a/docs/v2/patterns/contrib.mdx b/docs/v2/patterns/contrib.mdx index d2f812f52..da757662e 100644 --- a/docs/v2/patterns/contrib.mdx +++ b/docs/v2/patterns/contrib.mdx @@ -18,7 +18,7 @@ The available modules can be viewed in the [contrib directory](https://github.co To use a contrib module, import it from the `fastmcp.contrib` package: -```python +```python test="skip" from fastmcp.contrib import my_module ``` diff --git a/docs/v2/servers/auth/authentication.mdx b/docs/v2/servers/auth/authentication.mdx index c42bfa8bf..c6b829bfe 100644 --- a/docs/v2/servers/auth/authentication.mdx +++ b/docs/v2/servers/auth/authentication.mdx @@ -161,7 +161,7 @@ The implementation provides all required OAuth endpoints including authorization ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.oauth import MyOAuthProvider +from fastmcp.server.auth import OAuthProvider auth = MyOAuthProvider( user_store=your_user_database, diff --git a/docs/v2/servers/auth/oauth-proxy.mdx b/docs/v2/servers/auth/oauth-proxy.mdx index b9bc03430..c3c2e25c5 100644 --- a/docs/v2/servers/auth/oauth-proxy.mdx +++ b/docs/v2/servers/auth/oauth-proxy.mdx @@ -115,6 +115,12 @@ mcp = FastMCP(name="My Server", auth=auth) 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). + + 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. + + Path for OAuth callbacks. Must match the redirect URI configured in your OAuth application diff --git a/docs/v2/servers/auth/oidc-proxy.mdx b/docs/v2/servers/auth/oidc-proxy.mdx index 0b3a21d71..a8a97a8de 100644 --- a/docs/v2/servers/auth/oidc-proxy.mdx +++ b/docs/v2/servers/auth/oidc-proxy.mdx @@ -79,6 +79,12 @@ mcp = FastMCP(name="My Server", auth=auth) Public URL of your FastMCP server (e.g., `https://your-server.com`) + + 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. + + Strict flag for configuration validation. When True, requires all OIDC mandatory fields. diff --git a/docs/v2/servers/proxy.mdx b/docs/v2/servers/proxy.mdx index c60b628f7..b5ceb4b97 100644 --- a/docs/v2/servers/proxy.mdx +++ b/docs/v2/servers/proxy.mdx @@ -52,7 +52,7 @@ The recommended way to create a proxy is using `ProxyClient`, which provides ful ```python from fastmcp import FastMCP -from fastmcp.server.proxy import ProxyClient +from fastmcp.server.providers.proxy import ProxyClient # Create a proxy with full MCP feature support proxy = FastMCP.as_proxy( @@ -86,7 +86,7 @@ FastMCP proxies provide session isolation to ensure safe concurrent operations. When you pass a disconnected client (which is the normal case), each request gets its own isolated backend session: ```python -from fastmcp.server.proxy import ProxyClient +from fastmcp.server.providers.proxy import ProxyClient # Each request creates a fresh backend session (recommended) proxy = FastMCP.as_proxy(ProxyClient("backend_server.py")) @@ -121,7 +121,7 @@ A common use case is bridging transports - exposing a server running on one tran ```python from fastmcp import FastMCP -from fastmcp.server.proxy import ProxyClient +from fastmcp.server.providers.proxy import ProxyClient # Bridge remote SSE server to local stdio remote_proxy = FastMCP.as_proxy( @@ -164,7 +164,7 @@ if __name__ == "__main__": - **Progress**: Forwards progress notifications during long operations ```python -from fastmcp.server.proxy import ProxyClient +from fastmcp.server.providers.proxy import ProxyClient # ProxyClient automatically handles all these features backend = ProxyClient("advanced_backend.py") @@ -304,7 +304,7 @@ Internally, `FastMCP.as_proxy()` uses the `FastMCPProxy` class. You generally do ### Direct Usage ```python -from fastmcp.server.proxy import FastMCPProxy, ProxyClient +from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient # Provide a client factory for explicit session control def create_client(): diff --git a/docs/v2/servers/storage-backends.mdx b/docs/v2/servers/storage-backends.mdx index cd14ab80e..25b8580b0 100644 --- a/docs/v2/servers/storage-backends.mdx +++ b/docs/v2/servers/storage-backends.mdx @@ -236,13 +236,13 @@ middleware = ResponseCachingMiddleware(cache_storage=namespaced_store) The [FastMCP Client](/v2/clients/client) uses storage for persisting OAuth tokens locally. By default, tokens are stored in memory: ```python -from fastmcp.client.auth import OAuthClientProvider +from fastmcp.client.auth import OAuth from key_value.aio.stores.disk import DiskStore # Store tokens on disk for persistence across restarts token_storage = DiskStore(directory="~/.local/share/fastmcp/tokens") -oauth_provider = OAuthClientProvider( +oauth_provider = OAuth( mcp_url="https://your-mcp-server.com/mcp/sse", token_storage=token_storage ) diff --git a/docs/v2/tutorials/rest-api.mdx b/docs/v2/tutorials/rest-api.mdx index 362eb0026..6524a2335 100644 --- a/docs/v2/tutorials/rest-api.mdx +++ b/docs/v2/tutorials/rest-api.mdx @@ -152,7 +152,7 @@ Here’s how you can add custom route maps to turn `GET` requests into `Resource ```python api_server_with_resources.py {3, 37-42} import httpx from fastmcp import FastMCP -from fastmcp.server.openapi import RouteMap, MCPType +from fastmcp.server.providers.openapi import RouteMap, MCPType # Create an HTTP client for the target API diff --git a/examples/apps/approval/approval_server.py b/examples/apps/approval/approval_server.py new file mode 100644 index 000000000..3c82da5bc --- /dev/null +++ b/examples/apps/approval/approval_server.py @@ -0,0 +1,13 @@ +"""Approval gate — require human sign-off before the agent acts. + +Usage: + uv run python approval_server.py +""" + +from fastmcp import FastMCP +from fastmcp.apps.approval import Approval + +mcp = FastMCP("Approval Demo", providers=[Approval()]) + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/choice/choice_server.py b/examples/apps/choice/choice_server.py new file mode 100644 index 000000000..b91dfb726 --- /dev/null +++ b/examples/apps/choice/choice_server.py @@ -0,0 +1,13 @@ +"""Multiple choice — let the user pick from options instead of typing. + +Usage: + uv run python choice_server.py +""" + +from fastmcp import FastMCP +from fastmcp.apps.choice import Choice + +mcp = FastMCP("Choice Demo", providers=[Choice()]) + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/file_upload/file_upload_server.py b/examples/apps/file_upload/file_upload_server.py new file mode 100644 index 000000000..c567f7820 --- /dev/null +++ b/examples/apps/file_upload/file_upload_server.py @@ -0,0 +1,13 @@ +"""File upload — bypass the LLM context window to get files onto the server. + +Usage: + uv run python file_upload_server.py +""" + +from fastmcp import FastMCP +from fastmcp.apps.file_upload import FileUpload + +mcp = FastMCP("File Upload Server", providers=[FileUpload()]) + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/form/form_server.py b/examples/apps/form/form_server.py new file mode 100644 index 000000000..bfa2ab27e --- /dev/null +++ b/examples/apps/form/form_server.py @@ -0,0 +1,41 @@ +"""Form input — collect structured data from users via Pydantic models. + +Usage: + uv run python form_server.py +""" + +from typing import Literal + +from pydantic import BaseModel, Field + +from fastmcp import FastMCP +from fastmcp.apps.form import FormInput + + +class ShippingAddress(BaseModel): + name: str = Field(description="Full name") + street: str = Field(description="Street address") + city: str + state: str = Field(description="Two-letter state code") + zip_code: str = Field(description="5-digit ZIP") + + +class BugReport(BaseModel): + title: str = Field(description="Brief summary") + severity: Literal["low", "medium", "high", "critical"] + description: str = Field( + description="Detailed description", + json_schema_extra={"ui": {"type": "textarea"}}, + ) + + +mcp = FastMCP( + "Form Demo", + providers=[ + FormInput(model=ShippingAddress), + FormInput(model=BugReport), + ], +) + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/map/map_server.py b/examples/apps/map/map_server.py new file mode 100644 index 000000000..5bc868a14 --- /dev/null +++ b/examples/apps/map/map_server.py @@ -0,0 +1,164 @@ +"""Interactive Map — geocode addresses and render on an interactive map. + +Accepts plain addresses (or place names), geocodes them via +OpenStreetMap Nominatim, and renders an interactive Leaflet map. + +Usage: + fastmcp dev apps map_server.py +""" + +from __future__ import annotations + +from textwrap import dedent + +import httpx +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, + Card, + Column, + Embed, + Heading, + Muted, +) +from prefab_ui.components.data_table import DataTable, DataTableColumn + +from fastmcp import FastMCP + +mcp = FastMCP("Interactive Map") + +NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" + + +def _geocode(query: str) -> dict | None: + """Geocode an address using OpenStreetMap Nominatim (free, no key).""" + resp = httpx.get( + NOMINATIM_URL, + params={"q": query, "format": "json", "limit": 1}, + headers={"User-Agent": "fastmcp-map-example/1.0"}, + timeout=10, + ) + results = resp.json() + if results: + r = results[0] + return { + "name": r.get("display_name", query).split(",")[0], + "address": query, + "lat": float(r["lat"]), + "lng": float(r["lon"]), + } + return None + + +def _build_map_html( + locations: list[dict], + zoom: int, +) -> str: + markers_js = "" + for loc in locations: + name = str(loc["name"]).replace("\\", "\\\\").replace("'", "\\'") + markers_js += ( + f"L.marker([{loc['lat']}, {loc['lng']}]).addTo(map).bindPopup('{name}');\n" + ) + + avg_lat = sum(loc["lat"] for loc in locations) / len(locations) + avg_lng = sum(loc["lng"] for loc in locations) / len(locations) + + return dedent(f"""\ + + + + + + + + + +
+ + + + """) + + +@mcp.tool(app=True) +def show_map( + locations: list[str] | None = None, + title: str = "Map", + zoom: int = 2, +) -> PrefabApp: + """Show locations on an interactive map. + + Accepts addresses, place names, or landmarks. Each location is + geocoded via OpenStreetMap and displayed as a marker on an + interactive Leaflet map. + + Args: + locations: List of addresses or place names. Defaults to + sample US landmarks if not provided. + title: Heading for the map. + zoom: Initial zoom level (1-18, higher = closer). + """ + if not locations: + locations = [ + "Statue of Liberty, New York", + "Golden Gate Bridge, San Francisco", + "Space Needle, Seattle", + "Willis Tower, Chicago", + "Gateway Arch, St. Louis", + ] + + geocoded = [] + failed = [] + for loc in locations: + result = _geocode(loc) + if result: + geocoded.append(result) + else: + failed.append(loc) + + with PrefabApp() as app: + with Column(gap=4, css_class="p-6"): + Heading(title) + Muted(f"{len(geocoded)} locations mapped") + if failed: + for f in failed: + Badge(f"Could not find: {f}", variant="destructive") + + if geocoded: + map_html = _build_map_html(geocoded, zoom) + with Card(): + Embed( + html=map_html, + width="100%", + height="500px", + sandbox="allow-scripts", + ) + DataTable( + columns=[ + DataTableColumn(key="name", header="Name", sortable=True), + DataTableColumn(key="address", header="Address", sortable=True), + DataTableColumn(key="lat", header="Latitude", sortable=True), + DataTableColumn(key="lng", header="Longitude", sortable=True), + ], + rows=geocoded, + search=True, + ) + + return app + + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/quiz/quiz_server.py b/examples/apps/quiz/quiz_server.py new file mode 100644 index 000000000..7de6f08d7 --- /dev/null +++ b/examples/apps/quiz/quiz_server.py @@ -0,0 +1,258 @@ +"""Quiz / trivia app — a FastMCPApp example with multi-turn state. + +Demonstrates building state over a conversation: +- The LLM generates quiz questions and calls `take_quiz` to launch the UI +- The user answers via multiple-choice buttons (no forms) +- Each answer calls `submit_answer`, which returns correctness + updated score +- After the final question, a SendMessage pushes the score back to the LLM + +Usage: + uv run python quiz_server.py +""" + +from __future__ import annotations + +from prefab_ui.actions import SetState, ShowToast +from prefab_ui.actions.mcp import CallTool, SendMessage +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, + Button, + Card, + Column, + Heading, + If, + Muted, + Progress, + Row, + Text, +) +from prefab_ui.rx import ERROR, RESULT, Rx + +from fastmcp import FastMCP, FastMCPApp + +app = FastMCPApp("Quiz") + +DEFAULT_QUESTIONS = [ + { + "question": "What is the capital of Australia?", + "options": ["Sydney", "Melbourne", "Canberra", "Perth"], + "correct": 2, + }, + { + "question": "Which planet has the most moons?", + "options": ["Jupiter", "Saturn", "Uranus", "Neptune"], + "correct": 1, + }, + { + "question": "What year did the Berlin Wall fall?", + "options": ["1987", "1989", "1991", "1993"], + "correct": 1, + }, + { + "question": "Which element has the chemical symbol 'Au'?", + "options": ["Silver", "Aluminum", "Gold", "Argon"], + "correct": 2, + }, + { + "question": "What is the deepest ocean?", + "options": ["Atlantic", "Indian", "Arctic", "Pacific"], + "correct": 3, + }, +] + + +# --------------------------------------------------------------------------- +# Backend tool — grade an answer and advance state +# --------------------------------------------------------------------------- + + +@app.tool() +def submit_answer( + question_index: int, + selected: int, + correct: int, + total_questions: int, + current_score: int, +) -> dict: + """Grade an answer and return the updated quiz state. + + Returns a dict with: + - is_correct: whether the selected answer matched the correct index + - new_score: the updated cumulative score + - answered_index: the question that was just answered + - finished: whether this was the last question + """ + is_correct = selected == correct + new_score = current_score + (1 if is_correct else 0) + finished = (question_index + 1) >= total_questions + return { + "is_correct": is_correct, + "new_score": new_score, + "answered_index": question_index, + "finished": finished, + } + + +# --------------------------------------------------------------------------- +# UI entry point — the LLM calls this with a topic and generated questions +# --------------------------------------------------------------------------- + + +@app.ui() +def take_quiz( + topic: str = "General Knowledge", + questions: list[dict] | None = None, +) -> PrefabApp: + """Launch a quiz UI. + + The LLM generates the questions and passes them in: + - topic: displayed as the heading (e.g. "World Capitals") + - questions: list of dicts, each with: + - "question": the question text + - "options": list of answer strings + - "correct": index of the correct option + + If no questions are provided, a built-in set is used. + """ + if questions is None: + questions = DEFAULT_QUESTIONS + total = len(questions) + score = Rx("score") + current_q = Rx("current_question") + answered = Rx("answered") + + with Column(gap=6, css_class="p-6 max-w-2xl") as view: + Heading(f"Quiz: {topic}") + + with Row(gap=3, align="center"): + Badge(f"{score}/{total} correct", variant="secondary") + Progress(value=current_q, max=total, size="sm") + + for i, q in enumerate(questions): + visible = current_q == i + options = q["options"] + correct_idx = q["correct"] + + with If(visible): + with Card(): + with Column(gap=4, css_class="p-4"): + Text( + f"Question {i + 1} of {total}", + css_class="text-sm font-medium text-muted-foreground", + ) + Heading(q["question"], level=3) + + with If(~answered): + with Column(gap=2): + for opt_idx, option in enumerate(options): + on_success_actions = [ + SetState("answered", True), + SetState( + "last_correct", + RESULT.is_correct, + ), + SetState("score", RESULT.new_score), + ] + is_last = (i + 1) >= total + if is_last: + on_success_actions.append( + SetState("finished", True), + ) + + Button( + option, + variant="outline", + css_class="w-full justify-start", + on_click=CallTool( + submit_answer, + arguments={ + "question_index": i, + "selected": opt_idx, + "correct": correct_idx, + "total_questions": total, + "current_score": str(score), + }, + on_success=on_success_actions, + on_error=ShowToast( + ERROR, + variant="error", + ), + ), + ) + + with If(answered): + with Column(gap=2): + for opt_idx, option in enumerate(options): + if opt_idx == correct_idx: + Button( + f"{option}", + variant="success", + css_class="w-full justify-start", + disabled=True, + ) + else: + Button( + option, + variant="ghost", + css_class="w-full justify-start opacity-50", + disabled=True, + ) + + with If(Rx("last_correct")): + Badge("Correct!", variant="success") + with If(~Rx("last_correct")): + Badge( + f"Incorrect — answer: {options[correct_idx]}", + variant="destructive", + ) + + with If(answered & ~Rx("finished")): + Button( + "Next Question", + variant="default", + on_click=[ + SetState("current_question", current_q + 1), + SetState("answered", False), + SetState("last_correct", False), + ], + ) + + with If(Rx("finished") & answered): + with Card(css_class="border-2 border-primary"): + with Column(gap=3, css_class="p-4 items-center text-center"): + Heading("Quiz Complete!", level=2) + Text( + f"{score}/{total} correct", + css_class="text-2xl font-bold", + ) + Progress( + value=score, + max=total, + variant="success", + size="lg", + ) + Muted("Click below to send your results to the conversation.") + Button( + "Send Results", + variant="default", + on_click=SendMessage( + f'Quiz complete! Topic: "{topic}" ' + f"— Final score: {score}/{total} correct.", + ), + ) + + initial_state = { + "score": 0, + "current_question": 0, + "answered": False, + "last_correct": False, + "finished": False, + } + return PrefabApp(view=view, state=initial_state) + + +mcp = FastMCP("Quiz Server", providers=[app]) + +if __name__ == "__main__": + mcp.run(transport="http") diff --git a/examples/apps/sales_dashboard/sales_dashboard_server.py b/examples/apps/sales_dashboard/sales_dashboard_server.py new file mode 100644 index 000000000..fe38c64bc --- /dev/null +++ b/examples/apps/sales_dashboard/sales_dashboard_server.py @@ -0,0 +1,233 @@ +from prefab_ui.components import ( + Card, + CardContent, + Column, + Grid, + Heading, + Metric, + Muted, + Row, + Separator, + Text, +) +from prefab_ui.components.charts import AreaChart, ChartSeries, PieChart +from prefab_ui.components.data_table import DataTable, DataTableColumn + +from fastmcp import FastMCP + +mcp = FastMCP("Sales Dashboard") + +MONTHLY_REVENUE = [ + {"month": "Jul", "new_business": 182_000, "expansion": 74_000, "renewal": 210_000}, + {"month": "Aug", "new_business": 195_000, "expansion": 81_000, "renewal": 215_000}, + {"month": "Sep", "new_business": 224_000, "expansion": 93_000, "renewal": 208_000}, + {"month": "Oct", "new_business": 210_000, "expansion": 88_000, "renewal": 222_000}, + {"month": "Nov", "new_business": 248_000, "expansion": 102_000, "renewal": 230_000}, + {"month": "Dec", "new_business": 271_000, "expansion": 115_000, "renewal": 238_000}, + {"month": "Jan", "new_business": 235_000, "expansion": 97_000, "renewal": 241_000}, + {"month": "Feb", "new_business": 262_000, "expansion": 108_000, "renewal": 245_000}, + {"month": "Mar", "new_business": 289_000, "expansion": 121_000, "renewal": 252_000}, + {"month": "Apr", "new_business": 305_000, "expansion": 134_000, "renewal": 258_000}, + {"month": "May", "new_business": 318_000, "expansion": 142_000, "renewal": 263_000}, + {"month": "Jun", "new_business": 342_000, "expansion": 156_000, "renewal": 270_000}, +] + +REVENUE_BY_SEGMENT = [ + {"segment": "Enterprise", "revenue": 3_840_000}, + {"segment": "Mid-Market", "revenue": 2_160_000}, + {"segment": "SMB", "revenue": 1_440_000}, + {"segment": "Startup", "revenue": 720_000}, +] + +RECENT_DEALS = [ + { + "company": "Meridian Health Systems", + "amount": "$485,000", + "stage": "Closed Won", + "rep": "Sarah Chen", + "close_date": "Jun 12, 2026", + }, + { + "company": "Atlas Financial Group", + "amount": "$372,000", + "stage": "Closed Won", + "rep": "Marcus Rivera", + "close_date": "Jun 10, 2026", + }, + { + "company": "Pinnacle Manufacturing", + "amount": "$298,000", + "stage": "Negotiation", + "rep": "Aisha Patel", + "close_date": "Jun 28, 2026", + }, + { + "company": "Crestview Logistics", + "amount": "$264,000", + "stage": "Proposal Sent", + "rep": "James O'Brien", + "close_date": "Jul 5, 2026", + }, + { + "company": "Northstar Retail", + "amount": "$215,000", + "stage": "Closed Won", + "rep": "Sarah Chen", + "close_date": "Jun 8, 2026", + }, + { + "company": "Ironclad Security", + "amount": "$189,000", + "stage": "Negotiation", + "rep": "Lena Kowalski", + "close_date": "Jul 1, 2026", + }, + { + "company": "Summit Analytics", + "amount": "$176,000", + "stage": "Closed Won", + "rep": "Marcus Rivera", + "close_date": "Jun 5, 2026", + }, + { + "company": "Brightpath Education", + "amount": "$142,000", + "stage": "Proposal Sent", + "rep": "Aisha Patel", + "close_date": "Jul 12, 2026", + }, + { + "company": "Vantage Media", + "amount": "$128,000", + "stage": "Closed Won", + "rep": "Lena Kowalski", + "close_date": "Jun 3, 2026", + }, + { + "company": "Redwood Hospitality", + "amount": "$97,000", + "stage": "Discovery", + "rep": "James O'Brien", + "close_date": "Jul 20, 2026", + }, +] + + +@mcp.tool(app=True) +def sales_dashboard() -> Column: + """Company sales dashboard with KPIs, revenue trends, segment breakdown, and recent deals.""" + total_revenue = sum( + row["new_business"] + row["expansion"] + row["renewal"] + for row in MONTHLY_REVENUE + ) + current_quarter = sum( + row["new_business"] + row["expansion"] + row["renewal"] + for row in MONTHLY_REVENUE[-3:] + ) + prior_quarter = sum( + row["new_business"] + row["expansion"] + row["renewal"] + for row in MONTHLY_REVENUE[-6:-3] + ) + growth_pct = (current_quarter - prior_quarter) / prior_quarter * 100 + + with Column(gap=6, css_class="p-6") as view: + with Row(gap=2, align="center"): + Heading("Sales Dashboard") + Muted("FY2026 | Last updated Jun 15, 2026") + + with Grid(columns=4, gap=4): + with Card(): + with CardContent(): + Metric( + label="Total Revenue", + value=f"${total_revenue / 1_000_000:.1f}M", + delta="+18.2% YoY", + trend="up", + ) + + with Card(): + with CardContent(): + Metric( + label="Quarterly Growth", + value=f"{growth_pct:.1f}%", + delta="+3.8pp vs prior", + trend="up", + ) + + with Card(): + with CardContent(): + Metric( + label="Active Customers", + value="1,847", + delta="+124 this quarter", + trend="up", + ) + + with Card(): + with CardContent(): + Metric( + label="Avg Deal Size", + value="$236K", + delta="+12% vs H1", + trend="up", + ) + + with Grid(columns=3, gap=6): + with Card(css_class="col-span-2"): + with CardContent(): + Text( + "Monthly Revenue", + css_class="text-sm font-medium text-muted-foreground mb-2", + ) + AreaChart( + data=MONTHLY_REVENUE, + series=[ + ChartSeries(data_key="new_business", label="New Business"), + ChartSeries(data_key="expansion", label="Expansion"), + ChartSeries(data_key="renewal", label="Renewal"), + ], + x_axis="month", + stacked=True, + curve="smooth", + show_legend=True, + height=280, + y_axis_format="compact", + ) + + with Card(): + with CardContent(): + Text( + "Revenue by Segment", + css_class="text-sm font-medium text-muted-foreground mb-2", + ) + PieChart( + data=REVENUE_BY_SEGMENT, + data_key="revenue", + name_key="segment", + show_legend=True, + inner_radius=50, + height=280, + ) + + Separator() + + Text("Recent Deals", css_class="text-lg font-semibold") + + DataTable( + columns=[ + DataTableColumn(key="company", header="Company", sortable=True), + DataTableColumn(key="amount", header="Amount", sortable=True), + DataTableColumn(key="stage", header="Stage", sortable=True), + DataTableColumn(key="rep", header="Sales Rep", sortable=True), + DataTableColumn(key="close_date", header="Close Date", sortable=True), + ], + rows=RECENT_DEALS, + search=True, + paginated=True, + ) + + return view + + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/system_monitor/system_monitor_server.py b/examples/apps/system_monitor/system_monitor_server.py new file mode 100644 index 000000000..7105a3697 --- /dev/null +++ b/examples/apps/system_monitor/system_monitor_server.py @@ -0,0 +1,195 @@ +"""System monitor — live CPU, memory, and disk stats from the host machine. + +Auto-refreshes every 3 seconds via SetInterval + CallTool. + +Requires psutil: pip install psutil + +Usage: + fastmcp dev apps system_monitor_server.py +""" + +import platform +import time +from datetime import datetime + +import psutil +from prefab_ui.actions import SetInterval, SetState +from prefab_ui.actions.mcp import CallTool +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, + Card, + CardContent, + CardHeader, + Column, + Grid, + Heading, + Metric, + Muted, + Progress, + Row, + Select, + SelectOption, + Small, + Text, +) +from prefab_ui.components.charts import AreaChart, ChartSeries +from prefab_ui.components.control_flow import ForEach +from prefab_ui.rx import RESULT, STATE, Rx + +from fastmcp import FastMCP +from fastmcp.apps.app import FastMCPApp + +app = FastMCPApp("Monitor") + +_history: list[dict] = [] + + +def _collect_stats() -> dict: + """Collect a full snapshot of system stats.""" + cpu = psutil.cpu_percent(interval=0.1) + mem = psutil.virtual_memory() + disk = psutil.disk_usage("/") + + now = datetime.now().strftime("%H:%M:%S") + _history.append({"time": now, "cpu": cpu, "memory": mem.percent}) + if len(_history) > 100: + del _history[: len(_history) - 100] + + top_procs = [] + for p in sorted( + psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]), + key=lambda p: p.info.get("cpu_percent") or 0, + reverse=True, + )[:6]: + info = p.info + top_procs.append( + { + "pid": info.get("pid") or 0, + "name": info.get("name") or "unknown", + "cpu": f"{(info.get('cpu_percent') or 0):.1f}%", + "memory": f"{(info.get('memory_percent') or 0):.1f}%", + } + ) + + return { + "cpu": cpu, + "mem_pct": mem.percent, + "mem_used": mem.used // (1024**3), + "mem_total": mem.total // (1024**3), + "disk_pct": disk.percent, + "disk_used": disk.used // (1024**3), + "disk_total": disk.total // (1024**3), + "uptime": _format_uptime(), + "cores": psutil.cpu_count(), + "platform": f"{platform.system()} {platform.machine()}", + "hostname": platform.node(), + "healthy": cpu < 80 and mem.percent < 90, + "history": list(_history), + "top_procs": top_procs, + } + + +def _format_uptime() -> str: + elapsed = int(time.time() - psutil.boot_time()) + days, remainder = divmod(elapsed, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, _ = divmod(remainder, 60) + if days > 0: + return f"{days}d {hours}h {minutes}m" + return f"{hours}h {minutes}m" + + +@app.tool() +def refresh() -> dict: + """Collect fresh system stats.""" + return _collect_stats() + + +@app.ui() +def system_dashboard() -> PrefabApp: + """Live system dashboard with auto-refresh.""" + initial = _collect_stats() + + with PrefabApp(state={"stats": initial, "interval": "500"}) as ui: + with Column( + gap=6, + css_class="p-6", + on_mount=SetInterval( + duration=Rx("interval"), + on_tick=CallTool( + "refresh", + on_success=SetState("stats", RESULT), + ), + ), + ): + with Row(gap=3, align="center"): + Heading("System Monitor") + Badge(STATE.stats.hostname, variant="outline") + with Select(name="interval", css_class="w-32"): + SelectOption("0.5s", value="500") + SelectOption("1s", value="1000") + SelectOption("5s", value="5000") + + with Grid(columns=4, gap=4): + with Card(): + with CardContent(): + Metric(label="CPU", value=f"{STATE.stats.cpu}%") + Progress(value=STATE.stats.cpu) + + with Card(): + with CardContent(): + Metric(label="Memory", value=f"{STATE.stats.mem_pct}%") + Progress(value=STATE.stats.mem_pct) + Muted(f"{STATE.stats.mem_used}GB / {STATE.stats.mem_total}GB") + + with Card(): + with CardContent(): + Metric(label="Disk", value=f"{STATE.stats.disk_pct}%") + Progress(value=STATE.stats.disk_pct) + Muted(f"{STATE.stats.disk_used}GB / {STATE.stats.disk_total}GB") + + with Card(): + with CardContent(): + Metric(label="Uptime", value=STATE.stats.uptime) + Muted(f"{STATE.stats.cores} cores") + + with Grid(columns=[2, 1], gap=4): + with Card(): + with CardHeader(): + Text("CPU & Memory", css_class="text-sm font-medium") + with CardContent(): + AreaChart( + data=STATE.stats.history, + series=[ + ChartSeries(data_key="cpu", label="CPU %"), + ChartSeries(data_key="memory", label="Memory %"), + ], + x_axis="time", + curve="smooth", + show_legend=True, + height=220, + animate=False, + ) + + with Card(): + with CardHeader(): + Text("Top Processes", css_class="text-sm font-medium") + with CardContent(): + with Column(gap=2): + with ForEach("stats.top_procs") as proc: + with Row(justify="between", align="center"): + with Column(gap=0): + Small(proc.name) + Muted(proc.pid) + with Row(gap=2): + Badge(proc.cpu, variant="outline") + Badge(proc.memory, variant="outline") + + return ui + + +mcp = FastMCP("System Monitor", providers=[app]) + +if __name__ == "__main__": + mcp.run() diff --git a/examples/auth/authkit/README.md b/examples/auth/authkit/README.md new file mode 100644 index 000000000..8c4b8a6aa --- /dev/null +++ b/examples/auth/authkit/README.md @@ -0,0 +1,36 @@ +# AuthKit Example + +Protects a FastMCP server with WorkOS AuthKit. The server binds the JWT +`aud` claim to its own resource URL automatically — you just paste that same +URL into the WorkOS Dashboard as a resource indicator. + +## WorkOS Dashboard setup + +In the WorkOS Dashboard for your project, go to **Connect → Configuration** and: + +1. Under **MCP Auth**, enable **Dynamic Client Registration** (or **Client ID + Metadata Document** if your MCP client supports it). +2. Under **MCP resource indicators**, add `http://127.0.0.1:8000/mcp` as a + valid resource indicator. + +## Running + +1. Set your AuthKit domain: + + ```bash + export AUTHKIT_DOMAIN="https://your-app.authkit.app" + ``` + +2. Start the server. It logs the resource URL it's validating against — + that's the URL that must match your dashboard resource indicator: + + ```bash + python server.py + ``` + +3. In another terminal, run the client. Your browser will open for AuthKit + authentication: + + ```bash + python client.py + ``` diff --git a/examples/auth/authkit_dcr/client.py b/examples/auth/authkit/client.py similarity index 100% rename from examples/auth/authkit_dcr/client.py rename to examples/auth/authkit/client.py diff --git a/examples/auth/authkit_dcr/server.py b/examples/auth/authkit/server.py similarity index 50% rename from examples/auth/authkit_dcr/server.py rename to examples/auth/authkit/server.py index 8974376d2..7611ccddf 100644 --- a/examples/auth/authkit_dcr/server.py +++ b/examples/auth/authkit/server.py @@ -1,9 +1,11 @@ -"""AuthKit DCR server example for FastMCP. +"""AuthKit server example for FastMCP. -This example demonstrates how to protect a FastMCP server with AuthKit DCR. +Demonstrates an MCP server secured by WorkOS AuthKit. FastMCP binds the JWT +audience to this server's resource URL automatically; you configure the same +URL as an MCP resource indicator in the WorkOS Dashboard. Required environment variables: -- FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_AUTHKIT_DOMAIN: Your AuthKit domain (e.g., "https://your-app.authkit.app") +- AUTHKIT_DOMAIN: Your AuthKit domain (e.g., "https://your-app.authkit.app") To run: python server.py @@ -16,10 +18,10 @@ from fastmcp.server.auth.providers.workos import AuthKitProvider auth = AuthKitProvider( authkit_domain=os.getenv("AUTHKIT_DOMAIN") or "", - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", ) -mcp = FastMCP("AuthKit DCR Example Server", auth=auth) +mcp = FastMCP("AuthKit Example Server", auth=auth) @mcp.tool diff --git a/examples/auth/authkit_dcr/README.md b/examples/auth/authkit_dcr/README.md deleted file mode 100644 index 808246199..000000000 --- a/examples/auth/authkit_dcr/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# AuthKit DCR Example - -Demonstrates FastMCP server protection with AuthKit Dynamic Client Registration. - -## Setup - -1. Set your AuthKit domain: - - ```bash - export AUTHKIT_DOMAIN="https://your-app.authkit.app" - ``` - -2. Run the server: - - ```bash - python server.py - ``` - -3. In another terminal, run the client: - - ```bash - python client.py - ``` - -The client will open your browser for AuthKit authentication. diff --git a/examples/auth/aws_oauth/README.md b/examples/auth/aws_oauth/README.md index 9abff838c..c4e25b1f8 100644 --- a/examples/auth/aws_oauth/README.md +++ b/examples/auth/aws_oauth/README.md @@ -10,7 +10,7 @@ Demonstrates FastMCP server protection with AWS Cognito OAuth. - Create an App Client in your User Pool - Configure the App Client settings: - Enable "Authorization code grant" flow - - Add Callback URL: `http://localhost:8000/auth/callback` + - Add Callback URL: `http://127.0.0.1:8000/auth/callback` - Configure OAuth scopes (at minimum: `openid`) - Note your User Pool ID, App Client ID, Client Secret, and Cognito Domain Prefix diff --git a/examples/auth/aws_oauth/client.py b/examples/auth/aws_oauth/client.py index 4043e6d4f..afcf54fd1 100644 --- a/examples/auth/aws_oauth/client.py +++ b/examples/auth/aws_oauth/client.py @@ -10,7 +10,7 @@ import asyncio from fastmcp.client import Client -SERVER_URL = "http://localhost:8000/mcp" +SERVER_URL = "http://127.0.0.1:8000/mcp" async def main(): diff --git a/examples/auth/aws_oauth/requirements.txt b/examples/auth/aws_oauth/requirements.txt index 9c7f15cd1..044c95a70 100644 --- a/examples/auth/aws_oauth/requirements.txt +++ b/examples/auth/aws_oauth/requirements.txt @@ -1,2 +1,2 @@ fastmcp -python-dotenv \ No newline at end of file +python-dotenv diff --git a/examples/auth/aws_oauth/server.py b/examples/auth/aws_oauth/server.py index dfe596a83..261164391 100644 --- a/examples/auth/aws_oauth/server.py +++ b/examples/auth/aws_oauth/server.py @@ -31,7 +31,7 @@ auth = AWSCognitoProvider( or "eu-central-1", client_id=os.getenv("FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID") or "", client_secret=os.getenv("FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET") or "", - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", # redirect_path="/custom/callback" ) diff --git a/examples/auth/azure_oauth/README.md b/examples/auth/azure_oauth/README.md index ba0757ca7..98d9ae756 100644 --- a/examples/auth/azure_oauth/README.md +++ b/examples/auth/azure_oauth/README.md @@ -10,7 +10,7 @@ This example demonstrates how to use the Azure OAuth provider with FastMCP serve 2. Click "New registration" and configure: - Name: Your app name - Supported account types: Choose based on your needs - - Redirect URI: `http://localhost:8000/auth/callback` (Web platform) + - Redirect URI: `http://127.0.0.1:8000/auth/callback` (Web platform) 3. After creation, go to "Certificates & secrets" → "New client secret" 4. Note these values from the Overview page: - Application (client) ID diff --git a/examples/auth/azure_oauth/server.py b/examples/auth/azure_oauth/server.py index d214389aa..e0c9e799e 100644 --- a/examples/auth/azure_oauth/server.py +++ b/examples/auth/azure_oauth/server.py @@ -24,7 +24,7 @@ auth = AzureProvider( client_secret=os.getenv("FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET") or "", tenant_id=os.getenv("FASTMCP_SERVER_AUTH_AZURE_TENANT_ID") or "", # Required for single-tenant apps - get from Azure Portal - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", required_scopes=["read"], # required_scopes is automatically loaded from FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES # At least one scope is required - use unprefixed scope names from your Azure App (e.g., ["read", "write"]) diff --git a/examples/auth/clerk_oauth/README.md b/examples/auth/clerk_oauth/README.md new file mode 100644 index 000000000..9a79ff566 --- /dev/null +++ b/examples/auth/clerk_oauth/README.md @@ -0,0 +1,36 @@ +# Clerk OAuth Example + +Demonstrates FastMCP server protection with Clerk OAuth. + +## Setup + +1. Create a Clerk OAuth Application: + - Go to [Clerk Dashboard](https://dashboard.clerk.com/) + - Create or select an application + - Go to Developers > OAuth Applications + - Create an OAuth application + - Add Authorized redirect URI: `http://127.0.0.1:8000/auth/callback` + - Copy the Client ID and Client Secret + - Note your instance domain (e.g., `saving-primate-16.clerk.accounts.dev`) + +2. Set environment variables: + + ```bash + export FASTMCP_SERVER_AUTH_CLERK_DOMAIN="your-instance.clerk.accounts.dev" + export FASTMCP_SERVER_AUTH_CLERK_CLIENT_ID="your-clerk-client-id" + export FASTMCP_SERVER_AUTH_CLERK_CLIENT_SECRET="your-clerk-client-secret" + ``` + +3. Run the server: + + ```bash + python server.py + ``` + +4. In another terminal, run the client: + + ```bash + python client.py + ``` + +The client will open your browser for Clerk authentication. diff --git a/examples/auth/clerk_oauth/client.py b/examples/auth/clerk_oauth/client.py new file mode 100644 index 000000000..d9d44c9d6 --- /dev/null +++ b/examples/auth/clerk_oauth/client.py @@ -0,0 +1,33 @@ +"""OAuth client example for connecting to a Clerk-protected FastMCP server. + +This example demonstrates how to connect to an OAuth-protected FastMCP server +using Clerk as the identity provider. + +To run: + python client.py +""" + +import asyncio + +from fastmcp.client import Client + +SERVER_URL = "http://127.0.0.1:8000/mcp" + + +async def main(): + try: + async with Client(SERVER_URL, auth="oauth") as client: + assert await client.ping() + print("✅ Successfully authenticated!") + + tools = await client.list_tools() + print(f"🔧 Available tools ({len(tools)}):") + for tool in tools: + print(f" - {tool.name}: {tool.description}") + except Exception as e: + print(f"❌ Authentication failed: {e}") + raise + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/auth/clerk_oauth/server.py b/examples/auth/clerk_oauth/server.py new file mode 100644 index 000000000..e7d080734 --- /dev/null +++ b/examples/auth/clerk_oauth/server.py @@ -0,0 +1,40 @@ +"""Clerk OAuth server example for FastMCP. + +This example demonstrates how to protect a FastMCP server with Clerk OAuth. + +Required environment variables: +- FASTMCP_SERVER_AUTH_CLERK_DOMAIN: Your Clerk instance domain + (e.g., "saving-primate-16.clerk.accounts.dev") +- FASTMCP_SERVER_AUTH_CLERK_CLIENT_ID: Your Clerk OAuth client ID +- FASTMCP_SERVER_AUTH_CLERK_CLIENT_SECRET: Your Clerk OAuth client secret + +To run: + python server.py +""" + +import os + +from fastmcp import FastMCP +from fastmcp.server.auth.providers.clerk import ClerkProvider + +auth = ClerkProvider( + domain=os.getenv("FASTMCP_SERVER_AUTH_CLERK_DOMAIN") or "", + client_id=os.getenv("FASTMCP_SERVER_AUTH_CLERK_CLIENT_ID") or "", + client_secret=os.getenv("FASTMCP_SERVER_AUTH_CLERK_CLIENT_SECRET") or "", + base_url="http://127.0.0.1:8000", + # redirect_path="/auth/callback", # Default path - change if using a different callback URL + # Optional: specify required scopes (defaults to ["openid", "email", "profile"]) + # required_scopes=["openid", "email", "profile", "public_metadata"], +) + +mcp = FastMCP("Clerk OAuth Example Server", auth=auth) + + +@mcp.tool +def echo(message: str) -> str: + """Echo the provided message.""" + return message + + +if __name__ == "__main__": + mcp.run(transport="http", port=8000) diff --git a/examples/auth/discord_oauth/README.md b/examples/auth/discord_oauth/README.md index 74217f833..e757ad84b 100644 --- a/examples/auth/discord_oauth/README.md +++ b/examples/auth/discord_oauth/README.md @@ -8,7 +8,7 @@ Demonstrates FastMCP server protection with Discord OAuth. - Go to https://discord.com/developers/applications - Click "New Application" and give it a name - Go to OAuth2 in the left sidebar - - Add a Redirect URL: `http://localhost:8000/auth/callback` + - Add a Redirect URL: `http://127.0.0.1:8000/auth/callback` - Copy the Client ID and Client Secret 2. Set environment variables: diff --git a/examples/auth/discord_oauth/server.py b/examples/auth/discord_oauth/server.py index 424c97bdb..1e109b76a 100644 --- a/examples/auth/discord_oauth/server.py +++ b/examples/auth/discord_oauth/server.py @@ -18,7 +18,7 @@ from fastmcp.server.auth.providers.discord import DiscordProvider auth = DiscordProvider( client_id=os.getenv("FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID") or "", client_secret=os.getenv("FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET") or "", - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", # redirect_path="/auth/callback", # Default path - change if using a different callback URL ) diff --git a/examples/auth/github_oauth/README.md b/examples/auth/github_oauth/README.md index 557ba7774..dcd5c2205 100644 --- a/examples/auth/github_oauth/README.md +++ b/examples/auth/github_oauth/README.md @@ -6,7 +6,7 @@ Demonstrates FastMCP server protection with GitHub OAuth. 1. Create a GitHub OAuth App: - Go to GitHub Settings > Developer settings > OAuth Apps - - Set Authorization callback URL to: `http://localhost:8000/auth/callback` + - Set Authorization callback URL to: `http://127.0.0.1:8000/auth/callback` - Copy the Client ID and Client Secret 2. Set environment variables: diff --git a/examples/auth/github_oauth/client.py b/examples/auth/github_oauth/client.py index 7158583bc..8722a547c 100644 --- a/examples/auth/github_oauth/client.py +++ b/examples/auth/github_oauth/client.py @@ -10,7 +10,7 @@ import asyncio from fastmcp.client import Client, OAuth -SERVER_URL = "http://localhost:8000/mcp" +SERVER_URL = "http://127.0.0.1:8000/mcp" async def main(): diff --git a/examples/auth/github_oauth/server.py b/examples/auth/github_oauth/server.py index 1f88c6977..e93d6f01a 100644 --- a/examples/auth/github_oauth/server.py +++ b/examples/auth/github_oauth/server.py @@ -18,7 +18,7 @@ from fastmcp.server.auth.providers.github import GitHubProvider auth = GitHubProvider( client_id=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID") or "", client_secret=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET") or "", - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", # redirect_path="/auth/callback", # Default path - change if using a different callback URL ) diff --git a/examples/auth/google_oauth/README.md b/examples/auth/google_oauth/README.md index 869718344..82bcd8696 100644 --- a/examples/auth/google_oauth/README.md +++ b/examples/auth/google_oauth/README.md @@ -9,7 +9,7 @@ Demonstrates FastMCP server protection with Google OAuth. - Create or select a project - Go to APIs & Services > Credentials - Create OAuth 2.0 Client ID (Web application) - - Add Authorized redirect URI: `http://localhost:8000/auth/callback` + - Add Authorized redirect URI: `http://127.0.0.1:8000/auth/callback` - Copy the Client ID and Client Secret 2. Set environment variables: diff --git a/examples/auth/google_oauth/server.py b/examples/auth/google_oauth/server.py index 2a5b1c7df..2043ed6c3 100644 --- a/examples/auth/google_oauth/server.py +++ b/examples/auth/google_oauth/server.py @@ -18,7 +18,7 @@ from fastmcp.server.auth.providers.google import GoogleProvider auth = GoogleProvider( client_id=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID") or "", client_secret=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET") or "", - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", # redirect_path="/auth/callback", # Default path - change if using a different callback URL # Optional: specify required scopes # required_scopes=["openid", "https://www.googleapis.com/auth/userinfo.email"], diff --git a/examples/auth/keycloak_oauth/README.md b/examples/auth/keycloak_oauth/README.md new file mode 100644 index 000000000..ba6b95bf4 --- /dev/null +++ b/examples/auth/keycloak_oauth/README.md @@ -0,0 +1,29 @@ +# Keycloak OAuth Example + +Demonstrates FastMCP server protection with Keycloak OAuth. + +**Requires Keycloak 26.6.0 or later** with Dynamic Client Registration enabled. + +## Setup + +1. Configure a Keycloak realm with Dynamic Client Registration enabled and a trusted host policy for your server URL (e.g. `http://127.0.0.1:8000/*`). + +2. Set environment variables: + + ```bash + export KEYCLOAK_REALM_URL="http://localhost:8080/realms/your-realm" + ``` + +3. Run the server: + + ```bash + python server.py + ``` + +4. In another terminal, run the client: + + ```bash + python client.py + ``` + +The client will open your browser for Keycloak authentication. diff --git a/examples/auth/keycloak_oauth/client.py b/examples/auth/keycloak_oauth/client.py new file mode 100644 index 000000000..4992abbab --- /dev/null +++ b/examples/auth/keycloak_oauth/client.py @@ -0,0 +1,33 @@ +"""OAuth client example for connecting to a Keycloak-protected FastMCP server. + +To run: + python client.py +""" + +import asyncio + +from fastmcp import Client + +SERVER_URL = "http://127.0.0.1:8000/mcp" + + +async def main(): + async with Client(SERVER_URL, auth="oauth") as client: + assert await client.ping() + print("Successfully authenticated!") + + tools = await client.list_tools() + print(f"Available tools ({len(tools)}):") + for tool in tools: + print(f" - {tool.name}: {tool.description}") + + print("Calling protected tool: get_access_token_claims") + result = await client.call_tool("get_access_token_claims") + claims = result.data + print(f" sub: {claims.get('sub', 'N/A')}") + print(f" scope: {claims.get('scope', 'N/A')}") + print(f" azp: {claims.get('azp', 'N/A')}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/auth/keycloak_oauth/server.py b/examples/auth/keycloak_oauth/server.py new file mode 100644 index 000000000..7b4653103 --- /dev/null +++ b/examples/auth/keycloak_oauth/server.py @@ -0,0 +1,44 @@ +"""Keycloak OAuth server example for FastMCP. + +This example demonstrates how to protect a FastMCP server with Keycloak OAuth. + +Required: Keycloak 26.6.0 or later with Dynamic Client Registration enabled. + +To run: + KEYCLOAK_REALM_URL=https://your-keycloak.com/realms/myrealm 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/fastmcp", + base_url="http://127.0.0.1:8000", + # audience="http://127.0.0.1:8000", # Recommended for production +) + +mcp = FastMCP("Keycloak Example Server", auth=auth) + + +@mcp.tool +def echo(message: str) -> str: + """Echo the provided message.""" + return message + + +@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"), + } + + +if __name__ == "__main__": + mcp.run(transport="http", port=8000) diff --git a/examples/auth/mounted/README.md b/examples/auth/mounted/README.md index 5810dab4c..2bf213094 100644 --- a/examples/auth/mounted/README.md +++ b/examples/auth/mounted/README.md @@ -4,12 +4,12 @@ This example demonstrates mounting multiple OAuth-protected MCP servers in a sin ## URL Structure -- **GitHub MCP**: `http://localhost:8000/api/mcp/github/mcp` -- **Google MCP**: `http://localhost:8000/api/mcp/google/mcp` +- **GitHub MCP**: `http://127.0.0.1:8000/api/mcp/github/mcp` +- **Google MCP**: `http://127.0.0.1:8000/api/mcp/google/mcp` Discovery endpoints (RFC 8414 path-aware): -- **GitHub**: `http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/github` -- **Google**: `http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/google` +- **GitHub**: `http://127.0.0.1:8000/.well-known/oauth-authorization-server/api/mcp/github` +- **Google**: `http://127.0.0.1:8000/.well-known/oauth-authorization-server/api/mcp/google` ## Setup @@ -23,8 +23,8 @@ export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET="your-google-client-secret" ``` Configure redirect URIs in each provider's developer console (note the `/api/mcp/{provider}` prefix since the servers are mounted): -- GitHub: `http://localhost:8000/api/mcp/github/auth/callback/github` -- Google: `http://localhost:8000/api/mcp/google/auth/callback/google` +- GitHub: `http://127.0.0.1:8000/api/mcp/github/auth/callback/github` +- Google: `http://127.0.0.1:8000/api/mcp/google/auth/callback/google` ## Running diff --git a/examples/auth/mounted/server.py b/examples/auth/mounted/server.py index 24aefdd59..c5b3af593 100644 --- a/examples/auth/mounted/server.py +++ b/examples/auth/mounted/server.py @@ -5,10 +5,10 @@ application, each with its own provider. It showcases RFC 8414 path-aware discov where each server has its own authorization server metadata endpoint. URL structure: -- GitHub MCP: http://localhost:8000/api/mcp/github/mcp -- Google MCP: http://localhost:8000/api/mcp/google/mcp -- GitHub discovery: http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/github -- Google discovery: http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/google +- GitHub MCP: http://127.0.0.1:8000/api/mcp/github/mcp +- Google MCP: http://127.0.0.1:8000/api/mcp/google/mcp +- GitHub discovery: http://127.0.0.1:8000/.well-known/oauth-authorization-server/api/mcp/github +- Google discovery: http://127.0.0.1:8000/.well-known/oauth-authorization-server/api/mcp/google Required environment variables: - FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID: Your GitHub OAuth app client ID @@ -31,7 +31,7 @@ from fastmcp.server.auth.providers.github import GitHubProvider from fastmcp.server.auth.providers.google import GoogleProvider # Configuration -ROOT_URL = "http://localhost:8000" +ROOT_URL = "http://127.0.0.1:8000" API_PREFIX = "/api/mcp" # --- GitHub OAuth Server --- diff --git a/examples/auth/propelauth_oauth/README.md b/examples/auth/propelauth_oauth/README.md index 575ea7314..aa5b10ecf 100644 --- a/examples/auth/propelauth_oauth/README.md +++ b/examples/auth/propelauth_oauth/README.md @@ -36,7 +36,7 @@ Create a `.env` file: PROPELAUTH_AUTH_URL=https://auth.yourdomain.com PROPELAUTH_INTROSPECTION_CLIENT_ID=your-client-id PROPELAUTH_INTROSPECTION_CLIENT_SECRET=your-client-secret -BASE_URL=http://localhost:8000/ +BASE_URL=http://127.0.0.1:8000/ # Optional: additional scopes tokens must include (comma-separated) # PROPELAUTH_REQUIRED_SCOPES=read:user_data ``` @@ -50,7 +50,7 @@ Start the server: uv run python server.py ``` -The server will start on `http://localhost:8000/mcp` with PropelAuth OAuth authentication enabled. +The server will start on `http://127.0.0.1:8000/mcp` with PropelAuth OAuth authentication enabled. Test with client: diff --git a/examples/auth/propelauth_oauth/server.py b/examples/auth/propelauth_oauth/server.py index 8401882aa..ab1661d22 100644 --- a/examples/auth/propelauth_oauth/server.py +++ b/examples/auth/propelauth_oauth/server.py @@ -9,7 +9,7 @@ Required environment variables: Optional: - PROPELAUTH_REQUIRED_SCOPES: Comma-separated scopes tokens must include -- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://localhost:8000/`) +- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://127.0.0.1:8000/`) To run: python server.py @@ -29,7 +29,7 @@ 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.getenv("BASE_URL", "http://localhost:8000/"), + base_url=os.getenv("BASE_URL", "http://127.0.0.1:8000/"), ) mcp = FastMCP("PropelAuth OAuth Example Server", auth=auth) diff --git a/examples/auth/scalekit_oauth/README.md b/examples/auth/scalekit_oauth/README.md index c241d76f7..d16b81c37 100644 --- a/examples/auth/scalekit_oauth/README.md +++ b/examples/auth/scalekit_oauth/README.md @@ -24,7 +24,7 @@ Create a `.env` file: # Required Scalekit credentials SCALEKIT_ENVIRONMENT_URL= SCALEKIT_RESOURCE_ID= # res_926EXAMPLE5878 -BASE_URL=http://localhost:8000/ +BASE_URL=http://127.0.0.1:8000/ # Optional: additional scopes tokens must include (comma-separated) # SCALEKIT_REQUIRED_SCOPES=read,write ``` @@ -38,7 +38,7 @@ Start the server: uv run python server.py ``` -The server will start on `http://localhost:8000/mcp` with Scalekit OAuth authentication enabled. +The server will start on `http://127.0.0.1:8000/mcp` with Scalekit OAuth authentication enabled. Test with client: diff --git a/examples/auth/scalekit_oauth/server.py b/examples/auth/scalekit_oauth/server.py index 68cef23b5..09d4f5959 100644 --- a/examples/auth/scalekit_oauth/server.py +++ b/examples/auth/scalekit_oauth/server.py @@ -8,7 +8,7 @@ Required environment variables: Optional: - SCALEKIT_REQUIRED_SCOPES: Comma-separated scopes tokens must include -- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://localhost:8000/`) +- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://127.0.0.1:8000/`) To run: python server.py @@ -30,7 +30,7 @@ auth = ScalekitProvider( environment_url=os.getenv("SCALEKIT_ENVIRONMENT_URL") or "https://your-env.scalekit.com", resource_id=os.getenv("SCALEKIT_RESOURCE_ID") or "", - base_url=os.getenv("BASE_URL", "http://localhost:8000/"), + base_url=os.getenv("BASE_URL", "http://127.0.0.1:8000/"), required_scopes=required_scopes, ) diff --git a/examples/auth/workos_oauth/server.py b/examples/auth/workos_oauth/server.py index 08c1db62b..4dba970a8 100644 --- a/examples/auth/workos_oauth/server.py +++ b/examples/auth/workos_oauth/server.py @@ -20,7 +20,7 @@ auth = WorkOSProvider( client_id=os.getenv("WORKOS_CLIENT_ID") or "", client_secret=os.getenv("WORKOS_CLIENT_SECRET") or "", authkit_domain=os.getenv("WORKOS_AUTHKIT_DOMAIN") or "https://your-app.authkit.app", - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", # redirect_path="/auth/callback", # Default path - change if using a different callback URL ) diff --git a/examples/filesystem-provider/mcp/prompts/assistant.py b/examples/filesystem-provider/components/prompts/assistant.py similarity index 100% rename from examples/filesystem-provider/mcp/prompts/assistant.py rename to examples/filesystem-provider/components/prompts/assistant.py diff --git a/examples/filesystem-provider/mcp/resources/config.py b/examples/filesystem-provider/components/resources/config.py similarity index 100% rename from examples/filesystem-provider/mcp/resources/config.py rename to examples/filesystem-provider/components/resources/config.py diff --git a/examples/filesystem-provider/mcp/tools/calculator.py b/examples/filesystem-provider/components/tools/calculator.py similarity index 100% rename from examples/filesystem-provider/mcp/tools/calculator.py rename to examples/filesystem-provider/components/tools/calculator.py diff --git a/examples/filesystem-provider/mcp/tools/greeting.py b/examples/filesystem-provider/components/tools/greeting.py similarity index 100% rename from examples/filesystem-provider/mcp/tools/greeting.py rename to examples/filesystem-provider/components/tools/greeting.py diff --git a/examples/filesystem-provider/server.py b/examples/filesystem-provider/server.py index 2f3cf47a6..11bbf7f81 100644 --- a/examples/filesystem-provider/server.py +++ b/examples/filesystem-provider/server.py @@ -22,7 +22,7 @@ from fastmcp.server.providers import FileSystemProvider # Functions decorated with @tool, @resource, or @prompt are registered. # Directory structure is purely organizational - decorators determine type. provider = FileSystemProvider( - root=Path(__file__).parent / "mcp", + root=Path(__file__).parent / "components", reload=True, # Set True for dev mode (re-scan on every request) ) diff --git a/examples/testing_demo/uv.lock b/examples/testing_demo/uv.lock index 1eac33e3c..da907335b 100644 --- a/examples/testing_demo/uv.lock +++ b/examples/testing_demo/uv.lock @@ -237,62 +237,62 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, - { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, - { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, - { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] [[package]] @@ -375,7 +375,7 @@ wheels = [ [[package]] name = "fastmcp" -version = "3.1.1" +version = "3.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "authlib" }, @@ -400,9 +400,9 @@ dependencies = [ { name = "watchfiles" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/83/c95d3bf717698a693eccb43e137a32939d2549876e884e246028bff6ecce/fastmcp-3.1.1.tar.gz", hash = "sha256:db184b5391a31199323766a3abf3a8bfbb8010479f77eca84c0e554f18655c48", size = 17347644, upload-time = "2026-03-14T19:12:20.235Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/32/4f1b2cfd7b50db89114949f90158b1dcc2c92a1917b9f57c0ff24e47a2f4/fastmcp-3.2.0.tar.gz", hash = "sha256:d4830b8ffc3592d3d9c76dc0f398904cf41f04910e41a0de38cc1004e0903bef", size = 26318581, upload-time = "2026-03-30T20:25:37.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/ea/570122de7e24f72138d006f799768e14cc1ccf7fcb22b7750b2bd276c711/fastmcp-3.1.1-py3-none-any.whl", hash = "sha256:8132ba069d89f14566b3266919d6d72e2ec23dd45d8944622dca407e9beda7eb", size = 633754, upload-time = "2026-03-14T19:12:22.736Z" }, + { url = "https://files.pythonhosted.org/packages/4f/67/684fa2d2de1e7504549d4ca457b4f854ccec3cd3be03bd86b33b599fbf58/fastmcp-3.2.0-py3-none-any.whl", hash = "sha256:e71aba3df16f86f546a4a9e513261d3233bcc92bef0dfa647bac3fa33623f681", size = 705550, upload-time = "2026-03-30T20:25:35.499Z" }, ] [[package]] diff --git a/justfile b/justfile index 28f56c2e0..8c601a16c 100644 --- a/justfile +++ b/justfile @@ -20,10 +20,10 @@ docs-broken-links: # Generate API reference documentation for all modules api-ref-all: - uvx --with-editable . --refresh-package mdxify mdxify@latest --all --root-module fastmcp --anchor-name "Python SDK" --exclude fastmcp.contrib + uvx --with-editable . --refresh-package mdxify mdxify@latest --all --root-module fastmcp --nav-output docs/python-sdk-pages.json --exclude fastmcp.contrib # Generate API reference for specific modules (e.g., just api-ref prefect.flows prefect.tasks) api-ref *MODULES: - uvx --with-editable . --refresh-package mdxify mdxify@latest {{MODULES}} --root-module fastmcp --anchor-name "Python SDK" + uvx --with-editable . --refresh-package mdxify mdxify@latest {{MODULES}} --root-module fastmcp --nav-output docs/python-sdk-pages.json # Clean up API reference documentation api-ref-clean: diff --git a/loq.toml b/loq.toml index d495ee57f..b7d075845 100644 --- a/loq.toml +++ b/loq.toml @@ -12,20 +12,52 @@ max_lines = 1000 [[rules]] path = "src/fastmcp/server/context.py" -max_lines = 1272 +max_lines = 1404 [[rules]] path = "src/fastmcp/server/server.py" -max_lines = 3250 - -[[rules]] -path = "src/fastmcp/client/client.py" -max_lines = 1885 +max_lines = 2410 [[rules]] path = "src/fastmcp/server/auth/oauth_proxy/proxy.py" -max_lines = 1796 +max_lines = 2098 [[rules]] -path = "src/fastmcp/server/providers/local_provider.py" -max_lines = 1187 +path = "src/fastmcp/cli/apps_dev.py" +max_lines = 1814 + +[[rules]] +path = "src/fastmcp/cli/cli.py" +max_lines = 1116 + +[[rules]] +path = "src/fastmcp/server/dependencies.py" +max_lines = 1686 + +[[rules]] +path = "src/fastmcp/server/providers/proxy.py" +max_lines = 1096 + +[[rules]] +path = "src/fastmcp/tools/tool_transform.py" +max_lines = 1004 + +[[rules]] +path = "tests/server/providers/openapi/test_openapi_features.py" +max_lines = 1029 + +[[rules]] +path = "tests/server/tasks/test_task_mount.py" +max_lines = 1083 + +[[rules]] +path = "tests/server/test_dependencies.py" +max_lines = 1194 + +[[rules]] +path = "tests/test_mcp_config.py" +max_lines = 1185 + +[[rules]] +path = "tests/utilities/openapi/test_director.py" +max_lines = 1154 diff --git a/pyproject.toml b/pyproject.toml index 034d52134..1572feef0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "jsonref>=1.1.0", "uncalled-for>=0.2.0", "watchfiles>=1.0.0", + "griffelib>=2.0.0", ] requires-python = ">=3.10" @@ -53,13 +54,13 @@ classifiers = [ [project.optional-dependencies] anthropic = ["anthropic>=0.48.0"] -apps = ["prefab-ui>=0.14.0"] +apps = ["prefab-ui>=0.18.0"] # PyJWT floor: transitive via msal; CVE-2026-32597 affects <= 2.11.0 azure = ["azure-identity>=1.16.0", "PyJWT>=2.12.0"] -code-mode = ["pydantic-monty==0.0.8"] +code-mode = ["pydantic-monty==0.0.11"] gemini = ["google-genai>=1.18.0"] openai = ["openai>=1.102.0"] -tasks = ["pydocket>=0.18.0"] +tasks = ["pydocket>=0.19.0"] [dependency-groups] dev = [ @@ -85,10 +86,11 @@ dev = [ "pytest-timeout>=2.4.0", "pytest-xdist>=3.6.1", "ruff>=0.12.8", - "ty>=0.0.25", + "ty>=0.0.29", "prek>=0.2.12", "loq>=0.1.0a3", "opentelemetry-exporter-otlp-proto-grpc>=1.39.0", + "pytest-examples>=0.0.18", ] [project.scripts] @@ -162,6 +164,15 @@ python-version = "3.10" # Some code uses `# ty: ignore[invalid-argument-type]` for this limitation. # TODO: Remove these ignores once ty supports union narrowing +# Promote rules from default-ignore to warn (becomes error via error-on-warning) +division-by-zero = "warn" +possibly-missing-attribute = "warn" +possibly-missing-import = "warn" +possibly-unresolved-reference = "warn" +unsupported-dynamic-base = "warn" +unsupported-operator = "warn" +unused-ignore-comment = "warn" + [tool.ty.terminal] error-on-warning = true @@ -173,13 +184,24 @@ ignore = [ "SIM102", # Dont require combining if statements ] extend-select = [ - "B", # flake8-bugbear: Catches actual bugs like mutable default arguments - "C4", # flake8-comprehensions: More efficient/readable comprehensions - "I", # flake8-builtins: Catches builtins that are not explicitly imported - "PIE", # flake8-pie: More idiomatic Python code - "RUF", # Ruff-specific: Modern best practices unique to Ruff - "SIM", # flake8-simplify: Simplifies verbose code patterns - "UP", # flake8-unused-imports: Catches unused imports + "B", # flake8-bugbear: Catches actual bugs like mutable default arguments + "C4", # flake8-comprehensions: More efficient/readable comprehensions + "DTZ", # flake8-datetimez: Enforce timezone-aware datetime usage + "ERA", # eradicate: Detect commented-out code + "FA", # flake8-future-annotations: Enforce from __future__ import annotations + "FLY", # flynt: Static string joins that should be f-strings + "I", # isort: Import sorting + "INP", # flake8-no-pep420: Require __init__.py in namespace packages + "ISC", # flake8-implicit-str-concat: Prevent accidental string concatenation + "LOG", # flake8-logging: Catches logging module misuse + "PIE", # flake8-pie: More idiomatic Python code + "PLE", # pylint-error: Catches actual errors (invalid operations, syntax issues) + "RSE", # flake8-raise: Unnecessary parentheses on raise + "RUF", # Ruff-specific: Modern best practices unique to Ruff + "SIM", # flake8-simplify: Simplifies verbose code patterns + "SLOT", # flake8-slots: Enforce __slots__ where applicable + "TID", # flake8-tidy-imports: Banned imports and relative import enforcement + "UP", # pyupgrade: Modernize syntax for newer Python versions ] [tool.ruff.lint.isort] @@ -190,13 +212,24 @@ known-first-party = ["fastmcp"] # allow imports not at the top of the file "src/fastmcp/__init__.py" = ["E402"] "!src/**.py" = [ # Only enforce extended ruff rules for code in src/ - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "PIE", # flake8-pie - "RUF", # Ruff-specific - "SIM", # flake8-simplify + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "ERA", # eradicate + "FA", # flake8-future-annotations + "FLY", # flynt + "INP", # flake8-no-pep420 + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "PIE", # flake8-pie + "PLE", # pylint-error + "RSE", # flake8-raise + "RUF", # Ruff-specific + "SIM", # flake8-simplify + "SLOT", # flake8-slots + "TID", # flake8-tidy-imports ] [tool.codespell] -ignore-words-list = "asend,shttp,te" \ No newline at end of file +ignore-words-list = "asend,shttp,te" diff --git a/src/fastmcp/apps/app.py b/src/fastmcp/apps/app.py index eaba588df..70783e48a 100644 --- a/src/fastmcp/apps/app.py +++ b/src/fastmcp/apps/app.py @@ -29,7 +29,7 @@ from __future__ import annotations import inspect from collections.abc import AsyncIterator, Callable, Sequence -from contextlib import asynccontextmanager, suppress +from contextlib import asynccontextmanager from typing import Any, Literal, TypeVar, overload from mcp.types import AnyFunction, Icon, ToolAnnotations @@ -50,38 +50,58 @@ F = TypeVar("F", bound=Callable[..., Any]) # --------------------------------------------------------------------------- -def _resolve_tool_ref(fn: Any) -> Any: - """Resolve a callable or string to a ``ResolvedTool`` for CallTool serialization. +def _make_resolver(app_name: str | None = None) -> Any: + """Create a CallTool resolver that prefixes tool names with a hash. - For strings, passes them through as-is — the server resolves them at - call time using ``_meta.fastmcp.app``. + Structurally identical to the old ``___`` resolver — ``app_name`` is + the FastMCPApp's name, known at serialization time from the tool's + ``meta["fastmcp"]["app"]`` tag. The only change is the wire format: + ``_`` instead of ``___``. - For callables, extracts the tool name from ``__fastmcp__`` metadata - or ``__name__``. + 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 prefab_ui.app import ResolvedTool + from fastmcp.server.providers.addressing import ( + hashed_backend_name, + parse_hashed_backend_name, + ) - if isinstance(fn, str): - return ResolvedTool(name=fn) + def _prefix(local_name: str) -> str: + if app_name: + # Don't re-hash an already-addressed name (same guard the + # old ___ resolver had with "___" not in name). + if parse_hashed_backend_name(local_name) is not None: + return local_name + return hashed_backend_name(app_name, local_name) + return local_name - fmeta: Any = None - try: - from fastmcp.decorators import get_fastmcp_meta + def _resolve_tool_ref(fn: Any) -> Any: + from prefab_ui.app import ResolvedTool - fmeta = get_fastmcp_meta(fn) - except Exception: - pass + if isinstance(fn, str): + return ResolvedTool(name=_prefix(fn)) - if fmeta is not None: - name: str | None = getattr(fmeta, "name", None) - if name is not None: - return ResolvedTool(name=name) + fmeta: Any = None + try: + from fastmcp.decorators import get_fastmcp_meta - fn_name = getattr(fn, "__name__", None) - if fn_name is not None: - return ResolvedTool(name=fn_name) + fmeta = get_fastmcp_meta(fn) + except Exception: + pass - raise ValueError(f"Cannot resolve tool reference: {fn!r}") + if fmeta is not None: + name: str | None = getattr(fmeta, "name", None) + if name is not None: + return ResolvedTool(name=_prefix(name)) + + fn_name = getattr(fn, "__name__", None) + if fn_name is not None: + return ResolvedTool(name=_prefix(fn_name)) + + raise ValueError(f"Cannot resolve tool reference: {fn!r}") + + return _resolve_tool_ref def _dispatch_decorator( @@ -200,11 +220,15 @@ class FastMCPApp(Provider): raise ValueError(f"Cannot determine tool name for {fn!r}") from fastmcp.apps.config import AppConfig, app_config_to_meta_dict + from fastmcp.server.providers.addressing import hash_tool app_config = AppConfig(visibility=visibility) meta: dict[str, Any] = { "ui": app_config_to_meta_dict(app_config), - "fastmcp": {"app": self.name}, + "fastmcp": { + "app": self.name, + "_tool_hash": hash_tool(self.name, resolved_name), + }, } tool_obj = Tool.from_function( @@ -287,34 +311,23 @@ class FastMCPApp(Provider): def _register(fn: F, tool_name: str | None) -> F: from fastmcp.apps.config import AppConfig, app_config_to_meta_dict + from fastmcp.server.providers.addressing import hash_tool from fastmcp.server.providers.local_provider.decorators.tools import ( PREFAB_RENDERER_URI, - _ensure_prefab_renderer, ) - try: - from prefab_ui.renderer import get_renderer_csp - - from fastmcp.apps.config import ResourceCSP - - csp = get_renderer_csp() - app_config = AppConfig( - resource_uri=PREFAB_RENDERER_URI, - visibility=["model"], - csp=ResourceCSP( - resource_domains=csp.get("resource_domains"), - connect_domains=csp.get("connect_domains"), - ), - ) - except ImportError: - app_config = AppConfig( - resource_uri=PREFAB_RENDERER_URI, - visibility=["model"], - ) + resolved = tool_name or getattr(fn, "__name__", None) or "unknown" + app_config = AppConfig( + resource_uri=PREFAB_RENDERER_URI, + visibility=["model"], + ) meta: dict[str, Any] = { "ui": app_config_to_meta_dict(app_config), - "fastmcp": {"app": self.name}, + "fastmcp": { + "app": self.name, + "_tool_hash": hash_tool(self.name, resolved), + }, } tool_obj = Tool.from_function( @@ -331,10 +344,6 @@ class FastMCPApp(Provider): ) self._local._add_component(tool_obj) - # Register the Prefab renderer resource on the internal provider - with suppress(ImportError): - _ensure_prefab_renderer(self._local) - return fn return _dispatch_decorator(name_or_fn, name, _register, "ui") @@ -354,9 +363,12 @@ class FastMCPApp(Provider): if not isinstance(tool, Tool): tool = Tool._ensure_tool(tool) - # Tag with app name and visibility for routing + from fastmcp.server.providers.addressing import hash_tool + meta = dict(tool.meta) if tool.meta else {} - meta.setdefault("fastmcp", {})["app"] = self.name + fm = meta.setdefault("fastmcp", {}) + fm["app"] = self.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/src/fastmcp/apps/approval.py b/src/fastmcp/apps/approval.py new file mode 100644 index 000000000..17b124e1f --- /dev/null +++ b/src/fastmcp/apps/approval.py @@ -0,0 +1,198 @@ +"""Approval — a Provider that adds human-in-the-loop approval to any server. + +The LLM presents a summary of what it's about to do, and the user +approves or rejects via buttons. The result is sent back into the +conversation as a message, prompting the LLM's next turn. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps.approval import Approval + + mcp = FastMCP("My Server") + mcp.add_provider(Approval()) +""" + +from __future__ import annotations + +from typing import Literal + +try: + from prefab_ui.actions import SetState + from prefab_ui.actions.mcp import SendMessage + from prefab_ui.app import PrefabApp + from prefab_ui.components import ( + H3, + Button, + Card, + CardContent, + CardFooter, + CardHeader, + Column, + Muted, + Row, + Text, + ) + from prefab_ui.components.control_flow import If + from prefab_ui.rx import STATE +except ImportError as _exc: + raise ImportError( + "Approval requires prefab-ui. Install with: pip install 'fastmcp[apps]'" + ) from _exc + + +from fastmcp.apps.app import FastMCPApp + + +class Approval(FastMCPApp): + """A Provider that adds human-in-the-loop approval to a server. + + The LLM calls the ``request_approval`` tool with a summary and + optional details. The user sees an approval card with Approve and + Reject buttons. Clicking either sends a message back into the + conversation (via ``SendMessage``), triggering the LLM's next turn. + + The message appears as if the user sent it, so the LLM sees + something like ``'"Deploy v3.2 to production" is APPROVED'``. + + Example:: + + from fastmcp import FastMCP + from fastmcp.apps.approval import Approval + + mcp = FastMCP("My Server") + mcp.add_provider(Approval()) + + Customized:: + + Approval( + title="Deploy Gate", + approve_text="Ship it", + approve_variant="default", + reject_text="Abort", + reject_variant="destructive", + ) + """ + + def __init__( + self, + name: str = "Approval", + *, + title: str = "Approval Required", + approve_text: str = "Approve", + reject_text: str = "Reject", + approve_variant: Literal[ + "default", "destructive", "success", "info" + ] = "default", + reject_variant: Literal[ + "default", "outline", "destructive", "success", "info" + ] = "outline", + ) -> None: + super().__init__(name) + self._title = title + self._approve_text = approve_text + self._reject_text = reject_text + self._approve_variant = approve_variant + self._reject_variant = reject_variant + self._register_tools() + + def __repr__(self) -> str: + return f"Approval({self.name!r})" + + def _register_tools(self) -> None: + provider = self + + @self.ui() + def request_approval( + summary: str, + details: str | None = None, + title: str | None = None, + approve_text: str | None = None, + reject_text: str | None = None, + approve_variant: str | None = None, + reject_variant: str | None = None, + ) -> PrefabApp: + """Request human approval before proceeding with an action. + + Call this tool proactively whenever you are about to take a + significant or irreversible action and want the user to + confirm first. Do NOT wait for the user to ask you to seek + approval — use your judgment about when confirmation is + appropriate. + + The user will see an approval card with the summary, optional + details, and Approve/Reject buttons. When they click a button, + their decision appears as a message in the conversation (as if + the user typed it), like: + + "Deploy v3.2 to production" — I selected: Approve + + or: + + "Deploy v3.2 to production" — I selected: Reject + + IMPORTANT: After calling this tool, you MUST stop and wait + for the user's response. Do not continue, do not take any + other actions, do not generate further output until you see + the "I selected:" message. If approved, continue with the + action. If rejected, acknowledge and ask how to proceed. + + Args: + summary: Brief description of the action requiring approval + (shown prominently to the user). + details: Optional longer explanation, context, or + consequences of the action. + title: Heading for the approval card (default: "Approval Required"). + approve_text: Label for the approve button (default: "Approve"). + reject_text: Label for the reject button (default: "Reject"). + approve_variant: Button style — "default", "destructive", + "success", or "info". + reject_variant: Button style for the reject button + (same options plus "outline"). + """ + _title = title or provider._title + _approve = approve_text or provider._approve_text + _reject = reject_text or provider._reject_text + _approve_v = approve_variant or provider._approve_variant + _reject_v = reject_variant or provider._reject_variant + + approve_msg = f'"{summary}" — I selected: {_approve}' + reject_msg = f'"{summary}" — I selected: {_reject}' + + with Card(css_class="max-w-lg mx-auto") as view: + with CardHeader(): + H3(_title) + + with CardContent(), Column(gap=3): + Text(summary, css_class="font-medium") + if details: + Muted(details) + + with CardFooter(): + with If(STATE.decided): + Muted("Response sent.") + with If(~STATE.decided): # noqa: SIM117 + with Row(gap=2, css_class="w-full justify-end"): + Button( + _reject, + variant=_reject_v, + on_click=[ + SendMessage(reject_msg), + SetState("decided", True), + ], + ) + Button( + _approve, + variant=_approve_v, + on_click=[ + SendMessage(approve_msg), + SetState("decided", True), + ], + ) + + return PrefabApp( + view=view, + state={"decided": False}, + ) diff --git a/src/fastmcp/apps/choice.py b/src/fastmcp/apps/choice.py new file mode 100644 index 000000000..aaffef903 --- /dev/null +++ b/src/fastmcp/apps/choice.py @@ -0,0 +1,141 @@ +"""Choice — a Provider that lets the user pick from a set of options. + +The LLM presents options, the user clicks one, and the selection +flows back into the conversation as a message. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps.choice import Choice + + mcp = FastMCP("My Server") + mcp.add_provider(Choice()) +""" + +from __future__ import annotations + +from typing import Literal + +try: + from prefab_ui.actions import SetState + from prefab_ui.actions.mcp import SendMessage + from prefab_ui.app import PrefabApp + from prefab_ui.components import ( + H3, + Button, + Card, + CardContent, + CardFooter, + CardHeader, + Column, + Muted, + Text, + ) + from prefab_ui.components.control_flow import If + from prefab_ui.rx import STATE +except ImportError as _exc: + raise ImportError( + "Choice requires prefab-ui. Install with: pip install 'fastmcp[apps]'" + ) from _exc + +from fastmcp.apps.app import FastMCPApp + + +class Choice(FastMCPApp): + """A Provider that lets the user choose from a set of options. + + The LLM calls ``choose`` with a prompt and a list of options. + The user sees a card with one button per option. Clicking a button + sends the selection back into the conversation via ``SendMessage``, + triggering the LLM's next turn. + + Example:: + + from fastmcp import FastMCP + from fastmcp.apps.choice import Choice + + mcp = FastMCP("My Server") + mcp.add_provider(Choice()) + """ + + def __init__( + self, + name: str = "Choice", + *, + title: str = "Choose an Option", + variant: Literal[ + "default", "outline", "destructive", "success", "info" + ] = "outline", + ) -> None: + super().__init__(name) + self._title = title + self._variant = variant + self._register_tools() + + def __repr__(self) -> str: + return f"Choice({self.name!r})" + + def _register_tools(self) -> None: + provider = self + + @self.ui() + def choose( + prompt: str, + options: list[str], + title: str | None = None, + ) -> PrefabApp: + """Present the user with a set of options to choose from. + + Call this tool when you need the user to make a decision + between discrete alternatives. Use it proactively — don't + ask the user to type their choice in chat when you can + present clean, clickable options instead. + + The user will see a card with one button per option. When + they click one, their choice appears as a message in the + conversation (as if the user typed it), like: + + "Which deployment strategy?" — I selected: Blue-green + + IMPORTANT: After calling this tool, you MUST stop and wait + for the user's response. Do not continue or take any other + actions until you see the "I selected:" message. + + Args: + prompt: The question or decision to present to the user. + options: List of options the user can choose from. + title: Optional heading for the card. + """ + _title = title or provider._title + + with Card(css_class="max-w-lg mx-auto") as view: + with CardHeader(): + H3(_title) + + with CardContent(): + Text(prompt, css_class="font-medium") + + with CardFooter(): + with If(STATE.decided): + Muted("Response sent.") + with If(~STATE.decided): # noqa: SIM117 + with Column(gap=2, css_class="w-full"): + for option in options: + Button( + option, + variant=provider._variant, + css_class="w-full justify-start", + on_click=[ + SendMessage( + f'"{prompt}" — I selected: {option}' + ), + SetState("decided", True), + ], + ) + + return PrefabApp( + view=view, + state={"decided": False}, + ) diff --git a/src/fastmcp/apps/file_upload.py b/src/fastmcp/apps/file_upload.py new file mode 100644 index 000000000..aeb890379 --- /dev/null +++ b/src/fastmcp/apps/file_upload.py @@ -0,0 +1,405 @@ +"""FileUpload — a Provider that adds drag-and-drop file upload to any server. + +Lets users upload files directly to the server through an interactive UI, +bypassing the LLM context window entirely. The LLM can then read and work +with uploaded files through model-visible tools. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from fastmcp import FastMCP + from fastmcp.apps import FileUpload + + mcp = FastMCP("My Server") + mcp.add_provider(FileUpload()) + +For custom persistence, override the storage methods:: + + class S3Upload(FileUpload): + def on_store(self, files, ctx): + # write to S3, return summaries + ... + + def on_list(self, ctx): + # list from S3 + ... + + def on_read(self, name, ctx): + # read from S3 + ... +""" + +from __future__ import annotations + +try: + from prefab_ui.actions import SetState, ShowToast + from prefab_ui.actions.mcp import CallTool + from prefab_ui.app import PrefabApp + from prefab_ui.components import ( + H3, + Badge, + Button, + Card, + CardContent, + CardFooter, + CardHeader, + Column, + DropZone, + Muted, + Row, + Separator, + Small, + Text, + ) + from prefab_ui.components.control_flow import Else, ForEach, If + from prefab_ui.rx import ERROR, RESULT, STATE, Rx +except ImportError as _exc: + raise ImportError( + "FileUpload requires prefab-ui. Install with: pip install 'fastmcp[apps]'" + ) from _exc + +import base64 +from datetime import datetime, timezone +from typing import Any + +from fastmcp.apps.app import FastMCPApp +from fastmcp.server.context import Context + +_TEXT_EXTENSIONS = frozenset( + (".csv", ".json", ".txt", ".md", ".py", ".yaml", ".yml", ".toml") +) + + +def _b64_decoded_size(b64: str) -> int: + """Return the exact decoded byte-length of a base64 string without decoding it.""" + n = len(b64) + if n == 0: + return 0 + padding = b64.count("=", max(0, n - 2)) + return n * 3 // 4 - padding + + +def _format_size(size: int) -> str: + if size < 1024: + return f"{size} B" + elif size < 1024 * 1024: + return f"{size / 1024:.1f} KB" + else: + return f"{size / (1024 * 1024):.1f} MB" + + +def _make_summary(entry: dict[str, Any]) -> dict[str, Any]: + return { + "name": entry["name"], + "type": entry["type"], + "size": entry["size"], + "size_display": _format_size(entry["size"]), + "uploaded_at": entry["uploaded_at"], + } + + +class FileUpload(FastMCPApp): + """A Provider that adds file upload capabilities to a server. + + Registers a drag-and-drop UI tool, a backend storage tool, and + model-visible tools for listing and reading uploaded files. + + Files are scoped by MCP session and stored in memory by default. + Override ``on_store``, ``on_list``, and ``on_read`` for custom + persistence (filesystem, S3, database, etc.). Each method receives + the current ``Context``, giving access to session ID, auth tokens, + and request metadata for partitioning and authorization. + + **Session scoping:** The default storage uses ``ctx.session_id`` to + isolate files by session. This works with stdio, SSE, and stateful + HTTP transports. In **stateless HTTP** mode, each request creates a + new session, so files won't persist across requests. For stateless + deployments, override the storage methods to partition by a stable + identifier from the auth context:: + + class UserScopedUpload(FileUpload): + def on_store(self, files, ctx): + user_id = ctx.access_token["sub"] + ... + + Example:: + + from fastmcp import FastMCP + from fastmcp.apps.file_upload import FileUpload + + mcp = FastMCP("My Server") + mcp.add_provider(FileUpload()) + """ + + def __init__( + self, + name: str = "Files", + *, + max_file_size: int = 10 * 1024 * 1024, + title: str = "File Upload", + description: str = ( + "Drop files to upload them to the server. " + "The model can then read and analyze them " + "without using the context window." + ), + drop_label: str = "Drop files here", + ) -> None: + super().__init__(name) + self._max_file_size = max_file_size + self._title = title + self._description = description + self._drop_label = drop_label + + # Default in-memory store, keyed by session_id + self._store: dict[str, dict[str, dict[str, Any]]] = {} + + self._register_tools() + + def __repr__(self) -> str: + return f"FileUpload({self.name!r})" + + # ------------------------------------------------------------------ + # Storage interface — override these for custom persistence + # ------------------------------------------------------------------ + + def _get_scope_key(self, ctx: Context) -> str: + """Return the key used to partition file storage. + + Defaults to ``ctx.session_id``, which is stable for stdio, SSE, + and stateful HTTP. The default ``on_store``/``on_list``/``on_read`` + implementations call this to partition the in-memory store. + + Override to scope by user, tenant, or any other dimension:: + + def _get_scope_key(self, ctx): + return ctx.access_token["sub"] + """ + try: + return ctx.session_id + except RuntimeError: + return "__default__" + + def on_store( + self, + files: list[dict[str, Any]], + ctx: Context, + ) -> list[dict[str, Any]]: + """Store uploaded files and return summaries. + + Args: + files: List of file dicts, each with ``name``, ``size``, + ``type``, and ``data`` (base64-encoded content). + ctx: The current request context. Use for session ID, + auth tokens, or any metadata needed for partitioning. + + Override this method for custom persistence. The default + implementation stores files in memory, scoped by + ``_get_scope_key(ctx)``. + + Returns: + List of file summary dicts (``name``, ``type``, ``size``, + ``size_display``, ``uploaded_at``). + """ + scope = self._get_scope_key(ctx) + session_files = self._store.setdefault(scope, {}) + for f in files: + session_files[f["name"]] = { + "name": f["name"], + "size": f["size"], + "type": f["type"], + "data": f["data"], + "uploaded_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + return [_make_summary(e) for e in session_files.values()] + + def on_list(self, ctx: Context) -> list[dict[str, Any]]: + """List all stored files. + + Args: + ctx: The current request context. + + Override this method for custom persistence. The default + implementation returns files from the current scope. + + Returns: + List of file summary dicts. + """ + scope = self._get_scope_key(ctx) + session_files = self._store.get(scope, {}) + return [_make_summary(e) for e in session_files.values()] + + def on_read(self, name: str, ctx: Context) -> dict[str, Any]: + """Read a file's contents by name. + + Args: + name: The filename to read. + ctx: The current request context. + + Override this method for custom persistence. The default + implementation reads from the current scope's in-memory store. + Text files are decoded from base64; binary files return a + truncated base64 preview. + + Returns: + Dict with file metadata and ``content`` (text) or + ``content_base64`` (binary preview). + + Raises: + ValueError: If the file is not found. + """ + scope = self._get_scope_key(ctx) + session_files = self._store.get(scope, {}) + if name not in session_files: + available = list(session_files.keys()) + raise ValueError(f"File {name!r} not found. Available: {available}") + entry = session_files[name] + result: dict[str, Any] = { + "name": entry["name"], + "size": entry["size"], + "type": entry["type"], + "uploaded_at": entry["uploaded_at"], + } + is_text = entry["type"].startswith("text/") or any( + entry["name"].endswith(ext) for ext in _TEXT_EXTENSIONS + ) + if is_text: + try: + result["content"] = base64.b64decode(entry["data"]).decode("utf-8") + except UnicodeDecodeError: + result["content_base64"] = entry["data"][:200] + "..." + else: + result["content_base64"] = entry["data"][:200] + "..." + return result + + # ------------------------------------------------------------------ + # Tool registration + # ------------------------------------------------------------------ + + def _register_tools(self) -> None: + provider = self + + @self.tool() + def store_files(files: list[dict], ctx: Context) -> list[dict]: + """Store uploaded files. Receives file objects with name, size, type, data (base64).""" + for f in files: + # Compute actual data size from the base64 payload rather + # than trusting the client-reported ``size`` field. + actual_size = _b64_decoded_size(f.get("data", "")) + if actual_size > provider._max_file_size: + raise ValueError( + f"File {f.get('name', '?')!r} exceeds max size " + f"({_format_size(actual_size)} > " + f"{_format_size(provider._max_file_size)})" + ) + return provider.on_store(files, ctx) + + @self.tool(model=True) + def list_files(ctx: Context) -> list[dict]: + """List all uploaded files with metadata.""" + return provider.on_list(ctx) + + @self.tool(model=True) + def read_file(name: str, ctx: Context) -> dict: + """Read an uploaded file's contents by name.""" + return provider.on_read(name, ctx) + + @self.ui() + def file_manager(ctx: Context) -> PrefabApp: + """Upload and manage files. Drop files here to send them to the server.""" + with Card(css_class="max-w-2xl mx-auto") as view: + with CardHeader(), Row(gap=2, align="center"): + H3(provider._title) + with If(STATE.stored.length()): + Badge( + STATE.stored.length(), # ty:ignore[invalid-argument-type] + variant="secondary", + ) + + with CardContent(), Column(gap=4): + Muted(provider._description) + + DropZone( + name="pending", + icon="inbox", + label=provider._drop_label, + description=( + "Any file type, up to " + f"{_format_size(provider._max_file_size)}" + ), + multiple=True, + max_size=provider._max_file_size, + ) + + with If(STATE.pending.length()), Column(gap=2): + with ( + ForEach("pending"), + Row(gap=2, align="center"), + Column(gap=0), + ): + Small(Rx("$item.name")) # ty:ignore[invalid-argument-type] + Muted(Rx("$item.type")) # ty:ignore[invalid-argument-type] + + Button( + "Upload to Server", + on_click=CallTool( + "store_files", + arguments={ + "files": Rx("pending"), + }, + on_success=[ + SetState("stored", RESULT), + SetState("pending", []), + ShowToast( + "Files uploaded!", + variant="success", + ), + ], + on_error=ShowToast( + ERROR, # ty:ignore[invalid-argument-type] + variant="error", + ), + ), + ) + + with If(STATE.stored.length()): + Separator() + Text( + "Uploaded", + css_class="font-medium text-sm", + ) + with ( + ForEach("stored") as f, + Row( + gap=2, + align="center", + css_class="justify-between", + ), + ): + with Column(gap=0): + Small(f.name) # ty:ignore[invalid-argument-type] + Muted(f.uploaded_at) # ty:ignore[invalid-argument-type] + with Row(gap=2): + Badge(f.type, variant="secondary") # ty:ignore[invalid-argument-type] + Badge( + f.size_display, # ty:ignore[invalid-argument-type] + variant="outline", + ) + + with CardFooter(), Row(align="center", css_class="w-full"): + with If(STATE.stored.length()): + Muted( + f"{STATE.stored.length()}" + f" {STATE.stored.length().pluralize('file')}" + " on server" + ) + with Else(): + Muted("No files uploaded yet") + + return PrefabApp( + view=view, + state={ + "pending": [], + "stored": provider.on_list(ctx), + }, + ) diff --git a/src/fastmcp/apps/form.py b/src/fastmcp/apps/form.py new file mode 100644 index 000000000..e5e6d1151 --- /dev/null +++ b/src/fastmcp/apps/form.py @@ -0,0 +1,208 @@ +"""FormInput — a Provider that collects structured input from the user. + +Define a Pydantic model for the data you need, and ``FormInput`` +generates a form UI. The user fills it out, the submission is +validated, and an optional callback processes the result. + +Requires ``fastmcp[apps]`` (prefab-ui). + +Usage:: + + from pydantic import BaseModel + from fastmcp import FastMCP + from fastmcp.apps.form import FormInput + + class ShippingAddress(BaseModel): + street: str + city: str + state: str + zip_code: str + + mcp = FastMCP("My Server") + mcp.add_provider(FormInput(model=ShippingAddress)) +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +try: + from prefab_ui.actions import SetState + from prefab_ui.actions.mcp import CallTool, SendMessage + from prefab_ui.app import PrefabApp + from prefab_ui.components import ( + H3, + Card, + CardContent, + CardFooter, + CardHeader, + Column, + Form, + Muted, + ) + from prefab_ui.components.control_flow import If + from prefab_ui.rx import RESULT, STATE +except ImportError as _exc: + raise ImportError( + "FormInput requires prefab-ui. Install with: pip install 'fastmcp[apps]'" + ) from _exc + +import pydantic + +from fastmcp.apps.app import FastMCPApp + + +def _backfill_boolean_defaults( + model: type[pydantic.BaseModel], + data: dict[str, Any], +) -> dict[str, Any]: + """Fill in missing boolean fields with their model defaults. + + HTML checkboxes omit the field entirely when unchecked, so the + submitted data dict won't contain a key for ``False`` booleans. + This backfills those missing keys so Pydantic validation succeeds. + """ + for name, field_info in model.model_fields.items(): + if name in data: + continue + if field_info.annotation is bool: + if field_info.default is not pydantic.fields.PydanticUndefined: + data[name] = field_info.default + else: + data[name] = False + return data + + +class FormInput(FastMCPApp): + """A Provider that collects structured input via a Pydantic model. + + Define a model for the data you need, and ``FormInput`` generates + a form from it using ``Form.from_model()``. Field types, labels, + descriptions, and validation are all derived from the model. + + Optionally provide an ``on_submit`` callback to process the + validated data. The callback receives a model instance and returns + a string that goes back to the LLM. Without a callback, the + validated JSON is sent directly. + + Example:: + + from pydantic import BaseModel + from fastmcp import FastMCP + from fastmcp.apps.form import FormInput + + class Contact(BaseModel): + name: str + email: str + + mcp = FastMCP("My Server") + mcp.add_provider(FormInput(model=Contact)) + + With a callback:: + + def save_contact(contact: Contact) -> str: + db.insert(contact.model_dump()) + return f"Saved {contact.name}" + + mcp.add_provider(FormInput(model=Contact, on_submit=save_contact)) + """ + + def __init__( + self, + model: type[pydantic.BaseModel], + *, + name: str | None = None, + title: str | None = None, + submit_text: str = "Submit", + tool_name: str | None = None, + on_submit: Callable[..., str] | None = None, + send_message: bool = False, + ) -> None: + app_name = name or model.__name__ + super().__init__(app_name) + self._model = model + self._title = title or model.__name__ + self._submit_text = submit_text + self._tool_name = tool_name or f"collect_{model.__name__.lower()}" + self._on_submit = on_submit + self._send_message = send_message + self._register_tools() + + def __repr__(self) -> str: + return f"FormInput({self._model.__name__!r})" + + def _register_tools(self) -> None: + provider = self + model = self._model + + @self.tool() + def submit_form(data: dict[str, Any] | None = None) -> str: + """Validate and process form submission.""" + if data is None: + data = {} + data = _backfill_boolean_defaults(model, data) + validated = model.model_validate(data) + if provider._on_submit is not None: + return provider._on_submit(validated) + return json.dumps(validated.model_dump(mode="json")) + + @self.ui( + name=provider._tool_name, + description=( + f"Collect {model.__name__} information from the user via a form. " + f"Call this tool when you need the user to provide " + f"{model.__name__} data. The user will see a validated form. " + f"After calling this tool, STOP and wait for the user to submit." + ), + ) + def collect_input( + prompt: str, + title: str | None = None, + submit_text: str | None = None, + ) -> PrefabApp: + """Collect structured input from the user. + + Args: + prompt: Tell the user what you need and why. + title: Optional heading for the form card. + submit_text: Optional label for the submit button. + """ + _title = title or provider._title + _submit = submit_text or provider._submit_text + + with Card(css_class="max-w-lg mx-auto") as view: + with CardHeader(): + H3(_title) + + with CardContent(), Column(gap=4): + Muted(prompt) + + on_success_actions: list[Any] = [ + SetState("submitted", True), + ] + if provider._send_message: + on_success_actions.insert( + 0, + SendMessage(RESULT), # ty:ignore[invalid-argument-type] + ) + + Form.from_model( + model, + submit_label=_submit, + on_submit=[ + CallTool( + "submit_form", + on_success=on_success_actions, + ), + ], + ) + + with CardFooter(), If(STATE.submitted): + Muted("Submitted.") + + return PrefabApp( + view=view, + state={"submitted": False}, + ) diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py index b074e60b8..4597e8de3 100644 --- a/src/fastmcp/cli/apps_dev.py +++ b/src/fastmcp/cli/apps_dev.py @@ -36,7 +36,6 @@ import sys import tarfile import tempfile import time -import urllib.request import webbrowser from pathlib import Path from typing import Any @@ -597,6 +596,33 @@ _LOG_PANEL_HTML = """\ var countEl = document.getElementById("mcp-log-count"); var openBtn = document.getElementById("mcp-log-open"); var resizeHandle = document.getElementById("mcp-log-resize"); + var allFilterKeys = ["tools", "notifications", "bridge", "errors"]; + + function syncURL() { + var params = new URLSearchParams(window.location.search); + if (!panel.classList.contains("hidden")) { + params.set("log", "open"); + } else { + params.delete("log"); + } + var on = []; + for (var i = 0; i < allFilterKeys.length; i++) { + if (activeFilters[allFilterKeys[i]]) on.push(allFilterKeys[i]); + } + if (on.length === allFilterKeys.length) { + params.delete("filters"); + } else { + params.set("filters", on.join(",")); + } + if (minLevel === 0) { + params.delete("level"); + } else { + params.set("level", levelOrder[minLevel]); + } + var qs = params.toString(); + var url = window.location.pathname + (qs ? "?" + qs : ""); + history.replaceState(null, "", url); + } function setFrameLayout(w) { var frame = document.getElementById("app-frame"); @@ -609,12 +635,15 @@ _LOG_PANEL_HTML = """\ panel.classList.add("hidden"); openBtn.style.display = "block"; setFrameLayout("100%"); + syncURL(); }); openBtn.addEventListener("click", function() { panel.classList.remove("hidden"); openBtn.style.display = "none"; setFrameLayout("calc(100% - " + panelWidth + "px)"); + entries.scrollTop = entries.scrollHeight; + syncURL(); }); resizeHandle.addEventListener("mousedown", function(e) { @@ -652,6 +681,34 @@ _LOG_PANEL_HTML = """\ var levelOrder = ["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]; var minLevel = 0; + // Restore state from URL params + (function restoreURL() { + var params = new URLSearchParams(window.location.search); + if (params.get("log") === "open") { + panel.classList.remove("hidden"); + openBtn.style.display = "none"; + setFrameLayout("calc(100% - " + panelWidth + "px)"); + } + var fp = params.get("filters"); + if (fp !== null) { + var on = fp ? fp.split(",") : []; + for (var i = 0; i < allFilterKeys.length; i++) { + var k = allFilterKeys[i]; + activeFilters[k] = on.indexOf(k) !== -1; + var btn = document.querySelector("[data-filter='" + k + "']"); + if (btn) btn.classList.toggle("active", activeFilters[k]); + } + } + var lp = params.get("level"); + if (lp) { + var idx = levelOrder.indexOf(lp); + if (idx >= 0) { + minLevel = idx; + document.getElementById("mcp-log-level-select").value = lp; + } + } + })(); + document.getElementById("mcp-log-filters").addEventListener("click", function(e) { var btn = e.target.closest("[data-filter]"); if (!btn) return; @@ -659,11 +716,13 @@ _LOG_PANEL_HTML = """\ activeFilters[f] = !activeFilters[f]; btn.classList.toggle("active", activeFilters[f]); applyFilters(); + syncURL(); }); document.getElementById("mcp-log-level-select").addEventListener("change", function(e) { minLevel = levelOrder.indexOf(e.target.value); applyFilters(); + syncURL(); }); function shouldShow(el) { @@ -799,6 +858,7 @@ _LOG_PANEL_HTML = """\ } var polling = false; + var firstPoll = true; function poll() { if (polling) return; polling = true; @@ -809,14 +869,16 @@ _LOG_PANEL_HTML = """\ lastId = data[data.length - 1].id; totalCount += data.length; countEl.textContent = String(totalCount); - var atBottom = entries.scrollHeight - entries.scrollTop - entries.clientHeight < 40; + var panelVisible = !panel.classList.contains("hidden"); + var atBottom = !panelVisible || entries.scrollHeight - entries.scrollTop - entries.clientHeight < 40; for (var i = 0; i < data.length; i++) { var el = renderEntry(data[i]); el.classList.add("new"); if (!shouldShow(el)) el.style.display = "none"; entries.appendChild(el); } - if (atBottom) entries.scrollTop = entries.scrollHeight; + if (atBottom || (firstPoll && panelVisible)) entries.scrollTop = entries.scrollHeight; + firstPoll = false; }) .catch(function() {}) .finally(function() { polling = false; }); @@ -1214,8 +1276,10 @@ def _fetch_app_bridge_bundle_sync( # -- Download and patch app-bridge.js ----------------------------------- npm_url = f"https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-{version}.tgz" - with urllib.request.urlopen(npm_url) as resp: - data = resp.read() + with httpx.Client(timeout=30.0) as client: + resp = client.get(npm_url, follow_redirects=True) + resp.raise_for_status() + data = resp.content with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar: member = tar.extractfile("package/dist/src/app-bridge.js") @@ -1237,8 +1301,10 @@ def _fetch_app_bridge_bundle_sync( # version-specific v4.mjs (e.g. /zod@4.3.6/es2022/v4.mjs) which is # broken. We fetch the wrapper to discover the exact version. types_url = f"{sdk_base}/types.js" - with urllib.request.urlopen(types_url) as resp: - types_content = resp.read().decode() + with httpx.Client(timeout=30.0) as client: + resp = client.get(types_url, follow_redirects=True) + resp.raise_for_status() + types_content = resp.text # Extract the zod/v4?target=es2022 path from the types.js redirect zod_wrapper_match = re.search(r'import "(/zod@[^"]*v4[^"]*)"', types_content) @@ -1249,8 +1315,10 @@ def _fetch_app_bridge_bundle_sync( zod_wrapper_path = zod_wrapper_match.group(1) # e.g. /zod@^4.3.5/v4?target=es2022 zod_wrapper_url = f"https://esm.sh{zod_wrapper_path}" - with urllib.request.urlopen(zod_wrapper_url) as resp: - wrapper_content = resp.read().decode() + with httpx.Client(timeout=30.0) as client: + resp = client.get(zod_wrapper_url, follow_redirects=True) + resp.raise_for_status() + wrapper_content = resp.text # The wrapper does: export * from "/zod@4.3.6/es2022/v4.mjs" broken_match = re.search( @@ -1377,7 +1445,12 @@ def _make_dev_app( for k, v in data.items(): if isinstance(v, str): stripped = v.strip() - if stripped and stripped[0] in ("{", "["): + # Skip empty strings — the form sends them for + # unfilled optional fields, but they'll fail + # validation against non-string types. + if not stripped: + continue + if stripped[0] in ("{", "["): try: parsed = json.loads(stripped) if isinstance(parsed, (dict, list)): @@ -1439,7 +1512,9 @@ def _make_dev_app( if k.lower() not in ("host", "content-length") } - client = httpx.AsyncClient(timeout=None) + # Use a reasonable default timeout to prevent the proxy from hanging + # if the backend server is unresponsive. + client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, read=None)) async def _stream_and_cleanup(resp: httpx.Response) -> Any: is_sse = "text/event-stream" in resp.headers.get("content-type", "") @@ -1468,6 +1543,7 @@ def _make_dev_app( except ( httpx.RemoteProtocolError, httpx.ReadError, + httpx.ReadTimeout, httpcore.RemoteProtocolError, ): pass # Connection closed during shutdown — not an error @@ -1508,7 +1584,7 @@ def _make_dev_app( headers=fwd_headers, media_type=content_type or "application/octet-stream", ) - except httpx.ConnectError: + except (httpx.ConnectError, httpx.ConnectTimeout): await client.aclose() return Response( content=json.dumps({"error": "MCP server not reachable"}).encode(), @@ -1635,7 +1711,10 @@ async def run_dev_apps( # Check ports before starting anything import socket - for port, label in [(mcp_port, "MCP server"), (dev_port, "dev UI")]: + for port, label, flag in [ + (mcp_port, "MCP server", "--mcp-port"), + (dev_port, "dev UI", "--dev-port"), + ]: in_use = False for family, addr in ( (socket.AF_INET, ("127.0.0.1", port)), @@ -1651,7 +1730,7 @@ async def run_dev_apps( if in_use: logger.error( f"Port {port} ({label}) is already in use. " - f"Try --mcp-port or --dev-port to use different ports." + f"Try {flag} to use a different port." ) sys.exit(1) diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 876da5826..3c11fa35f 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -521,13 +521,13 @@ async def run( # Warn about options that are ignored in module mode ignored_options: list[str] = [] - if transport: + if transport is not None: ignored_options.append("--transport") - if host: + if host is not None: ignored_options.append("--host") - if port: + if port is not None: ignored_options.append("--port") - if path: + if path is not None: ignored_options.append("--path") if ignored_options: logger.warning( @@ -601,11 +601,15 @@ async def run( sys.exit(1) # Get effective values (CLI overrides take precedence) - final_transport = transport or config.deployment.transport - final_host = host or config.deployment.host - final_port = port or config.deployment.port - final_path = path or config.deployment.path - final_log_level = log_level or config.deployment.log_level + final_transport = ( + transport if transport is not None else config.deployment.transport + ) + final_host = host if host is not None else config.deployment.host + final_port = port if port is not None else config.deployment.port + final_path = path if path is not None else config.deployment.path + final_log_level = ( + log_level if log_level is not None else config.deployment.log_level + ) final_server_args = server_args or config.deployment.args # Use CLI override if provided, otherwise use settings # no_banner CLI flag overrides the show_server_banner setting @@ -642,11 +646,11 @@ async def run( if final_transport: reload_cmd.extend(["--transport", final_transport]) if final_transport != "stdio": - if final_host: + if final_host is not None: reload_cmd.extend(["--host", final_host]) - if final_port: + if final_port is not None: reload_cmd.extend(["--port", str(final_port)]) - if final_path: + if final_path is not None: reload_cmd.extend(["--path", final_path]) if final_log_level: reload_cmd.extend(["--log-level", final_log_level]) @@ -691,11 +695,11 @@ async def run( inner_cmd.extend(["--transport", final_transport]) # Only add HTTP-specific options for non-stdio transports if final_transport != "stdio": - if final_host: + if final_host is not None: inner_cmd.extend(["--host", final_host]) - if final_port: + if final_port is not None: inner_cmd.extend(["--port", str(final_port)]) - if final_path: + if final_path is not None: inner_cmd.extend(["--path", final_path]) if final_log_level: inner_cmd.extend(["--log-level", final_log_level]) diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index 36b029c38..9c39a5dbd 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -15,6 +15,7 @@ from typing import Any, Literal from mcp.server.fastmcp import FastMCP as FastMCP1x from watchfiles import Change, awatch +import fastmcp from fastmcp.server.server import FastMCP, create_proxy from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config import ( @@ -181,11 +182,15 @@ async def run_command( config = load_mcp_server_config(config_path) # Merge deployment config with CLI arguments (CLI takes precedence) - transport = transport or config.deployment.transport - host = host or config.deployment.host - port = port or config.deployment.port - path = path or config.deployment.path - log_level = log_level or config.deployment.log_level + transport = ( + transport if transport is not None else config.deployment.transport + ) + host = host if host is not None else config.deployment.host + port = port if port is not None else config.deployment.port + path = path if path is not None else config.deployment.path + log_level = ( + log_level if log_level is not None else config.deployment.log_level + ) server_args = ( server_args if server_args is not None else config.deployment.args ) @@ -234,15 +239,22 @@ async def run_command( return kwargs = {} - if transport: + if transport is not None: kwargs["transport"] = transport - if host: - kwargs["host"] = host - if port: - kwargs["port"] = port - if path: - kwargs["path"] = path - if log_level: + # Resolve effective transport for the HTTP kwargs guard — transport + # may be None here if the user didn't pass --transport, in which case + # run_async will resolve it from settings.transport. + effective_transport = ( + transport if transport is not None else fastmcp.settings.transport + ) + if effective_transport != "stdio": + if host is not None: + kwargs["host"] = host + if port is not None: + kwargs["port"] = port + if path is not None: + kwargs["path"] = path + if log_level is not None: kwargs["log_level"] = log_level if stateless: kwargs["stateless"] = True @@ -310,9 +322,9 @@ async def run_v1_server_async( port: Port to bind to transport: Transport protocol to use """ - if host: + if host is not None: server.settings.host = host - if port: + if port is not None: server.settings.port = port match transport: diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index 64416393a..49f1a7653 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -279,8 +279,8 @@ class OAuth(OAuthClientProvider): warn( message="Using in-memory token storage -- tokens will be lost when the client restarts. " - + "For persistent storage across multiple MCP servers, provide an encrypted AsyncKeyValue backend. " - + "See https://gofastmcp.com/clients/auth/oauth#token-storage for details.", + "For persistent storage across multiple MCP servers, provide an encrypted AsyncKeyValue backend. " + "See https://gofastmcp.com/clients/auth/oauth#token-storage for details.", stacklevel=2, ) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 17fb7be90..c2004be7b 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -70,7 +70,6 @@ from .transports import ( PythonStdioTransport, SessionKwargs, SSETransport, - StdioTransport, StreamableHttpTransport, infer_transport, ) @@ -433,9 +432,25 @@ class Client( """ new_client = copy.copy(self) - if not isinstance(self.transport, StdioTransport): - # Reset session state to fresh state - new_client._session_state = ClientSessionState() + # Always reset session state so cloned clients start disconnected and do not + # share lifecycle state with the original instance. + new_client._session_state = ClientSessionState() + + # Reset mutable task tracking state so new client is independent + new_client._task_registry = {} + new_client._submitted_task_ids = set() + + # Create a fresh session kwargs dict so the clone doesn't share + # 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] + if isinstance( + self._session_kwargs.get("message_handler"), TaskNotificationHandler + ): + new_client._session_kwargs["message_handler"] = TaskNotificationHandler( + new_client + ) new_client.name += f":{secrets.token_hex(2)}" @@ -642,10 +657,19 @@ class Client( # stop the active session if self._session_state.session_task is None: return + session_task = self._session_state.session_task self._session_state.stop_event.set() # wait for session to finish to ensure state has been reset - await self._session_state.session_task - self._session_state.session_task = None + try: + if force: + with anyio.CancelScope(shield=True): + with anyio.move_on_after(self._disconnect_timeout): + with suppress(asyncio.CancelledError): + await session_task + else: + await session_task + finally: + self._session_state.session_task = None async def _session_runner(self): """ diff --git a/src/fastmcp/client/elicitation.py b/src/fastmcp/client/elicitation.py index 60545a744..0bfa9a203 100644 --- a/src/fastmcp/client/elicitation.py +++ b/src/fastmcp/client/elicitation.py @@ -61,10 +61,18 @@ def create_elicitation_callback( result = ElicitResult(action="accept", content=result) content = to_jsonable_python(result.content) if not isinstance(content, dict | None): - raise ValueError( - "Elicitation responses must be serializable as a JSON object (dict). Received: " - f"{result.content!r}" - ) + # Auto-wrap scalar values for ScalarElicitationType schemas + # (single "value" property). This lets handlers return T directly + # for ctx.elicit("msg", str/int/float/bool). + if isinstance(params, ElicitRequestFormParams) and set( + params.requestedSchema.get("properties", {}).keys() + ) == {"value"}: + content = {"value": content} + else: + raise ValueError( + "Elicitation responses must be serializable as a JSON object (dict). Received: " + f"{result.content!r}" + ) return MCPElicitResult( _meta=result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] action=result.action, diff --git a/src/fastmcp/client/mixins/tools.py b/src/fastmcp/client/mixins/tools.py index d6c37a5bd..aec595019 100644 --- a/src/fastmcp/client/mixins/tools.py +++ b/src/fastmcp/client/mixins/tools.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid import weakref -from typing import TYPE_CHECKING, Any, Literal, cast, overload +from typing import TYPE_CHECKING, Any, Literal, overload import mcp.types from pydantic import RootModel @@ -386,9 +386,12 @@ async def _parse_call_tool_result( data = None if result.isError and raise_on_error: - msg = cast(mcp.types.TextContent, result.content[0]).text + if result.content and isinstance(result.content[0], mcp.types.TextContent): + msg = result.content[0].text + else: + msg = f"Tool '{name}' returned an error" raise ToolError(msg) - elif result.structuredContent: + elif result.structuredContent and not result.isError: try: raw_fastmcp_meta = (result.meta or {}).get("fastmcp") fastmcp_meta = ( diff --git a/src/fastmcp/client/sampling/handlers/anthropic.py b/src/fastmcp/client/sampling/handlers/anthropic.py index b7a6ce090..0939121d1 100644 --- a/src/fastmcp/client/sampling/handlers/anthropic.py +++ b/src/fastmcp/client/sampling/handlers/anthropic.py @@ -235,6 +235,10 @@ class AnthropicSamplingHandler: is_error=item.isError if item.isError else False, ) ) + else: + raise ValueError( + f"Unsupported content type for Anthropic: {type(item).__name__}" + ) if content_blocks: anthropic_messages.append( diff --git a/src/fastmcp/client/sampling/handlers/google_genai.py b/src/fastmcp/client/sampling/handlers/google_genai.py index ad1a3d1e8..614138722 100644 --- a/src/fastmcp/client/sampling/handlers/google_genai.py +++ b/src/fastmcp/client/sampling/handlers/google_genai.py @@ -144,15 +144,19 @@ class GoogleGenaiSamplingHandler: def _convert_tool_to_google_genai(tool: MCPTool) -> GoogleTool: """Convert an MCP Tool to Google GenAI format. - Google's parameters_json_schema accepts standard JSON Schema format, - so we pass tool.inputSchema directly without conversion. + We prune ``title`` fields from the schema because Gemini 2.5 Flash + produces ``MALFORMED_FUNCTION_CALL`` when Pydantic's auto-generated + title annotations are present. """ + from fastmcp.utilities.json_schema import compress_schema + + schema = compress_schema(tool.inputSchema, prune_titles=True) return GoogleTool( function_declarations=[ FunctionDeclaration( name=tool.name, description=tool.description or "", - parameters_json_schema=tool.inputSchema, + parameters_json_schema=schema, ) ] ) @@ -318,7 +322,18 @@ def _response_to_create_message_result( """Convert Google GenAI response to CreateMessageResult (no tools).""" if not (text := response.text): candidate = _get_candidate_from_response(response) - msg = f"No content in response: {candidate.finish_reason}" + # Check if the response only contained thinking + has_thoughts = ( + candidate.content + and candidate.content.parts + and all(getattr(p, "thought", False) for p in candidate.content.parts) + ) + if has_thoughts: + msg = ( + "Model returned only thinking/reasoning content with no response text." + ) + else: + msg = f"No content in response (finish_reason={candidate.finish_reason})" raise ValueError(msg) return CreateMessageResult( @@ -361,7 +376,7 @@ def _response_to_result_with_tools( if candidate.content and candidate.content.parts: for part in candidate.content.parts: # Note: Skip thought parts from thinking_config - not relevant for MCP responses - if part.text: + if part.text and not part.thought: content.append(TextContent(type="text", text=part.text)) elif part.function_call is not None: fc = part.function_call @@ -376,7 +391,9 @@ def _response_to_result_with_tools( ) if not content: - raise ValueError("No content in response from completion") + finish = candidate.finish_reason if candidate else "unknown" + msg = f"No content in response from completion (finish_reason={finish})" + raise ValueError(msg) return CreateMessageResultWithTools( content=content, diff --git a/src/fastmcp/client/sampling/handlers/openai.py b/src/fastmcp/client/sampling/handlers/openai.py index ffc40f158..bbd6a0ae5 100644 --- a/src/fastmcp/client/sampling/handlers/openai.py +++ b/src/fastmcp/client/sampling/handlers/openai.py @@ -243,6 +243,10 @@ class OpenAISamplingHandler: content=content_text, ) ) + else: + raise ValueError( + f"Unsupported content type for OpenAI: {type(item).__name__}" + ) # Add assistant message with tool calls if present # OpenAI requires: assistant (with tool_calls) -> tool messages diff --git a/src/fastmcp/client/tasks.py b/src/fastmcp/client/tasks.py index ae6b0ad98..60166c5ca 100644 --- a/src/fastmcp/client/tasks.py +++ b/src/fastmcp/client/tasks.py @@ -216,8 +216,8 @@ class Task(abc.ABC, Generic[TaskResultT]): on status changes when server sends notifications/tasks/status. Args: - state: Desired state ('submitted', 'working', 'completed', 'failed'). - If None, waits for any terminal state (completed/failed) + 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: @@ -237,7 +237,7 @@ class Task(abc.ABC, Generic[TaskResultT]): self._status_event = asyncio.Event() start = time.time() - terminal_states = {"completed", "failed", "cancelled"} + in_progress_states = {"working"} poll_interval = 0.5 # Fallback polling interval (500ms) while True: @@ -245,7 +245,7 @@ class Task(abc.ABC, Generic[TaskResultT]): if self._status_cache: current = self._status_cache.status if state is None: - if current in terminal_states: + if current not in in_progress_states: return self._status_cache elif current == state: return self._status_cache @@ -269,6 +269,21 @@ class Task(abc.ABC, Generic[TaskResultT]): # 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. @@ -354,7 +369,7 @@ class ToolTask(Task["CallToolResult"]): self._check_client_connected() # Wait for completion using event-based wait (respects notifications) - await self.wait() + await self._wait_terminal() # Get the raw result (dict or CallToolResult) raw_result = await self._client.get_task_result(self._task_id) @@ -445,7 +460,7 @@ class PromptTask(Task[mcp.types.GetPromptResult]): self._check_client_connected() # Wait for completion using event-based wait (respects notifications) - await self.wait() + await self._wait_terminal() # Get the raw MCP result mcp_result = await self._client.get_task_result(self._task_id) @@ -517,7 +532,7 @@ class ResourceTask( self._check_client_connected() # Wait for completion using event-based wait (respects notifications) - await self.wait() + await self._wait_terminal() # Get the raw MCP result mcp_result = await self._client.get_task_result(self._task_id) diff --git a/src/fastmcp/client/transports/http.py b/src/fastmcp/client/transports/http.py index 5ff52a091..bfed0fa00 100644 --- a/src/fastmcp/client/transports/http.py +++ b/src/fastmcp/client/transports/http.py @@ -94,6 +94,8 @@ class StreamableHttpTransport(ClientTransport): ) self.sse_read_timeout = normalize_timeout_to_timedelta(sse_read_timeout) + self.forward_incoming_headers: bool = False + self._get_session_id_cb: Callable[[], str | None] | None = None def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): @@ -148,10 +150,14 @@ class StreamableHttpTransport(ClientTransport): async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - # Load headers from an active HTTP request, if available. This will only be true - # if the client is used in a FastMCP Proxy, in which case the MCP client headers - # need to be forwarded to the remote server. - headers = get_http_headers(include={"authorization"}) | self.headers + # 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 self.forward_incoming_headers: + headers = get_http_headers(include={"authorization"}) | self.headers + else: + headers = dict(self.headers) # Configure timeout if provided, preserving MCP's 30s connect default timeout: httpx.Timeout | None = None diff --git a/src/fastmcp/client/transports/sse.py b/src/fastmcp/client/transports/sse.py index fa900eb7b..43bdb7c96 100644 --- a/src/fastmcp/client/transports/sse.py +++ b/src/fastmcp/client/transports/sse.py @@ -61,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: httpx.Auth | Literal["oauth"] | str | None): @@ -117,12 +119,16 @@ class SSETransport(ClientTransport): ) -> AsyncIterator[ClientSession]: client_kwargs: dict[str, Any] = {} - # load headers from an active HTTP request, if available. This will only be true - # if the client is used in a FastMCP Proxy, in which case the MCP client headers - # need to be forwarded to the remote server. - client_kwargs["headers"] = ( - get_http_headers(include={"authorization"}) | self.headers - ) + # 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 self.forward_incoming_headers: + client_kwargs["headers"] = ( + get_http_headers(include={"authorization"}) | self.headers + ) + else: + client_kwargs["headers"] = dict(self.headers) # sse_read_timeout has a default value set, so we can't pass None without overriding it # instead we simply leave the kwarg out if it's not provided diff --git a/src/fastmcp/experimental/transforms/code_mode.py b/src/fastmcp/experimental/transforms/code_mode.py index 57644239c..09fb61e07 100644 --- a/src/fastmcp/experimental/transforms/code_mode.py +++ b/src/fastmcp/experimental/transforms/code_mode.py @@ -1,7 +1,10 @@ import importlib import json from collections.abc import Awaitable, Callable, Sequence -from typing import Annotated, Any, Literal, Protocol +from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol + +if TYPE_CHECKING: + from pydantic_monty import ResourceLimits from mcp.types import TextContent from pydantic import Field @@ -102,7 +105,7 @@ class MontySandboxProvider: def __init__( self, *, - limits: dict[str, Any] | None = None, + limits: "ResourceLimits | None" = None, ) -> None: self.limits = limits @@ -127,16 +130,12 @@ class MontySandboxProvider: for key, value in (external_functions or {}).items() } - monty = pydantic_monty.Monty( - code, - inputs=list(inputs.keys()), + monty = pydantic_monty.Monty(code, inputs=list(inputs)) + return await monty.run_async( + inputs=inputs or None, + external_functions=async_functions or None, + limits=self.limits, ) - run_kwargs: dict[str, Any] = {"external_functions": async_functions} - if inputs: - run_kwargs["inputs"] = inputs - if self.limits is not None: - run_kwargs["limits"] = self.limits - return await pydantic_monty.run_monty_async(monty, **run_kwargs) # --------------------------------------------------------------------------- diff --git a/src/fastmcp/mcp_config.py b/src/fastmcp/mcp_config.py index d4dbf2df5..8f9f836fc 100644 --- a/src/fastmcp/mcp_config.py +++ b/src/fastmcp/mcp_config.py @@ -322,7 +322,7 @@ class MCPConfig(BaseModel): def from_file(cls, file_path: Path) -> Self: """Load configuration from JSON file.""" if file_path.exists() and (content := file_path.read_text().strip()): - return cls.model_validate_json(content) + return cls.model_validate_json(content) # ty: ignore[possibly-unresolved-reference] raise ValueError(f"No MCP servers defined in the config: {file_path}") diff --git a/src/fastmcp/prompts/base.py b/src/fastmcp/prompts/base.py index f7d8321d7..db399bde2 100644 --- a/src/fastmcp/prompts/base.py +++ b/src/fastmcp/prompts/base.py @@ -386,7 +386,7 @@ class Prompt(FastMCPComponent): fn_key: str | None = None, task_key: str | None = None, **kwargs: Any, - ) -> Execution: # ty:ignore[invalid-method-override] + ) -> Execution: """Schedule this prompt for background execution via docket. Args: diff --git a/src/fastmcp/prompts/function_prompt.py b/src/fastmcp/prompts/function_prompt.py index b5ddd3425..77b38e49d 100644 --- a/src/fastmcp/prompts/function_prompt.py +++ b/src/fastmcp/prompts/function_prompt.py @@ -36,6 +36,7 @@ from fastmcp.utilities.async_utils import ( call_sync_fn_in_threadpool, is_coroutine_function, ) +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.types import get_cached_typeadapter @@ -152,7 +153,9 @@ class FunctionPrompt(Prompt): if param.kind == inspect.Parameter.VAR_KEYWORD: raise ValueError("Functions with **kwargs are not supported as prompts") - description = metadata.description or inspect.getdoc(fn) + # Parse the outer docstring (before unwrapping) to preserve the class + # docstring as the prompt description for callable class instances. + outer_docstring = parse_docstring(fn) # Normalize task to TaskConfig and validate task_value = metadata.task @@ -171,6 +174,24 @@ class FunctionPrompt(Prompt): if isinstance(fn, staticmethod): fn = fn.__func__ + # For callable classes, argument descriptions must come from + # __call__'s docstring — where the exposed parameters are actually + # declared. The class docstring's Args section, if any, typically + # describes __init__, so falling back to it would risk injecting + # constructor docs into __call__'s arguments on overlapping names. + # The description, however, comes from the class docstring (which + # describes what the prompt IS) when present. + inner_docstring = parse_docstring(fn) + parsed_docstring = ParsedDocstring( + description=outer_docstring.description or inner_docstring.description, + parameters=inner_docstring.parameters, + ) + description = ( + metadata.description + if metadata.description is not None + else parsed_docstring.description + ) + # Transform Context type annotations to Depends() for unified DI fn = transform_context_annotations(fn) @@ -180,6 +201,18 @@ class FunctionPrompt(Prompt): parameters = type_adapter.json_schema() parameters = compress_schema(parameters, prune_titles=True) + # Inject parameter descriptions from the docstring into the schema. + # Explicit annotations (Field(description=...), Annotated[x, "..."]) + # already have a "description" key and take precedence. + if parsed_docstring.parameters: + properties = parameters.get("properties", {}) + for param_name, param_desc in parsed_docstring.parameters.items(): + if ( + param_name in properties + and "description" not in properties[param_name] + ): + properties[param_name]["description"] = param_desc + # Convert parameters to PromptArguments arguments: list[PromptArgument] = [] if "properties" in parameters: @@ -330,14 +363,10 @@ class FunctionPrompt(Prompt): return self.convert_result(result) except Exception as e: logger.exception(f"Error rendering prompt {self.name}") - raise PromptError(f"Error rendering prompt {self.name}.") from e + 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. - - FunctionPrompt registers the underlying function, which has the user's - Depends parameters for docket to resolve. - """ + """Register this prompt with docket for background execution.""" if not self.task_config.supports_tasks(): return docket.register(self.fn, names=[self.key]) diff --git a/src/fastmcp/resources/base.py b/src/fastmcp/resources/base.py index 467ba1898..70d422dab 100644 --- a/src/fastmcp/resources/base.py +++ b/src/fastmcp/resources/base.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import json from collections.abc import Callable from typing import TYPE_CHECKING, Annotated, Any, ClassVar, overload @@ -188,6 +189,12 @@ class ResourceResult(pydantic.BaseModel): f"Use ResourceContent({item!r}) to wrap the value." ) return contents + # Auto-serialize JSON-native types to JSON text + if ( + isinstance(contents, dict | list | tuple | int | float | bool) + or contents is None + ): + return [ResourceContent(json.dumps(contents), mime_type="application/json")] raise TypeError( f"contents must be str, bytes, or list[ResourceContent], got {type(contents).__name__}" ) @@ -329,7 +336,30 @@ class Resource(FastMCPComponent): [ResourceContent(raw_value, mime_type=self.mime_type, meta=self.meta)] ) - # ResourceResult.__init__ handles all other normalization + # 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 @@ -413,7 +443,7 @@ class Resource(FastMCPComponent): fn_key: str | None = None, task_key: str | None = None, **kwargs: Any, - ) -> Execution: # ty:ignore[invalid-method-override] + ) -> Execution: """Schedule this resource for background execution via docket. Args: diff --git a/src/fastmcp/resources/function_resource.py b/src/fastmcp/resources/function_resource.py index 771eeb7cb..df542fcce 100644 --- a/src/fastmcp/resources/function_resource.py +++ b/src/fastmcp/resources/function_resource.py @@ -196,7 +196,9 @@ class FunctionResource(Resource): name=func_name, version=str(metadata.version) if metadata.version is not None else None, title=metadata.title, - description=metadata.description or inspect.getdoc(fn), + description=metadata.description + if metadata.description is not None + else inspect.getdoc(fn), icons=metadata.icons, mime_type=resolved_mime or "text/plain", tags=metadata.tags or set(), @@ -228,11 +230,7 @@ class FunctionResource(Resource): return result def register_with_docket(self, docket: Docket) -> None: - """Register this resource with docket for background execution. - - FunctionResource registers the underlying function, which has the user's - Depends parameters for docket to resolve. - """ + """Register this resource with docket for background execution.""" if not self.task_config.supports_tasks(): return docket.register(self.fn, names=[self.key]) diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index d1e0981c0..53ea6a15e 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -7,7 +7,7 @@ import inspect import re from collections.abc import Callable from typing import TYPE_CHECKING, Any, ClassVar, overload -from urllib.parse import parse_qs, unquote +from urllib.parse import parse_qs, quote, unquote import mcp.types from mcp.types import Annotations, Icon @@ -109,6 +109,38 @@ def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None: return params +def expand_uri_template(uri_template: str, params: dict[str, Any]) -> str: + """Expand a URI template with parameters — inverse of `match_uri_template`. + + Supports the same RFC 6570 subset: + - Path params: `{var}`, `{var*}` + - Query params: `{?var1,var2}` + """ + result = uri_template + + # Replace {name} and {name*} path placeholders + for key, value in params.items(): + value_str = str(value) + result = result.replace(f"{{{key}}}", value_str) + result = result.replace(f"{{{key}*}}", value_str) + + # Expand {?param1,param2,...} query parameter blocks + def _expand_query_block(match: re.Match[str]) -> str: + names = [n.strip() for n in match.group(1).split(",")] + parts = [ + f"{quote(name)}={quote(str(params[name]))}" + for name in names + if name in params + ] + if parts: + return "?" + "&".join(parts) + return "" + + result = re.sub(r"\{\?([^}]+)\}", _expand_query_block, result) + + return result + + class ResourceTemplate(FastMCPComponent): """A template for dynamically creating resources.""" @@ -312,7 +344,7 @@ class ResourceTemplate(FastMCPComponent): fn_key: str | None = None, task_key: str | None = None, **kwargs: Any, - ) -> Execution: # ty:ignore[invalid-method-override] + ) -> Execution: """Schedule this template for background execution via docket. Args: @@ -439,11 +471,7 @@ class FunctionResourceTemplate(ResourceTemplate): return result def register_with_docket(self, docket: Docket) -> None: - """Register this template with docket for background execution. - - FunctionResourceTemplate registers the underlying function, which has the - user's Depends parameters for docket to resolve. - """ + """Register this template with docket for background execution.""" if not self.task_config.supports_tasks(): return docket.register(self.fn, names=[self.key]) @@ -557,7 +585,7 @@ class FunctionResourceTemplate(ResourceTemplate): f"URI parameters {all_uri_params} must be a subset of the function arguments: {func_params}" ) - description = description or inspect.getdoc(fn) + description = description if description is not None else inspect.getdoc(fn) # Normalize task to TaskConfig and validate if task is None: diff --git a/src/fastmcp/server/app.py b/src/fastmcp/server/app.py index d097117d4..ad15d59b1 100644 --- a/src/fastmcp/server/app.py +++ b/src/fastmcp/server/app.py @@ -8,7 +8,7 @@ import warnings from fastmcp.apps.app import FastMCPApp as FastMCPApp from fastmcp.apps.app import _dispatch_decorator as _dispatch_decorator -from fastmcp.apps.app import _resolve_tool_ref as _resolve_tool_ref +from fastmcp.apps.app import _make_resolver as _make_resolver from fastmcp.exceptions import FastMCPDeprecationWarning warnings.warn( diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index c7141feec..852f440a4 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -217,6 +217,7 @@ class AuthProvider(TokenVerifierProtocol): self, base_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | None = None, + resource_base_url: AnyHttpUrl | str | None = None, ): """ Initialize the auth provider. @@ -224,11 +225,21 @@ class AuthProvider(TokenVerifierProtocol): Args: base_url: The base URL of this server (e.g., http://localhost:8000). This is used for constructing .well-known endpoints and OAuth metadata. + resource_base_url: Optional public base URL for the protected resource. + When provided, the resource URL advertised in protected resource + metadata (RFC 9728) is derived from this URL instead of ``base_url``, + while operational OAuth routes remain rooted at ``base_url``. + Providers that mint their own downstream tokens (e.g. ``OAuthProxy``) + also use this as the minted token audience. Upstream token audience + validation is configured separately on the token verifier. required_scopes: List of OAuth scopes required for all requests. """ if isinstance(base_url, str): base_url = AnyHttpUrl(base_url) + if isinstance(resource_base_url, str): + resource_base_url = AnyHttpUrl(resource_base_url) self.base_url = base_url + self.resource_base_url = resource_base_url self.required_scopes = required_scopes or [] self._mcp_path: str | None = None self._resource_url: AnyHttpUrl | None = None @@ -321,32 +332,35 @@ class AuthProvider(TokenVerifierProtocol): Returns: List of Starlette Middleware instances to apply to the HTTP app """ - # TODO(ty): remove type ignores when ty supports Starlette Middleware typing return [ Middleware( - AuthenticationMiddleware, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + AuthenticationMiddleware, # type: ignore[arg-type] backend=BearerAuthBackend(self), ), - Middleware(AuthContextMiddleware), # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + Middleware(AuthContextMiddleware), # type: ignore[arg-type] ] def _get_resource_url(self, path: str | None = None) -> AnyHttpUrl | None: """Get the actual resource URL being protected. + Uses ``resource_base_url`` if set; otherwise falls back to + ``base_url``. + Args: path: The path where the resource endpoint is mounted (e.g., "/mcp") Returns: The full URL of the protected resource """ - if self.base_url is None: + resource_base_url = self.resource_base_url or self.base_url + if resource_base_url is None: return None if path: - prefix = str(self.base_url).rstrip("/") + prefix = str(resource_base_url).rstrip("/") suffix = path.lstrip("/") return AnyHttpUrl(f"{prefix}/{suffix}") - return self.base_url + return resource_base_url class TokenVerifier(AuthProvider): @@ -360,15 +374,25 @@ class TokenVerifier(AuthProvider): self, base_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | None = None, + resource_base_url: AnyHttpUrl | str | None = None, ): """ Initialize the token verifier. Args: base_url: The base URL of this server + resource_base_url: Optional public base URL for the protected resource. + When provided, the resource URL advertised in protected resource + metadata is derived from this URL instead of ``base_url``. Does not + configure upstream token audience validation — set ``audience`` on + your verifier to match. required_scopes: Scopes that are required for all requests """ - super().__init__(base_url=base_url, required_scopes=required_scopes) + super().__init__( + base_url=base_url, + resource_base_url=resource_base_url, + required_scopes=required_scopes, + ) @property def scopes_supported(self) -> list[str]: @@ -407,6 +431,7 @@ class RemoteAuthProvider(AuthProvider): authorization_servers: list[AnyHttpUrl], base_url: AnyHttpUrl | str, scopes_supported: list[str] | None = None, + resource_base_url: AnyHttpUrl | str | None = None, resource_name: str | None = None, resource_documentation: AnyHttpUrl | None = None, ): @@ -416,6 +441,12 @@ class RemoteAuthProvider(AuthProvider): token_verifier: TokenVerifier instance for token validation authorization_servers: List of authorization servers that issue valid tokens base_url: The base URL of this server + resource_base_url: Optional public base URL for the protected resource. + When provided, the resource URL advertised in protected resource + metadata is derived from this URL instead of ``base_url``. Does not + configure the token verifier's audience — set ``audience`` on the + verifier to match if you want validated tokens bound to the same + resource. scopes_supported: Scopes to advertise in OAuth metadata. If None, uses the token verifier's scopes_supported property. Use this when the scopes clients request differ from the scopes that @@ -425,6 +456,7 @@ class RemoteAuthProvider(AuthProvider): """ super().__init__( base_url=base_url, + resource_base_url=resource_base_url, required_scopes=token_verifier.required_scopes, ) self.token_verifier = token_verifier @@ -445,6 +477,12 @@ class RemoteAuthProvider(AuthProvider): Creates protected resource metadata routes (RFC 9728). """ + # Lifecycle hook: let subclasses react to the mcp_path becoming known + # (e.g., bind token audience to the resource URL). Mirrors the call in + # OAuthAuthorizationServerProvider.get_routes so all providers see the + # path at the same point in their lifecycle. + self.set_mcp_path(mcp_path) + routes = [] # Get the resource URL based on the MCP path @@ -498,6 +536,7 @@ class MultiAuth(AuthProvider): server: AuthProvider | None = None, verifiers: list[TokenVerifier] | TokenVerifier | None = None, base_url: AnyHttpUrl | str | None = None, + resource_base_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | None = None, ): """Initialize the multi-auth provider. @@ -508,6 +547,8 @@ class MultiAuth(AuthProvider): the first verifier tried. verifiers: One or more token verifiers to try after the server. base_url: Override the base URL. Defaults to the server's base_url. + resource_base_url: Override the protected resource base URL. Defaults + to the server's resource_base_url when available. required_scopes: Override required scopes. Defaults to the server's. """ if verifiers is None: @@ -519,16 +560,29 @@ class MultiAuth(AuthProvider): raise ValueError("MultiAuth requires at least a server or one verifier") effective_base_url = base_url or (server.base_url if server else None) + effective_resource_base_url = resource_base_url or ( + server.resource_base_url if server else None + ) effective_scopes = ( required_scopes if required_scopes is not None else (server.required_scopes if server else None) ) - super().__init__(base_url=effective_base_url, required_scopes=effective_scopes) + super().__init__( + base_url=effective_base_url, + resource_base_url=effective_resource_base_url, + required_scopes=effective_scopes, + ) self.server = server self.verifiers = list(verifiers) + # If an explicit resource_base_url override was passed to MultiAuth, + # propagate it to the wrapped server so its routes advertise metadata + # consistent with the outer auth challenge URL. + if resource_base_url is not None and self.server is not None: + self.server.resource_base_url = self.resource_base_url + self._sources: list[AuthProvider] = [] if self.server is not None: self._sources.append(self.server) @@ -594,6 +648,7 @@ class OAuthProvider( self, *, base_url: AnyHttpUrl | str, + 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, @@ -605,6 +660,9 @@ class OAuthProvider( Args: base_url: The public URL of this FastMCP server + resource_base_url: Optional public base URL for the protected resource. + When provided, the protected resource metadata and token audience are + derived from this URL instead of ``base_url``. issuer_url: The issuer URL for OAuth metadata (defaults to base_url) service_documentation_url: The URL of the service documentation. client_registration_options: The client registration options. @@ -612,7 +670,11 @@ class OAuthProvider( required_scopes: Scopes that are required for all requests. """ - super().__init__(base_url=base_url, required_scopes=required_scopes) + super().__init__( + base_url=base_url, + resource_base_url=resource_base_url, + required_scopes=required_scopes, + ) if issuer_url is None: self.issuer_url = self.base_url diff --git a/src/fastmcp/server/auth/handlers/__init__.py b/src/fastmcp/server/auth/handlers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastmcp/server/auth/jwt_issuer.py b/src/fastmcp/server/auth/jwt_issuer.py index 4e17eac60..78635b90c 100644 --- a/src/fastmcp/server/auth/jwt_issuer.py +++ b/src/fastmcp/server/auth/jwt_issuer.py @@ -246,7 +246,7 @@ class JWTIssuer: # Validate expiration exp = payload.get("exp") - if exp and exp < time.time(): + if exp is not None and exp < time.time(): logger.debug("Token expired") raise JoseError("Token has expired") diff --git a/src/fastmcp/server/auth/oauth_proxy/consent.py b/src/fastmcp/server/auth/oauth_proxy/consent.py index b2ad20642..01b28baf3 100644 --- a/src/fastmcp/server/auth/oauth_proxy/consent.py +++ b/src/fastmcp/server/auth/oauth_proxy/consent.py @@ -29,6 +29,10 @@ from fastmcp.utilities.ui import create_secure_html_response if TYPE_CHECKING: from fastmcp.server.auth.oauth_proxy.proxy import OAuthProxy +# Maximum number of remembered client approvals/denials stored in cookies. +# Keeps the Cookie header bounded to avoid hitting reverse proxy header limits. +_MAX_REMEMBERED_CLIENTS = 25 + logger = get_logger(__name__) @@ -285,8 +289,9 @@ class ConsentMixin: query_params["code_challenge_method"] = "S256" # Forward resource indicator if present in transaction - if resource := transaction.get("resource"): - query_params["resource"] = resource + if self._forward_resource: + if resource := transaction.get("resource"): + query_params["resource"] = resource # Extra configured parameters if self._extra_authorize_params: @@ -461,10 +466,12 @@ class ConsentMixin: client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"]) if action == "approve": - approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS")) - if client_key not in approved: - approved.add(client_key) - approved_b64 = self._encode_list_cookie(sorted(approved)) + approved = list(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS")) + if client_key in approved: + approved.remove(client_key) + approved.append(client_key) + approved = approved[-_MAX_REMEMBERED_CLIENTS:] + approved_b64 = self._encode_list_cookie(approved) consent_token = secrets.token_urlsafe(32) txn_model.consent_token = consent_token @@ -483,10 +490,12 @@ class ConsentMixin: return response elif action == "deny": - denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS")) - if client_key not in denied: - denied.add(client_key) - denied_b64 = self._encode_list_cookie(sorted(denied)) + denied = list(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS")) + if client_key in denied: + denied.remove(client_key) + denied.append(client_key) + denied = denied[-_MAX_REMEMBERED_CLIENTS:] + denied_b64 = self._encode_list_cookie(denied) callback_params = { "error": "access_denied", diff --git a/src/fastmcp/server/auth/oauth_proxy/proxy.py b/src/fastmcp/server/auth/oauth_proxy/proxy.py index b41c9d318..1b0baead2 100644 --- a/src/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy/proxy.py @@ -240,6 +240,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): token_verifier: TokenVerifier, # FastMCP server configuration base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, redirect_path: str | None = None, issuer_url: AnyHttpUrl | str | None = None, service_documentation_url: AnyHttpUrl | str | None = None, @@ -248,6 +249,8 @@ class OAuthProxy(OAuthProvider, ConsentMixin): valid_scopes: list[str] | None = None, # PKCE configuration forward_pkce: bool = True, + # Resource indicator (RFC 8707) + forward_resource: bool = True, # Token endpoint authentication token_endpoint_auth_method: str | None = None, # Extra parameters to forward to authorization endpoint @@ -279,6 +282,8 @@ class OAuthProxy(OAuthProvider, ConsentMixin): token_verifier: Token verifier for validating access tokens base_url: Public URL of the server that exposes this FastMCP server; redirect path is relative to this URL + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback") issuer_url: Issuer URL for OAuth metadata (defaults to base_url) service_documentation_url: Optional service documentation URL @@ -341,6 +346,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): super().__init__( base_url=base_url, + resource_base_url=resource_base_url, issuer_url=issuer_url, service_documentation_url=service_documentation_url, client_registration_options=client_registration_options, @@ -358,7 +364,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): else None ) self._upstream_revocation_endpoint: str | None = upstream_revocation_endpoint - self._default_scope_str: str = " ".join(self.required_scopes or []) + self._default_scope_str: str = " ".join( + valid_scopes or self.required_scopes or [] + ) # Store redirect configuration if not redirect_path: @@ -374,7 +382,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): ): logger.warning( "allowed_client_redirect_uris is empty list; no redirect URIs will be accepted. " - + "This will block all OAuth clients." + "This will block all OAuth clients." ) self._allowed_client_redirect_uris: list[str] | None = ( allowed_client_redirect_uris @@ -382,6 +390,8 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # PKCE configuration self._forward_pkce: bool = forward_pkce + # Resource indicator (RFC 8707) + self._forward_resource: bool = forward_resource # Token endpoint authentication self._token_endpoint_auth_method: str | None = token_endpoint_auth_method @@ -398,7 +408,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): elif not require_authorization_consent: logger.warning( "Authorization consent screen disabled - only use for local development or testing. " - + "In production, this screen protects against confused deputy attacks." + "In production, this screen protects against confused deputy attacks." ) # Extra parameters for authorization and token endpoints @@ -425,7 +435,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): if len(jwt_signing_key) < 12: logger.warning( "jwt_signing_key is less than 12 characters; it is recommended to use a longer. " - + "string for the key derivation." + "string for the key derivation." ) jwt_signing_key = derive_jwt_key( low_entropy_material=jwt_signing_key, @@ -658,7 +668,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): client = await self._client_store.get(key=client_id) if client is not None: - if client.allowed_redirect_uri_patterns is None: + if self._allowed_client_redirect_uris is not None: client.allowed_redirect_uri_patterns = ( self._allowed_client_redirect_uris ) @@ -1567,6 +1577,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # 1. Verify FastMCP JWT signature and claims payload = self.jwt_issuer.verify_token(token) jti = payload["jti"] + upstream_claims = payload.get("upstream_claims") # 2. Look up upstream token via JTI mapping jti_mapping = await self._jti_mapping_store.get(key=jti) @@ -1689,6 +1700,17 @@ class OAuthProxy(OAuthProvider, ConsentMixin): } ) + # Propagate upstream claims from the verified FastMCP JWT into the + # final AccessToken object. This allows subclasses to access custom + # identity data extracted during the initial authorization flow. + # We perform a model copy to avoid mutating a potentially cached + # reference shared across concurrent requests. + if validated and upstream_claims: + validated = validated.model_copy(deep=True) + if validated.claims is None: + validated.claims = {} + validated.claims["upstream_claims"] = upstream_claims + logger.debug( "Token swap successful for JTI=%s (upstream validated)", jti[:8] ) diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index 2da50bbde..08c746f0b 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -214,6 +214,7 @@ class OIDCProxy(OAuthProxy): verify_id_token: bool = False, # FastMCP server configuration base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, issuer_url: AnyHttpUrl | str | None = None, redirect_path: str | None = None, # Client configuration @@ -226,6 +227,7 @@ class OIDCProxy(OAuthProxy): # Consent screen configuration require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, # Extra parameters extra_authorize_params: dict[str, str] | None = None, extra_token_params: dict[str, str] | None = None, @@ -254,6 +256,8 @@ class OIDCProxy(OAuthProxy): Useful for providers that issue opaque (non-JWT) access tokens, since the id_token is always a standard JWT verifiable via the provider's JWKS. base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback") @@ -369,6 +373,7 @@ class OIDCProxy(OAuthProxy): "upstream_revocation_endpoint": revocation_endpoint, "token_verifier": token_verifier, "base_url": base_url, + "resource_base_url": resource_base_url, "issuer_url": issuer_url or base_url, "service_documentation_url": self.oidc_config.service_documentation, "allowed_client_redirect_uris": allowed_client_redirect_uris, @@ -377,6 +382,7 @@ class OIDCProxy(OAuthProxy): "token_endpoint_auth_method": token_endpoint_auth_method, "require_authorization_consent": require_authorization_consent, "consent_csp_policy": consent_csp_policy, + "forward_resource": forward_resource, "fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds, "enable_cimd": enable_cimd, } diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index d38e4046d..601f314ef 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -65,6 +65,7 @@ class Auth0Provider(OIDCProxy): client_secret: str, audience: str, base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, issuer_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | None = None, redirect_path: str | None = None, @@ -73,6 +74,7 @@ class Auth0Provider(OIDCProxy): jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, ) -> None: """Initialize Auth0 OAuth provider. @@ -82,6 +84,8 @@ class Auth0Provider(OIDCProxy): client_secret: Auth0 application client secret audience: Auth0 API audience base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. 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 Auth0 scopes (defaults to ["openid"]) @@ -112,6 +116,7 @@ class Auth0Provider(OIDCProxy): client_secret=client_secret, audience=audience, base_url=base_url, + resource_base_url=resource_base_url, issuer_url=issuer_url, redirect_path=redirect_path, required_scopes=auth0_required_scopes, @@ -120,6 +125,7 @@ class Auth0Provider(OIDCProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, ) logger.debug( diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index dbec10e2d..6f2cfcd56 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -38,15 +38,39 @@ logger = get_logger(__name__) class AWSCognitoTokenVerifier(JWTVerifier): - """Token verifier that filters claims to Cognito-specific subset.""" + """Token verifier for Cognito access tokens. + + Cognito access tokens use a ``client_id`` claim instead of the + standard ``aud`` claim. This subclass passes ``audience=None`` + to the parent (skipping the ``aud`` check) and validates the + ``client_id`` claim directly. + """ + + def __init__(self, *, audience: str | list[str] | None = None, **kwargs): + self._expected_client_id = audience + super().__init__(audience=None, **kwargs) async def verify_token(self, token: str) -> AccessToken | None: """Verify token and filter claims to Cognito-specific subset.""" - # Use base JWT verification access_token = await super().verify_token(token) if not access_token: return None + # Validate client_id claim (Cognito's equivalent of aud) + if self._expected_client_id: + token_client_id = access_token.claims.get("client_id") + if isinstance(self._expected_client_id, list): + valid = token_client_id in self._expected_client_id + else: + valid = token_client_id == self._expected_client_id + if not valid: + self.logger.debug( + "Token validation failed: client_id mismatch (expected %s, got %s)", + self._expected_client_id, + token_client_id, + ) + return None + # Filter claims to Cognito-specific subset cognito_claims = { "sub": access_token.claims.get("sub"), @@ -54,7 +78,6 @@ class AWSCognitoTokenVerifier(JWTVerifier): "cognito:groups": access_token.claims.get("cognito:groups", []), } - # Return new AccessToken with filtered claims return AccessToken( token=access_token.token, client_id=access_token.client_id, @@ -102,6 +125,7 @@ class AWSCognitoProvider(OIDCProxy): client_id: str, client_secret: str, base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, aws_region: str = "eu-central-1", issuer_url: AnyHttpUrl | str | None = None, redirect_path: str = "/auth/callback", @@ -111,6 +135,7 @@ class AWSCognitoProvider(OIDCProxy): jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, ): """Initialize AWS Cognito OAuth provider. @@ -119,6 +144,8 @@ class AWSCognitoProvider(OIDCProxy): client_id: Cognito app client ID client_secret: Cognito app client secret base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. aws_region: AWS region where your User Pool is located (defaults to "eu-central-1") 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. @@ -160,6 +187,7 @@ class AWSCognitoProvider(OIDCProxy): algorithm="RS256", required_scopes=required_scopes_final, base_url=base_url, + resource_base_url=resource_base_url, issuer_url=issuer_url, redirect_path=redirect_path, allowed_client_redirect_uris=allowed_client_redirect_uris, @@ -167,6 +195,7 @@ class AWSCognitoProvider(OIDCProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, ) logger.debug( diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 00c1ab154..d336e84ac 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: from azure.identity.aio import OnBehalfOfCredential from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull + from pydantic import AnyHttpUrl from fastmcp.server.auth.auth import AuthProvider @@ -103,6 +104,7 @@ class AzureProvider(OAuthProxy): tenant_id: str, required_scopes: list[str], base_url: str, + resource_base_url: AnyHttpUrl | str | None = None, identifier_uri: str | None = None, issuer_url: str | None = None, redirect_path: str | None = None, @@ -112,6 +114,7 @@ class AzureProvider(OAuthProxy): jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, base_authority: str = "login.microsoftonline.com", http_client: httpx.AsyncClient | None = None, enable_cimd: bool = True, @@ -130,6 +133,8 @@ class AzureProvider(OAuthProxy): Example: identifier_uri="api://my-api" + required_scopes=["read"] → tokens validated for "api://my-api/read" base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in Azure App registration (defaults to "/auth/callback") @@ -219,7 +224,7 @@ class AzureProvider(OAuthProxy): token_verifier = JWTVerifier( jwks_uri=jwks_uri, issuer=issuer, - audience=client_id, + audience=[client_id, self.identifier_uri], algorithm="RS256", required_scopes=validation_scopes, # Only validate non-OIDC scopes http_client=http_client, @@ -241,6 +246,7 @@ class AzureProvider(OAuthProxy): upstream_client_secret=client_secret, token_verifier=token_verifier, base_url=base_url, + resource_base_url=resource_base_url, redirect_path=redirect_path, issuer_url=issuer_url or base_url, # Default to base_url if not specified allowed_client_redirect_uris=allowed_client_redirect_uris, @@ -248,6 +254,7 @@ class AzureProvider(OAuthProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, valid_scopes=parsed_required_scopes, enable_cimd=enable_cimd, ) @@ -625,7 +632,7 @@ class AzureJWTVerifier(JWTVerifier): super().__init__( jwks_uri=f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys", issuer=issuer, - audience=client_id, + audience=[client_id, self._identifier_uri], algorithm="RS256", required_scopes=required_scopes, ) diff --git a/src/fastmcp/server/auth/providers/clerk.py b/src/fastmcp/server/auth/providers/clerk.py new file mode 100644 index 000000000..b465faf39 --- /dev/null +++ b/src/fastmcp/server/auth/providers/clerk.py @@ -0,0 +1,388 @@ +"""Clerk OAuth provider for FastMCP. + +This module provides a complete Clerk OAuth integration that's ready to use +with a Clerk domain, client ID, and client secret. It handles all the complexity +of Clerk's OAuth/OIDC flow, token validation, and user management. + +Clerk uses standard OIDC endpoints derived from the instance domain +(e.g., ``https://.clerk.accounts.dev``). Token verification is +performed via the introspection endpoint (RFC 7662) for security-critical +checks (active status, audience, scopes), followed by the userinfo endpoint +for profile enrichment. Userinfo failure is non-fatal. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.clerk import ClerkProvider + + auth = ClerkProvider( + domain="saving-primate-16.clerk.accounts.dev", + client_id="your-clerk-client-id", + client_secret="your-clerk-client-secret", + base_url="https://my-server.com", + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` +""" + +from __future__ import annotations + +import contextlib +from typing import Literal + +import httpx +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import TokenVerifier +from fastmcp.server.auth.auth import AccessToken +from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class ClerkTokenVerifier(TokenVerifier): + """Token verifier for Clerk OAuth tokens. + + Clerk issues standard OIDC tokens. Verification uses the introspection + endpoint (RFC 7662) as the primary security gate — it confirms the token + is active and provides metadata (scopes, expiry, audience). The userinfo + endpoint is called second for profile enrichment (name, email, picture) + and its failure is non-fatal. + + When a ``client_id`` is configured, the audience from introspection is + validated against it. When ``required_scopes`` are configured, + introspection must return the token's scopes — the verifier will not + assume scopes when introspection is unavailable. + """ + + def __init__( + self, + *, + domain: str, + client_id: str | None = None, + client_secret: str | None = None, + required_scopes: list[str] | None = None, + timeout_seconds: int = 10, + http_client: httpx.AsyncClient | None = None, + ): + """Initialize the Clerk token verifier. + + Args: + domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev") + client_id: Clerk OAuth client ID, used for introspection endpoint authentication + client_secret: Clerk OAuth client secret, used for introspection endpoint authentication + required_scopes: Required OAuth scopes (e.g., ["openid", "email", "profile"]) + timeout_seconds: HTTP request timeout + http_client: Optional httpx.AsyncClient for connection pooling. When provided, + the client is reused across calls and the caller is responsible for its + lifecycle. When None (default), a fresh client is created per call. + """ + super().__init__(required_scopes=required_scopes) + self.domain = domain.rstrip("/") + self._client_id = client_id + self._client_secret = client_secret + self.timeout_seconds = timeout_seconds + self._http_client = http_client + + self._userinfo_url = f"https://{self.domain}/oauth/userinfo" + self._introspection_url = f"https://{self.domain}/oauth/token_info" + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify a Clerk OAuth token via introspection and userinfo. + + Calls the introspection endpoint first to validate the token and + retrieve auth metadata (active status, scopes, expiry, audience). + If the token passes security checks, the userinfo endpoint is called + for profile enrichment. Userinfo failure is non-fatal. + + When a ``client_id`` is configured, the token's audience must match it. + When ``required_scopes`` are configured, introspection must confirm + them; tokens are rejected if scope information is unavailable. + """ + try: + async with ( + contextlib.nullcontext(self._http_client) + if self._http_client is not None + else httpx.AsyncClient(timeout=self.timeout_seconds) + ) as client: + # Step 1: Validate token via introspection (RFC 7662). + # Security-critical checks (active, audience, scopes) come first. + introspect_data_payload: dict = {"token": token} + introspect_kwargs: dict = { + "data": introspect_data_payload, + "headers": {"User-Agent": "FastMCP-Clerk-OAuth"}, + } + + if self._client_id and self._client_secret: + introspect_kwargs["auth"] = ( + self._client_id, + self._client_secret, + ) + elif self._client_id: + introspect_data_payload["client_id"] = self._client_id + + introspect_response = await client.post( + self._introspection_url, + **introspect_kwargs, + ) + + if introspect_response.status_code != 200: + logger.debug( + "Clerk introspection failed: %d", + introspect_response.status_code, + ) + return None + + introspect_data = introspect_response.json() + + # RFC 7662 requires the 'active' field in the response. + # A missing field indicates a malformed response — reject. + if "active" not in introspect_data or not introspect_data["active"]: + logger.debug( + "Clerk introspection: token inactive or missing 'active' field" + ) + return None + + scope_str = introspect_data.get("scope", "") + token_scopes = scope_str.split() if scope_str else [] + + aud = introspect_data.get("aud") or introspect_data.get("client_id") + + expires_at: int | None = None + exp = introspect_data.get("exp") + if exp is not None: + with contextlib.suppress(ValueError, TypeError): + expires_at = int(exp) + + if self._client_id and aud != self._client_id: + logger.debug( + "Clerk token audience mismatch: got %s, expected %s", + aud, + self._client_id, + ) + return None + + if self.required_scopes: + if not token_scopes: + logger.debug( + "Clerk token missing scope information; " + "cannot verify required scopes %s", + self.required_scopes, + ) + return None + token_scopes_set = set(token_scopes) + required_scopes_set = set(self.required_scopes) + if not required_scopes_set.issubset(token_scopes_set): + logger.debug( + "Clerk token missing required scopes. Has %s, needs %s", + token_scopes_set, + required_scopes_set, + ) + return None + + # Step 2: Fetch user profile via userinfo. + # Enriches the token with profile data (name, email, picture). + sub = introspect_data.get("sub") + user_data: dict = {} + try: + userinfo_response = await client.get( + self._userinfo_url, + headers={ + "Authorization": f"Bearer {token}", + "User-Agent": "FastMCP-Clerk-OAuth", + }, + ) + if userinfo_response.status_code == 200: + user_data = userinfo_response.json() + if not sub: + sub = user_data.get("sub") + except Exception as e: + logger.debug("Clerk userinfo call failed: %s", e) + + if not sub: + logger.debug("Clerk token missing 'sub' claim") + return None + + access_token = AccessToken( + token=token, + client_id=aud or sub, + scopes=token_scopes, + expires_at=expires_at, + claims={ + "sub": sub, + "aud": aud, + "email": user_data.get("email"), + "email_verified": user_data.get("email_verified"), + "name": user_data.get("name"), + "picture": user_data.get("picture"), + "given_name": user_data.get("given_name"), + "family_name": user_data.get("family_name"), + "preferred_username": user_data.get("preferred_username"), + "iss": user_data.get("iss"), + "clerk_user_data": user_data or None, + }, + ) + logger.debug("Clerk token verified successfully for sub=%s", sub) + return access_token + + except httpx.RequestError as e: + logger.debug("Failed to verify Clerk token: %s", e) + return None + except Exception as e: + logger.debug("Clerk token verification error: %s", e) + return None + + +class ClerkProvider(OAuthProxy): + """Complete Clerk OAuth provider for FastMCP. + + This provider makes it trivial to add Clerk OAuth protection to any + FastMCP server. Provide your Clerk instance domain, OAuth app credentials, + and a base URL, and you're ready to go. + + Clerk uses standard OIDC endpoints derived from the instance domain. + All endpoint URLs are constructed automatically from the domain parameter. + + Features: + - Transparent OAuth proxy to Clerk + - Automatic token validation via Clerk's userinfo & introspection APIs + - User information extraction from Clerk's OIDC claims + - PKCE support (S256) + - Minimal configuration required + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.clerk import ClerkProvider + + auth = ClerkProvider( + domain="saving-primate-16.clerk.accounts.dev", + client_id="your-clerk-client-id", + client_secret="your-clerk-client-secret", + base_url="https://my-server.com", + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + def __init__( + self, + *, + domain: str, + client_id: str, + client_secret: str | None = None, + base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, + issuer_url: AnyHttpUrl | str | None = None, + redirect_path: str | None = None, + required_scopes: list[str] | None = None, + valid_scopes: list[str] | None = None, + timeout_seconds: int = 10, + allowed_client_redirect_uris: list[str] | None = None, + client_storage: AsyncKeyValue | None = None, + jwt_signing_key: str | bytes | None = None, + require_authorization_consent: bool | Literal["external"] = True, + consent_csp_policy: str | None = None, + forward_resource: bool = True, + extra_authorize_params: dict[str, str] | None = None, + http_client: httpx.AsyncClient | None = None, + enable_cimd: bool = True, + ): + """Initialize Clerk OAuth provider. + + Args: + domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev"). + This is used to derive all OAuth/OIDC endpoint URLs. + client_id: Clerk OAuth application client ID + client_secret: Clerk OAuth application client secret. + Optional for PKCE public clients. When omitted, jwt_signing_key must be provided. + base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL + to avoid 404s during discovery when mounting under a path. + redirect_path: Redirect path configured in Clerk OAuth app (defaults to "/auth/callback") + required_scopes: Required Clerk scopes (defaults to ["openid", "email", "profile"]). + Clerk supports: "openid", "email", "profile", "public_metadata", + "private_metadata", "offline_access". + valid_scopes: All scopes that clients are allowed to request, advertised through + well-known endpoints. Defaults to required_scopes if not provided. + timeout_seconds: HTTP request timeout for Clerk API calls (defaults to 10) + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). + If None, an encrypted file store will be created in the data directory + (derived from ``platformdirs``). + jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes + are provided, they will be used as is. If a string is provided, it will be derived + into a 32-byte key. If not provided, the upstream client secret will be used to + derive a 32-byte key using PBKDF2. + require_authorization_consent: Whether to require user consent before authorizing + clients (default True). When "external", the built-in consent screen is skipped + but no warning is logged, indicating that consent is handled externally by Clerk. + consent_csp_policy: Custom CSP policy for the consent page. + extra_authorize_params: Additional parameters to forward to Clerk's authorization + endpoint. Example: {"prompt": "login"} to force re-authentication. + http_client: Optional httpx.AsyncClient for connection pooling in token verification. + When provided, the client is reused across verify_token calls and the caller + is responsible for its lifecycle. When None (default), a fresh client is created + per call. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs (default True). Set to False to disable. + """ + domain = domain.rstrip("/") + + required_scopes_final = ( + parse_scopes(required_scopes) + if required_scopes is not None + else ["openid", "email", "profile"] + ) + + parsed_valid_scopes = ( + parse_scopes(valid_scopes) if valid_scopes is not None else None + ) + + token_verifier = ClerkTokenVerifier( + domain=domain, + client_id=client_id, + client_secret=client_secret, + required_scopes=required_scopes_final, + timeout_seconds=timeout_seconds, + http_client=http_client, + ) + + extra_authorize_params_final = ( + dict(extra_authorize_params) if extra_authorize_params else {} + ) + + super().__init__( + upstream_authorization_endpoint=f"https://{domain}/oauth/authorize", + upstream_token_endpoint=f"https://{domain}/oauth/token", + upstream_client_id=client_id, + upstream_client_secret=client_secret, + token_verifier=token_verifier, + base_url=base_url, + resource_base_url=resource_base_url, + redirect_path=redirect_path, + issuer_url=issuer_url or base_url, + allowed_client_redirect_uris=allowed_client_redirect_uris, + client_storage=client_storage, + jwt_signing_key=jwt_signing_key, + require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, + extra_authorize_params=extra_authorize_params_final or None, + valid_scopes=parsed_valid_scopes, + enable_cimd=enable_cimd, + ) + + logger.debug( + "Initialized Clerk OAuth provider for domain %s with scopes: %s", + domain, + required_scopes_final, + ) diff --git a/src/fastmcp/server/auth/providers/discord.py b/src/fastmcp/server/auth/providers/discord.py index 15bf9e5dc..333001cfa 100644 --- a/src/fastmcp/server/auth/providers/discord.py +++ b/src/fastmcp/server/auth/providers/discord.py @@ -196,6 +196,7 @@ class DiscordProvider(OAuthProxy): client_id: str, client_secret: str, base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, issuer_url: AnyHttpUrl | str | None = None, redirect_path: str | None = None, required_scopes: list[str] | None = None, @@ -205,6 +206,7 @@ class DiscordProvider(OAuthProxy): jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, http_client: httpx.AsyncClient | None = None, enable_cimd: bool = True, ): @@ -214,6 +216,8 @@ class DiscordProvider(OAuthProxy): client_id: Discord OAuth client ID (e.g., "123456789") client_secret: Discord OAuth client secret (e.g., "S....") base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in Discord OAuth app (defaults to "/auth/callback") @@ -265,6 +269,7 @@ class DiscordProvider(OAuthProxy): upstream_client_secret=client_secret, token_verifier=token_verifier, base_url=base_url, + resource_base_url=resource_base_url, redirect_path=redirect_path, issuer_url=issuer_url or base_url, # Default to base_url if not specified allowed_client_redirect_uris=allowed_client_redirect_uris, @@ -272,6 +277,7 @@ class DiscordProvider(OAuthProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, enable_cimd=enable_cimd, ) diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index e0d140d38..0655a8bd5 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -209,6 +209,7 @@ class GitHubProvider(OAuthProxy): client_id: str, client_secret: str, base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, issuer_url: AnyHttpUrl | str | None = None, redirect_path: str | None = None, required_scopes: list[str] | None = None, @@ -220,6 +221,7 @@ class GitHubProvider(OAuthProxy): jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, http_client: httpx.AsyncClient | None = None, enable_cimd: bool = True, ): @@ -229,6 +231,8 @@ class GitHubProvider(OAuthProxy): client_id: GitHub OAuth app client ID (e.g., "Ov23li...") client_secret: GitHub OAuth app client secret base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback") @@ -280,6 +284,7 @@ class GitHubProvider(OAuthProxy): upstream_client_secret=client_secret, token_verifier=token_verifier, base_url=base_url, + resource_base_url=resource_base_url, redirect_path=redirect_path, issuer_url=issuer_url or base_url, # Default to base_url if not specified allowed_client_redirect_uris=allowed_client_redirect_uris, @@ -287,6 +292,7 @@ class GitHubProvider(OAuthProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, enable_cimd=enable_cimd, ) diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 8557248a9..219e6c0db 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -173,7 +173,7 @@ class GoogleTokenVerifier(TokenVerifier): access_token = AccessToken( token=token, - client_id=aud, + client_id=sub, scopes=token_scopes, expires_at=expires_at, claims={ @@ -235,6 +235,7 @@ class GoogleProvider(OAuthProxy): client_id: str, client_secret: str | None = None, base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, issuer_url: AnyHttpUrl | str | None = None, redirect_path: str | None = None, required_scopes: list[str] | None = None, @@ -245,6 +246,7 @@ class GoogleProvider(OAuthProxy): jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, extra_authorize_params: dict[str, str] | None = None, http_client: httpx.AsyncClient | None = None, enable_cimd: bool = True, @@ -257,6 +259,8 @@ class GoogleProvider(OAuthProxy): Optional for PKCE public clients (e.g., native apps). When omitted, jwt_signing_key must be provided. base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in Google OAuth app (defaults to "/auth/callback") @@ -340,6 +344,7 @@ class GoogleProvider(OAuthProxy): upstream_client_secret=client_secret, token_verifier=token_verifier, base_url=base_url, + resource_base_url=resource_base_url, redirect_path=redirect_path, issuer_url=issuer_url or base_url, # Default to base_url if not specified allowed_client_redirect_uris=allowed_client_redirect_uris, @@ -347,6 +352,7 @@ class GoogleProvider(OAuthProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, extra_authorize_params=extra_authorize_params_final, valid_scopes=valid_scopes_final, enable_cimd=enable_cimd, diff --git a/src/fastmcp/server/auth/providers/in_memory.py b/src/fastmcp/server/auth/providers/in_memory.py index 08a7fc2a1..3ae686dac 100644 --- a/src/fastmcp/server/auth/providers/in_memory.py +++ b/src/fastmcp/server/auth/providers/in_memory.py @@ -37,6 +37,7 @@ class InMemoryOAuthProvider(OAuthProvider): def __init__( self, base_url: AnyHttpUrl | str | None = None, + resource_base_url: AnyHttpUrl | str | None = None, service_documentation_url: AnyHttpUrl | str | None = None, client_registration_options: ClientRegistrationOptions | None = None, revocation_options: RevocationOptions | None = None, @@ -44,6 +45,7 @@ class InMemoryOAuthProvider(OAuthProvider): ): super().__init__( base_url=base_url or "http://fastmcp.example.com", + resource_base_url=resource_base_url, service_documentation_url=service_documentation_url, client_registration_options=client_registration_options, revocation_options=revocation_options, diff --git a/src/fastmcp/server/auth/providers/introspection.py b/src/fastmcp/server/auth/providers/introspection.py index 65d9d86ce..f24b54833 100644 --- a/src/fastmcp/server/auth/providers/introspection.py +++ b/src/fastmcp/server/auth/providers/introspection.py @@ -286,7 +286,7 @@ class IntrospectionTokenVerifier(TokenVerifier): token=token, client_id=str(client_id), scopes=scopes, - expires_at=int(exp) if exp else None, + expires_at=int(exp) if exp is not None else None, claims=introspection_data, # Store full response for extensibility ) self._cache.set(token, result) diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index a97c1bd60..2417cd067 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -419,13 +419,16 @@ class JWTVerifier(TokenVerifier): or "unknown" ) - # Validate expiration + # Validate expiration. Kept at INFO (not WARNING like issuer/ + # audience/scope mismatches below) — expiry is expected-path noise + # from normal token rotation, not a configuration error worth + # surfacing by default. exp = claims.get("exp") - if exp and exp < time.time(): - self.logger.debug( - "Token validation failed: expired token for client %s", client_id + if exp is not None and exp < time.time(): + self.logger.info( + "Bearer token rejected for client %s: token expired", + client_id, ) - self.logger.info("Bearer token rejected for client %s", client_id) return None # Validate issuer - note we use issuer instead of issuer_url here because @@ -443,11 +446,13 @@ class JWTVerifier(TokenVerifier): issuer_valid = iss == self.issuer if not issuer_valid: - self.logger.debug( - "Token validation failed: issuer mismatch for client %s", + self.logger.warning( + "Bearer token rejected for client %s: issuer mismatch " + "(got %r, expected %r)", client_id, + iss, + self.issuer, ) - self.logger.info("Bearer token rejected for client %s", client_id) return None # Validate audience if configured @@ -474,11 +479,13 @@ class JWTVerifier(TokenVerifier): audience_valid = aud == self.audience if not audience_valid: - self.logger.debug( - "Token validation failed: audience mismatch for client %s", + self.logger.warning( + "Bearer token rejected for client %s: audience mismatch " + "(got %r, expected %r)", client_id, + aud, + self.audience, ) - self.logger.info("Bearer token rejected for client %s", client_id) return None # Extract scopes @@ -489,19 +496,20 @@ class JWTVerifier(TokenVerifier): token_scopes = set(scopes) required_scopes = set(self.required_scopes) if not required_scopes.issubset(token_scopes): - self.logger.debug( - "Token missing required scopes. Has: %s, Required: %s", - token_scopes, - required_scopes, + self.logger.warning( + "Bearer token rejected for client %s: missing required " + "scopes (has %s, requires %s)", + client_id, + sorted(token_scopes), + sorted(required_scopes), ) - self.logger.info("Bearer token rejected for client %s", client_id) return None return AccessToken( token=token, client_id=str(client_id), scopes=scopes, - expires_at=int(exp) if exp else None, + expires_at=int(exp) if exp is not None else None, claims=claims, ) diff --git a/src/fastmcp/server/auth/providers/keycloak.py b/src/fastmcp/server/auth/providers/keycloak.py new file mode 100644 index 000000000..d018bc4b0 --- /dev/null +++ b/src/fastmcp/server/auth/providers/keycloak.py @@ -0,0 +1,74 @@ +"""Keycloak authentication provider for FastMCP.""" + +from __future__ import annotations + +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class KeycloakAuthProvider(RemoteAuthProvider): + """Keycloak authentication provider using Dynamic Client Registration (DCR). + + Requires Keycloak 26.6.0 or later, which includes the fix for DCR compatibility + with MCP clients (https://github.com/keycloak/keycloak/pull/45309). + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider + + auth = KeycloakAuthProvider( + realm_url="https://keycloak.example.com/realms/myrealm", + base_url="https://my-mcp-server.example.com", + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + def __init__( + self, + *, + realm_url: AnyHttpUrl | str, + base_url: AnyHttpUrl | str, + required_scopes: list[str] | str | None = None, + audience: str | list[str] | None = None, + token_verifier: TokenVerifier | None = None, + ): + """Initialize the Keycloak auth provider. + + Args: + realm_url: Keycloak realm URL (e.g., "https://keycloak.example.com/realms/myrealm") + base_url: Public URL of this FastMCP server + required_scopes: Scopes to require on incoming tokens. Defaults to + ["openid"], which ensures the `sub` claim (user identifier) is + present in the access token. Override to require additional scopes. + audience: Optional audience(s) for JWT validation. Recommended for production. + token_verifier: Optional custom token verifier. Defaults to a JWTVerifier + configured for Keycloak's JWKS endpoint and issuer. + """ + self.realm_url = str(realm_url).rstrip("/") + parsed_scopes = ( + parse_scopes(required_scopes) if required_scopes is not None else ["openid"] + ) + + if token_verifier is None: + token_verifier = JWTVerifier( + jwks_uri=f"{self.realm_url}/protocol/openid-connect/certs", + issuer=self.realm_url, + algorithm="RS256", + required_scopes=parsed_scopes, + audience=audience, + ) + + super().__init__( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl(self.realm_url)], + base_url=AnyHttpUrl(str(base_url).rstrip("/")), + ) diff --git a/src/fastmcp/server/auth/providers/oci.py b/src/fastmcp/server/auth/providers/oci.py index d4bf0d0c8..07bad2a41 100644 --- a/src/fastmcp/server/auth/providers/oci.py +++ b/src/fastmcp/server/auth/providers/oci.py @@ -123,6 +123,7 @@ class OCIProvider(OIDCProxy): client_id: str, client_secret: str, base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, audience: str | None = None, issuer_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | None = None, @@ -132,6 +133,7 @@ class OCIProvider(OIDCProxy): jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, ) -> None: """Initialize OCI OIDC provider. @@ -140,6 +142,8 @@ class OCIProvider(OIDCProxy): client_id: OCI IAM Domain Integrated Application client id client_secret: OCI Integrated Application client secret base_url: Public URL where OIDC endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. audience: OCI API audience (optional) issuer_url: Issuer URL for OCI IAM Domain metadata. This will override issuer URL from the discovery URL. required_scopes: Required OCI scopes (defaults to ["openid"]) @@ -157,6 +161,7 @@ class OCIProvider(OIDCProxy): client_secret=client_secret, audience=audience, base_url=base_url, + resource_base_url=resource_base_url, issuer_url=issuer_url, redirect_path=redirect_path, required_scopes=oci_required_scopes, @@ -165,6 +170,7 @@ class OCIProvider(OIDCProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, ) logger.debug( diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 957e29b0e..bd6d582ad 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -164,6 +164,7 @@ class WorkOSProvider(OAuthProxy): client_secret: str, authkit_domain: str, base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, issuer_url: AnyHttpUrl | str | None = None, redirect_path: str | None = None, required_scopes: list[str] | None = None, @@ -173,6 +174,7 @@ class WorkOSProvider(OAuthProxy): jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, + forward_resource: bool = True, http_client: httpx.AsyncClient | None = None, enable_cimd: bool = True, ): @@ -183,6 +185,8 @@ class WorkOSProvider(OAuthProxy): client_secret: WorkOS client secret authkit_domain: Your WorkOS AuthKit domain (e.g., "https://your-app.authkit.app") base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in WorkOS (defaults to "/auth/callback") @@ -233,6 +237,7 @@ class WorkOSProvider(OAuthProxy): upstream_client_secret=client_secret, token_verifier=token_verifier, base_url=base_url, + resource_base_url=resource_base_url, redirect_path=redirect_path, issuer_url=issuer_url or base_url, # Default to base_url if not specified allowed_client_redirect_uris=allowed_client_redirect_uris, @@ -240,6 +245,7 @@ class WorkOSProvider(OAuthProxy): jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, enable_cimd=enable_cimd, ) @@ -271,17 +277,22 @@ class AuthKitProvider(RemoteAuthProvider): For detailed setup instructions, see: https://workos.com/docs/authkit/mcp/integrating/token-verification + Token audience is bound to this server automatically: when the MCP + mount path becomes known (typically at ``http_app()`` construction), + ``JWTVerifier.audience`` is set to the resource URL advertised in + ``.well-known/oauth-protected-resource``. Enable Resource Indicators + (RFC 8707) in your WorkOS Dashboard and list that same URL — AuthKit + will then mint tokens with the matching ``aud`` claim. + Example: ```python from fastmcp.server.auth.providers.workos import AuthKitProvider - # Create AuthKit metadata provider (JWT verifier created automatically) workos_auth = AuthKitProvider( authkit_domain="https://your-workos-domain.authkit.app", base_url="https://your-fastmcp-server.com", ) - # Use with FastMCP mcp = FastMCP("My App", auth=workos_auth) ``` """ @@ -291,7 +302,7 @@ class AuthKitProvider(RemoteAuthProvider): *, authkit_domain: AnyHttpUrl | str, base_url: AnyHttpUrl | str, - client_id: str | None = None, + resource_base_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | None = None, scopes_supported: list[str] | None = None, resource_name: str | None = None, @@ -303,16 +314,20 @@ class AuthKitProvider(RemoteAuthProvider): Args: authkit_domain: Your AuthKit domain (e.g., "https://your-app.authkit.app") base_url: Public URL of this FastMCP server - client_id: Your WorkOS project client ID (e.g., "client_01ABC..."). Used to - validate the JWT audience claim. Found in your WorkOS Dashboard under - API Keys. This is the project-level client ID, not individual MCP client IDs. + resource_base_url: Optional public base URL for the protected resource. + When provided, this URL is advertised in protected resource metadata + instead of ``base_url``. Useful when OAuth callbacks and the protected + MCP resource live under different public URLs. required_scopes: Optional list of scopes to require for all requests scopes_supported: Optional list of scopes to advertise in OAuth metadata. If None, uses required_scopes. Use this when the scopes clients should request differ from the scopes enforced on tokens. resource_name: Optional name for the protected resource metadata. resource_documentation: Optional documentation URL for the protected resource. - token_verifier: Optional token verifier. If None, creates JWT verifier for AuthKit + token_verifier: Optional token verifier. If provided, it is used as-is and + audience auto-wiring is skipped — the caller is responsible for setting + an appropriate ``audience``. If None (default), a ``JWTVerifier`` is + created with audience bound to this server's resource URL. """ self.authkit_domain = str(authkit_domain).rstrip("/") self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) @@ -322,19 +337,14 @@ class AuthKitProvider(RemoteAuthProvider): parse_scopes(required_scopes) if required_scopes is not None else None ) - # Create default JWT verifier if none provided + # When no custom verifier is provided, we own the JWTVerifier and can + # bind its audience to our resource URL once set_mcp_path() is called. + self._auto_bind_audience = token_verifier is None if token_verifier is None: - logger.warning( - "AuthKitProvider cannot validate token audience for the specific resource " - "because AuthKit does not support RFC 8707 resource indicators. " - "This may leave the server vulnerable to cross-server token replay. " - "Consider using WorkOSProvider (OAuth proxy) for audience-bound tokens." - ) token_verifier = JWTVerifier( jwks_uri=f"{self.authkit_domain}/oauth2/jwks", issuer=self.authkit_domain, algorithm="RS256", - audience=client_id, required_scopes=parsed_scopes, ) @@ -343,11 +353,34 @@ class AuthKitProvider(RemoteAuthProvider): token_verifier=token_verifier, authorization_servers=[AnyHttpUrl(self.authkit_domain)], 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. + + AuthKit with Resource Indicators (RFC 8707) mints tokens whose ``aud`` + claim equals the resource URL the client requested — which is the URL + we advertise in ``.well-known/oauth-protected-resource``. Binding the + audience here keeps validation in lock-step with what clients are sent. + """ + 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( + "AuthKit tokens will be validated against aud=%s. " + "Configure this URL as a Resource Indicator in the WorkOS Dashboard.", + resource_url, + ) + def get_routes( self, mcp_path: str | None = None, diff --git a/src/fastmcp/server/auth/ssrf.py b/src/fastmcp/server/auth/ssrf.py index 39c28e959..86ea2031d 100644 --- a/src/fastmcp/server/auth/ssrf.py +++ b/src/fastmcp/server/auth/ssrf.py @@ -119,7 +119,7 @@ async def resolve_hostname(hostname: str, port: int = 443) -> list[str]: ips = list({info[4][0] for info in infos}) if not ips: raise SSRFError(f"DNS resolution returned no addresses for {hostname}") - return ips + return ips # ty: ignore[invalid-return-type] except socket.gaierror as e: raise SSRFError(f"DNS resolution failed for {hostname}: {e}") from e diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 11bbbf90e..afaae2f5a 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -272,16 +272,18 @@ class Context: self._server_token = _current_server.set(weakref.ref(self.fastmcp)) - # Set docket/worker from server instance for this request's context. - # This ensures ContextVars work even in ASGI environments (Lambda, FastAPI mount) - # where lifespan ContextVars don't propagate to request handlers. - server = 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) - else: + + if not is_docket_available(): # Without docket, the lifespan won't provide a SharedContext, # so create one scoped to this Context for Shared() dependencies. self._shared_context = SharedContext() @@ -297,7 +299,6 @@ class Context: _current_worker, ) - # Mirror __aenter__: clean up docket/worker tokens or SharedContext if hasattr(self, "_worker_token"): _current_worker.reset(self._worker_token) del self._worker_token @@ -998,7 +999,7 @@ class Context: session.create_message() API directly. """ # TODO: Add background task support similar to elicit() when is_background_task - return await sample_impl( + return await sample_impl( # ty: ignore[invalid-return-type] self, messages=messages, system_prompt=system_prompt, diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 4d2c2b1c4..eb1f24c3b 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -8,14 +8,12 @@ CurrentWorker) and background task execution require fastmcp[tasks]. from __future__ import annotations import contextlib +import importlib.metadata import inspect -import logging import weakref -from collections import OrderedDict from collections.abc import AsyncGenerator, Callable from contextlib import AsyncExitStack, asynccontextmanager -from contextvars import ContextVar, Token -from dataclasses import dataclass +from contextvars import ContextVar from datetime import datetime, timezone from functools import lru_cache from types import TracebackType @@ -29,6 +27,7 @@ from mcp.server.auth.provider import ( AccessToken as _SDKAccessToken, ) from mcp.server.lowlevel.server import request_ctx +from packaging.version import Version from starlette.requests import Request from uncalled_for import Dependency, get_dependency_parameters from uncalled_for.resolution import _Depends @@ -42,12 +41,9 @@ from fastmcp.utilities.async_utils import ( ) from fastmcp.utilities.types import find_kwarg_by_type, is_class_member_of_type -_logger = logging.getLogger(__name__) - if TYPE_CHECKING: from docket import Docket from docket.worker import Worker - from mcp.server.session import ServerSession from fastmcp.server.context import Context from fastmcp.server.server import FastMCP @@ -64,6 +60,7 @@ __all__ = [ "CurrentWorker", "Progress", "TaskContextInfo", + "TaskContextSnapshot", "TokenClaim", "get_access_token", "get_context", @@ -82,131 +79,28 @@ __all__ = [ ] -# --- TaskContextInfo and get_task_context --- - - -@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).""" - - session_id: str - """The session ID that submitted this task.""" - - -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 session_id, or None if not in a task. - """ - if not is_docket_available(): - return None - - from docket.dependencies import current_execution - - try: - execution = current_execution.get() - # Parse the task key: {session_id}:{task_id}:{task_type}:{component} - from fastmcp.server.tasks.keys import parse_task_key - - key_parts = parse_task_key(execution.key) - return TaskContextInfo( - task_id=key_parts["client_task_id"], - session_id=key_parts["session_id"], - ) - except LookupError: - # Not in worker context - return None - except (ValueError, KeyError): - # Invalid task key format - return None - - -# --- Session registry for background task Context --- - - -_task_sessions: dict[str, weakref.ref[ServerSession]] = {} - - -def register_task_session(session_id: str, session: ServerSession) -> None: - """Register a session for Context access in background tasks. - - 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. - - Args: - session_id: The session identifier - session: The ServerSession instance - """ - _task_sessions[session_id] = weakref.ref(session) - - -def get_task_session(session_id: str) -> ServerSession | None: - """Get a registered session by ID if still alive. - - Args: - session_id: The session identifier - - Returns: - The ServerSession if found and alive, None otherwise - """ - ref = _task_sessions.get(session_id) - if ref is None: - return None - session = ref() - if session is None: - # Session was garbage collected, clean up entry - _task_sessions.pop(session_id, None) - return session - - -# --- ContextVars --- +# Task context lives in fastmcp.server.tasks.context; public symbols are +# re-exported here so existing imports from dependencies continue to work. +# _get_task_snapshot_sync and _load_task_snapshot_async are not re-exported +# but are used internally by get_access_token / get_http_request / get_server. +from fastmcp.server.tasks.context import ( + TaskContextInfo, + TaskContextSnapshot, + _get_task_snapshot_sync, + _load_task_snapshot_async, + 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 ) -# --- Background task server map --- -# Maps task_id → server weakref so background workers can resolve the correct -# server for mounted-child tasks. Follows the same pattern as _task_sessions. -# Populated in submit_to_docket() where the child server is in context; -# consulted in get_server() when running inside a Docket worker. - -_task_server_map: OrderedDict[str, weakref.ref[FastMCP]] = OrderedDict() -_TASK_SERVER_MAP_MAX_SIZE = 10_000 - - -def register_task_server(task_id: str, server: FastMCP) -> None: - """Register the server for a background task. - - Called at task-submission time (inside the child server's call_tool - context) so that background workers can resolve CurrentFastMCP() and - ctx.fastmcp to the child server for mounted tasks. - - The map is bounded to avoid unbounded growth in long-lived servers. - Evicted entries fall back to the ContextVar (parent server). - """ - _task_server_map[task_id] = weakref.ref(server) - while len(_task_server_map) > _TASK_SERVER_MAP_MAX_SIZE: - _task_server_map.popitem(last=False) - - _current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None) _current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None) -_task_access_token: ContextVar[AccessToken | None] = ContextVar( - "task_access_token", default=None -) # --- Docket availability check --- @@ -214,15 +108,34 @@ _task_access_token: ContextVar[AccessToken | None] = ContextVar( _DOCKET_AVAILABLE: bool | None = None +_MIN_DOCKET_VERSION = Version("0.19.0") + + def is_docket_available() -> bool: - """Check if pydocket is installed.""" + """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. + """ global _DOCKET_AVAILABLE if _DOCKET_AVAILABLE is None: try: - import docket # noqa: F401 + installed = Version(importlib.metadata.version("pydocket")) + if installed < _MIN_DOCKET_VERSION: + _DOCKET_AVAILABLE = False + else: + import docket # noqa: F401 - _DOCKET_AVAILABLE = True - except ImportError: + _DOCKET_AVAILABLE = True + except (importlib.metadata.PackageNotFoundError, ImportError): _DOCKET_AVAILABLE = False return _DOCKET_AVAILABLE @@ -234,12 +147,27 @@ def require_docket(feature: str) -> None: feature: Description of what requires docket (e.g., "`task=True`", "CurrentDocket()"). Will be included in the error message. """ - if not is_docket_available(): - raise ImportError( - f"FastMCP background tasks require the `tasks` extra. " - f"Install with: pip install 'fastmcp[tasks]'. " - f"(Triggered by {feature})" + 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 @@ -420,13 +348,9 @@ def get_server() -> FastMCP: # This handles mounted-child tasks where _current_server is the parent. task_info = get_task_context() if task_info is not None: - ref = _task_server_map.get(task_info.task_id) - if ref is not None: - server = ref() - if server is not None: - return server - # Server was garbage collected, clean up - _task_server_map.pop(task_info.task_id, None) + 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: @@ -441,6 +365,8 @@ def get_http_request() -> Request: """Get the current HTTP request. Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context. + In background tasks, returns a synthetic request populated with the + snapshotted headers from the originating HTTP request. """ # Try MCP SDK's request_ctx first (set during normal MCP request handling) request = None @@ -452,6 +378,31 @@ 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. + # Uses sync fallback chain: ContextVar → in-memory dict → sync Redis. + if request is None: + snapshot = _get_task_snapshot_sync() + 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 @@ -498,7 +449,7 @@ def get_http_headers( } if include: exclude_headers -= {h.lower() for h in include} - # (just in case) + # Sanity check: all entries must already be lowercase if not all(h.lower() == h for h in exclude_headers): raise ValueError("Excluded headers must be lowercase") headers: dict[str, str] = {} @@ -546,12 +497,11 @@ def get_access_token() -> AccessToken | None: # Fall back to background task snapshot (#3095) # In Docket workers, neither HTTP request nor SDK context var are available. - # The token was snapshotted in Redis at submit_to_docket() time and restored - # into this ContextVar by _CurrentContext.__aenter__(). + # Uses sync fallback chain: ContextVar → in-memory dict → sync Redis. if access_token is None: - task_token = _task_access_token.get() - if task_token is not None: - # Check expiration: if expires_at is set and past, treat as expired + snapshot = _get_task_snapshot_sync() + 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 @@ -782,79 +732,17 @@ async def resolve_dependencies( # so that get_dependency_parameters can detect them. -async def _restore_task_access_token( - session_id: str, task_id: str -) -> Token[AccessToken | None] | None: - """Restore the access token snapshot from Redis into a ContextVar. - - Called when setting up context in a Docket worker. The token was stored at - submit_to_docket() time. The token is restored regardless of expiration; - get_access_token() checks expiry when reading from the ContextVar. - - Returns: - The ContextVar token for resetting, or None if nothing was restored. - """ - docket = _current_docket.get() - if docket is None: - return None - - token_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:access_token") - try: - async with docket.redis() as redis: - token_data = await redis.get(token_key) - if token_data is not None: - restored = AccessToken.model_validate_json(token_data) - return _task_access_token.set(restored) - except Exception: - _logger.warning( - "Failed to restore access token for task %s:%s", - session_id, - task_id, - exc_info=True, - ) - return None - - -async def _restore_task_origin_request_id(session_id: str, task_id: str) -> str | None: - """Restore the origin request ID snapshot for a background task. - - Returns None if no request ID was captured at submission time. - """ - docket = _current_docket.get() - if docket is None: - return None - - request_id_key = docket.key( - f"fastmcp:task:{session_id}:{task_id}:origin_request_id" - ) - try: - async with docket.redis() as redis: - request_id_data = await redis.get(request_id_key) - if request_id_data is None: - return None - if isinstance(request_id_data, bytes): - return request_id_data.decode() - return str(request_id_data) - except Exception: - _logger.warning( - "Failed to restore origin request ID for task %s:%s", - session_id, - task_id, - exc_info=True, - ) - return None - - class _CurrentContext(Dependency["Context"]): """Async context manager for Context dependency. 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 restores the access token snapshot from Redis. - """ + and loads the unified task snapshot from Redis. - _context: Context | None = None - _access_token_cv_token: Token[AccessToken | None] | None = None + The shared default instance is a stateless factory. All per-invocation + 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 Context, _current_context @@ -867,31 +755,29 @@ class _CurrentContext(Dependency["Context"]): # Check if we're in a Docket worker context task_info = get_task_context() if task_info is not None: - # Get session from registry (registered when task was submitted) - session = get_task_session(task_info.session_id) - # Get server from ContextVar server = get_server() - origin_request_id = await _restore_task_origin_request_id( - task_info.session_id, task_info.task_id + + # Load unified snapshot (sets _task_snapshot ContextVar) + snapshot = await _load_task_snapshot_async( + task_info.task_scope, task_info.task_id ) - # Create task-aware Context - self._context = Context( + 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, ) - # Enter the context to set up ContextVars - await self._context.__aenter__() + await ctx.__aenter__() + return ctx - # Restore access token snapshot from Redis (#3095) - self._access_token_cv_token = await _restore_task_access_token( - task_info.session_id, task_info.task_id - ) - - return self._context - - # Neither foreground nor background context available raise RuntimeError( "No active context found. This can happen if:\n" " - Called outside an MCP request handler\n" @@ -905,36 +791,29 @@ class _CurrentContext(Dependency["Context"]): exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: - # Clean up access token ContextVar - if self._access_token_cv_token is not None: - _task_access_token.reset(self._access_token_cv_token) - self._access_token_cv_token = None - # Clean up if we created a context for background task - if self._context is not None: - await self._context.__aexit__(exc_type, exc_value, traceback) - self._context = None + from fastmcp.server.context import _current_context + + ctx = _current_context.get() + if ctx is not None and ctx.is_background_task: + await ctx.__aexit__(exc_type, exc_value, traceback) class _OptionalCurrentContext(Dependency["Context | None"]): - """Context dependency that degrades to None when no context is active. + """Context dependency that returns None instead of raising when no context + is active. Used for ``ctx: Context = None`` parameter patterns. - This is implemented as a wrapper (composition), not a subclass of - `_CurrentContext`, to avoid overriding `__aenter__` with an incompatible - return type. + Delegates entirely to ``_CurrentContext`` — just catches the RuntimeError. + Cleanup is handled by ``_CurrentContext.__aexit__`` reading from the + task-local ContextVar. """ - _inner: _CurrentContext | None = None - async def __aenter__(self) -> Context | None: - inner = _CurrentContext() try: - context = await inner.__aenter__() + return await _CurrentContext().__aenter__() except RuntimeError as exc: if "No active context found" in str(exc): return None raise - self._inner = inner - return context async def __aexit__( self, @@ -942,10 +821,11 @@ class _OptionalCurrentContext(Dependency["Context | None"]): exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: - if self._inner is None: - return - await self._inner.__aexit__(exc_type, exc_value, traceback) - self._inner = None + from fastmcp.server.context import _current_context + + ctx = _current_context.get() + if ctx is not None and ctx.is_background_task: + await _CurrentContext().__aexit__(exc_type, exc_value, traceback) def CurrentContext() -> Context: @@ -983,7 +863,14 @@ class _CurrentDocket(Dependency["Docket"]): async def __aenter__(self) -> Docket: require_docket("CurrentDocket()") - docket = _current_docket.get() + # 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 " @@ -1033,7 +920,13 @@ class _CurrentWorker(Dependency["Worker"]): async def __aenter__(self) -> Worker: require_docket("CurrentWorker()") - worker = _current_worker.get() + # 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 " @@ -1293,16 +1186,14 @@ class InMemoryProgress: class Progress(Dependency["Progress"]): - """FastMCP Progress dependency that works in both server and worker contexts. + """Progress dependency that works in both server and worker contexts. - Handles three execution modes: - - In Docket worker: Uses the execution's progress (observable via Redis) - - In FastMCP server with Docket: Falls back to in-memory progress - - In FastMCP server without Docket: Uses in-memory progress + In a Docket worker, delegates to the execution's Redis-backed progress + (observable across processes). Otherwise, uses in-memory tracking. - This allows tools to use Progress() regardless of whether they're called - immediately or as background tasks, and regardless of whether pydocket - is installed. + The shared default instance acts as a stateless factory — ``__aenter__`` + creates a fresh ``Progress`` per invocation so concurrent tasks never + share mutable state. """ _impl: ProgressLike | None = None @@ -1312,18 +1203,19 @@ class Progress(Dependency["Progress"]): if server_ref is None or server_ref() is None: raise RuntimeError("Progress dependency requires a FastMCP server context.") - if is_docket_available(): - from docket.dependencies import Progress as DocketProgress + instance = Progress() + if is_docket_available(): try: - docket_progress = DocketProgress() - self._impl = await docket_progress.__aenter__() - return self + from docket.dependencies import current_execution + + instance._impl = current_execution.get().progress + return instance except LookupError: pass - self._impl = InMemoryProgress() - return self + instance._impl = InMemoryProgress() + return instance async def __aexit__( self, @@ -1331,7 +1223,7 @@ class Progress(Dependency["Progress"]): exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: - self._impl = None + pass @property def current(self) -> int | None: @@ -1373,22 +1265,9 @@ class Progress(Dependency["Progress"]): class _CurrentAccessToken(Dependency[AccessToken]): """Async context manager for AccessToken dependency.""" - _access_token_cv_token: Token[AccessToken | None] | None = None - async def __aenter__(self) -> AccessToken: token = get_access_token() - # If no token found and we're in a Docket worker, try restoring from - # Redis. This handles the case where ctx: Context is not in the - # function signature, so _CurrentContext never ran the restoration. - if token is None: - task_info = get_task_context() - if task_info is not None: - self._access_token_cv_token = await _restore_task_access_token( - task_info.session_id, task_info.task_id - ) - token = get_access_token() - if token is None: raise RuntimeError( "No access token found. Ensure authentication is configured " @@ -1402,9 +1281,7 @@ class _CurrentAccessToken(Dependency[AccessToken]): exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: - if self._access_token_cv_token is not None: - _task_access_token.reset(self._access_token_cv_token) - self._access_token_cv_token = None + pass def CurrentAccessToken() -> AccessToken: diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 239967c23..46d0c956d 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -32,11 +32,15 @@ logger = get_logger(__name__) class StreamableHTTPASGIApp: """ASGI application wrapper for Streamable HTTP server transport.""" - def __init__(self, session_manager): + def __init__(self, session_manager: StreamableHTTPSessionManager | None): self.session_manager = session_manager async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: try: + if self.session_manager is None: + raise RuntimeError( + "Task group is not initialized. Make sure to use run()." + ) await self.session_manager.handle_request(scope, receive, send) except RuntimeError as e: if str(e) == "Task group is not initialized. Make sure to use run().": @@ -125,8 +129,7 @@ def create_base_app( A Starlette application """ # Always add RequestContextMiddleware as the outermost middleware - # TODO(ty): remove type ignore when ty supports Starlette Middleware typing - middleware.insert(0, Middleware(RequestContextMiddleware)) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + middleware.insert(0, Middleware(RequestContextMiddleware)) # type: ignore[arg-type] return StarletteWithLifespan( routes=routes, @@ -297,17 +300,8 @@ def create_streamable_http_app( server_routes: list[BaseRoute] = [] server_middleware: list[Middleware] = [] - # Create session manager using the provided event store - session_manager = StreamableHTTPSessionManager( - app=server._mcp_server, - event_store=event_store, - retry_interval=retry_interval, - json_response=json_response, - stateless=stateless_http, - ) - - # Create the ASGI app wrapper - streamable_http_app = StreamableHTTPASGIApp(session_manager) + # Create the ASGI app wrapper (session manager is set each lifespan cycle) + streamable_http_app = StreamableHTTPASGIApp(None) # Add StreamableHTTP routes with or without auth if auth: @@ -365,7 +359,17 @@ def create_streamable_http_app( # Create a lifespan manager to start and stop the session manager @asynccontextmanager async def lifespan(app: Starlette) -> AsyncGenerator[None, None]: - async with server._lifespan_manager(), session_manager.run(): + streamable_http_app.session_manager = StreamableHTTPSessionManager( + app=server._mcp_server, + event_store=event_store, + retry_interval=retry_interval, + json_response=json_response, + stateless=stateless_http, + ) + async with ( + server._lifespan_manager(), + streamable_http_app.session_manager.run(), + ): yield # Create and return the app with lifespan diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index b8c290954..cb2b49866 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -480,14 +480,15 @@ class ResponseCachingMiddleware(Middleware): return cached_value.unwrap() value: PromptResult = await call_next(context=context) + cached_value = CachablePromptResult.wrap(value) await self._get_prompt_cache.put( key=cache_key, - value=CachablePromptResult.wrap(value), + value=cached_value, ttl=self._get_prompt_settings.get("ttl", ONE_HOUR_IN_SECONDS), ) - return value + return cached_value.unwrap() def _matches_tool_cache_settings(self, tool_name: str) -> bool: """Check if the tool matches the cache settings for tool calls.""" diff --git a/src/fastmcp/server/middleware/response_limiting.py b/src/fastmcp/server/middleware/response_limiting.py index 3afaf0705..24e1cbc12 100644 --- a/src/fastmcp/server/middleware/response_limiting.py +++ b/src/fastmcp/server/middleware/response_limiting.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any import mcp.types as mt import pydantic_core @@ -67,7 +68,11 @@ class ResponseLimitingMiddleware(Middleware): self.truncation_suffix = truncation_suffix self.tools = set(tools) if tools is not None else None - def _truncate_to_result(self, text: str) -> ToolResult: + def _truncate_to_result( + self, + text: str, + meta: dict[str, Any] | None = None, + ) -> ToolResult: """Truncate text to fit within max_size and wrap in ToolResult.""" suffix_bytes = len(self.truncation_suffix.encode("utf-8")) # Account for JSON wrapper overhead: {"content":[{"type":"text","text":"..."}]} @@ -88,7 +93,14 @@ class ResponseLimitingMiddleware(Middleware): + self.truncation_suffix ) - return ToolResult(content=[TextContent(type="text", text=truncated)]) + # Preserve original meta, falling back to {} when absent. Having + # meta set ensures to_mcp_result() returns a CallToolResult, which + # bypasses MCP SDK outputSchema validation — a truncated response + # is no longer valid structured output. + return ToolResult( + content=[TextContent(type="text", text=truncated)], + meta=meta if meta is not None else {}, + ) async def on_call_tool( self, @@ -122,4 +134,4 @@ class ResponseLimitingMiddleware(Middleware): else serialized.decode("utf-8", errors="replace") ) - return self._truncate_to_result(text) + return self._truncate_to_result(text, meta=result.meta) diff --git a/src/fastmcp/server/mixins/lifespan.py b/src/fastmcp/server/mixins/lifespan.py index a6c0cb9ed..508041009 100644 --- a/src/fastmcp/server/mixins/lifespan.py +++ b/src/fastmcp/server/mixins/lifespan.py @@ -90,14 +90,12 @@ class LifespanMixin: name=settings.docket.name, url=settings.docket.url, ) as docket: - # Store on server instance for cross-task access (FastMCPTransport) self._docket = docket # Register task-enabled components with Docket for component in task_components: component.register_with_docket(docket) - # Set Docket in ContextVar so CurrentDocket can access it docket_token = _current_docket.set(docket) try: # Build worker kwargs from settings @@ -112,9 +110,7 @@ class LifespanMixin: # Create and start Worker async with Worker(docket, **worker_kwargs) as worker: - # Store on server instance for cross-context access self._worker = worker - # Set Worker in ContextVar so CurrentWorker can access it worker_token = _current_worker.set(worker) try: worker_task = asyncio.create_task(worker.run_forever()) @@ -128,9 +124,7 @@ class LifespanMixin: _current_worker.reset(worker_token) self._worker = None finally: - # Reset ContextVar _current_docket.reset(docket_token) - # Clear instance attribute self._docket = None finally: # Reset server ContextVar diff --git a/src/fastmcp/server/mixins/mcp_operations.py b/src/fastmcp/server/mixins/mcp_operations.py index df001376a..70bd65607 100644 --- a/src/fastmcp/server/mixins/mcp_operations.py +++ b/src/fastmcp/server/mixins/mcp_operations.py @@ -217,15 +217,12 @@ class MCPOperationsMixin: # fn_key is set by call_tool() after finding the tool. version_str: str | None = None task_meta: TaskMeta | None = None - app_name: str | None = None try: ctx = server._mcp_server.request_context - # Extract version and app name from _meta.fastmcp + # Extract version from _meta.fastmcp if ctx.meta: meta_dict = ctx.meta.model_dump(exclude_none=True) - fastmcp_meta = meta_dict.get("fastmcp", {}) - version_str = fastmcp_meta.get("version") - app_name = fastmcp_meta.get("app") + version_str = meta_dict.get("fastmcp", {}).get("version") # Extract SEP-1686 task metadata if ctx.experimental.is_task: mcp_task_meta = ctx.experimental.task_metadata @@ -236,7 +233,7 @@ class MCPOperationsMixin: version = VersionSpec(eq=version_str) if version_str else None result = await server.call_tool( - key, arguments, version=version, task_meta=task_meta, app_name=app_name + key, arguments, version=version, task_meta=task_meta ) if isinstance(result, mcp.types.CreateTaskResult): diff --git a/src/fastmcp/server/mixins/transport.py b/src/fastmcp/server/mixins/transport.py index 10223f38a..3c87a23e2 100644 --- a/src/fastmcp/server/mixins/transport.py +++ b/src/fastmcp/server/mixins/transport.py @@ -263,9 +263,11 @@ class TransportMixin: if stateless_http and transport == "sse": raise ValueError("SSE transport does not support stateless mode") - host = host or fastmcp.settings.host - port = port or fastmcp.settings.port - default_log_level_to_use = (log_level or fastmcp.settings.log_level).lower() + host = host if host is not None else fastmcp.settings.host + port = port if port is not None else fastmcp.settings.port + default_log_level_to_use = ( + log_level if log_level is not None else fastmcp.settings.log_level + ).lower() app = self.http_app( path=path, @@ -335,7 +337,9 @@ class TransportMixin: if transport in ("streamable-http", "http"): return create_streamable_http_app( server=self, - streamable_http_path=path or fastmcp.settings.streamable_http_path, + streamable_http_path=path + if path is not None + else fastmcp.settings.streamable_http_path, event_store=event_store, retry_interval=retry_interval, auth=self.auth, @@ -356,7 +360,7 @@ class TransportMixin: return create_sse_app( server=self, message_path=fastmcp.settings.message_path, - sse_path=path or fastmcp.settings.sse_path, + sse_path=path if path is not None else fastmcp.settings.sse_path, auth=self.auth, debug=fastmcp.settings.debug, middleware=middleware, diff --git a/src/fastmcp/server/providers/addressing.py b/src/fastmcp/server/providers/addressing.py new file mode 100644 index 000000000..71a70a077 --- /dev/null +++ b/src/fastmcp/server/providers/addressing.py @@ -0,0 +1,69 @@ +"""Deterministic tool hashing for backend-tool routing and per-tool resources. + +Each FastMCPApp backend tool gets a deterministic hash computed from its +app name + tool name. The hash serves two purposes: + +1. **Backend-tool routing.** Tools with ``"app"`` in their visibility are + callable via ``_``. The dispatcher parses the prefix, + then walks providers recursively (same pattern as the old ``get_app_tool``) + to find a tool whose stored hash matches. + +2. **Per-tool Prefab renderer URIs.** Each prefab tool gets a unique renderer + resource at ``ui://prefab/tool//renderer.html``. ``list_resources`` + 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"]``. +Deterministic across replicas (same code → same hash), no registry walk +needed. +""" + +from __future__ import annotations + +import hashlib + +#: Length of the hex hash prefix used in URIs and backend-tool names. +HASH_LENGTH = 12 + + +def hash_tool(app_name: str, tool_name: str) -> str: + """Deterministic hex hash for a tool in an app. + + Same inputs on every replica produce the same output. + """ + payload = f"{app_name}\x00{tool_name}".encode() + return hashlib.sha256(payload).hexdigest()[:HASH_LENGTH] + + +def hashed_backend_name(app_name: str, tool_name: str) -> str: + """Format the universal name for a backend tool: ``_``.""" + return f"{hash_tool(app_name, tool_name)}_{tool_name}" + + +def parse_hashed_backend_name(name: str) -> tuple[str, str] | None: + """Parse ``_`` → ``(hash, local_tool_name)`` or None.""" + if len(name) <= HASH_LENGTH + 1: + return None + prefix = name[:HASH_LENGTH] + if name[HASH_LENGTH] != "_": + return None + if not all(c in "0123456789abcdef" for c in prefix): + return None + return prefix, name[HASH_LENGTH + 1 :] + + +def hashed_resource_uri(app_name: str, tool_name: str) -> str: + """Per-tool Prefab renderer resource URI.""" + return f"ui://prefab/tool/{hash_tool(app_name, tool_name)}/renderer.html" + + +def parse_hashed_resource_uri(uri: str) -> str | None: + """Extract the hash from a Prefab renderer URI, or None.""" + prefix = "ui://prefab/tool/" + suffix = "/renderer.html" + if not uri.startswith(prefix) or not uri.endswith(suffix): + return None + h = uri[len(prefix) : -len(suffix)] + if len(h) != HASH_LENGTH or not all(c in "0123456789abcdef" for c in h): + return None + return h diff --git a/src/fastmcp/server/providers/aggregate.py b/src/fastmcp/server/providers/aggregate.py index c881595e5..5d6b8ce01 100644 --- a/src/fastmcp/server/providers/aggregate.py +++ b/src/fastmcp/server/providers/aggregate.py @@ -106,16 +106,37 @@ class AggregateProvider(Provider): def _collect_list_results( self, results: list[Sequence[T] | BaseException], operation: str ) -> list[T]: - """Collect successful list results, logging any exceptions.""" + """Collect successful list results, logging any exceptions. + + Emits a warning when the same MCP identity is returned by more than + one provider — surfaces composition mistakes to the server author. + This is always a warning: cross-provider collisions happen at runtime + (sometimes dynamically), so an errorable/strict mode would give the + author no way to react and would crash list calls in production. + """ collected: list[T] = [] + # FastMCPComponent.key encodes type, identifier, and version — + # so version variants of the same component are NOT reported as + # collisions (matching _get_highest_version_result behavior). + seen_keys: dict[str, int] = {} for i, result in enumerate(results): if isinstance(result, BaseException): - logger.debug( + logger.warning( f"Error during {operation} from provider " f"{self.providers[i]}: {result}" ) continue - collected.extend(result) + for item in result: + key = getattr(item, "key", None) + if key is not None: + first = seen_keys.setdefault(key, i) + if first != i: + logger.warning( + f"Duplicate {operation} component {key!r} " + f"from provider {self.providers[i]} " + f"(first seen from provider {self.providers[first]})" + ) + collected.append(item) return collected def _get_highest_version_result( @@ -132,7 +153,7 @@ class AggregateProvider(Provider): for i, result in enumerate(results): if isinstance(result, BaseException): if not isinstance(result, NotFoundError): - logger.debug( + logger.warning( f"Error during {operation} from provider " f"{self.providers[i]}: {result}" ) @@ -181,6 +202,19 @@ class AggregateProvider(Provider): return r 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.""" + results = await gather( + *[p.get_tool_by_hash(tool_hash, tool_name) for p in self.providers], + return_exceptions=True, + ) + for r in results: + if isinstance(r, BaseException): + continue + if r is not None: + return r + return None + # ------------------------------------------------------------------------- # Resources # ------------------------------------------------------------------------- diff --git a/src/fastmcp/server/providers/base.py b/src/fastmcp/server/providers/base.py index 2ac2e2643..e2429ee93 100644 --- a/src/fastmcp/server/providers/base.py +++ b/src/fastmcp/server/providers/base.py @@ -178,14 +178,8 @@ class Provider: async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: """Look up an app-visible tool by original name, bypassing transforms. - This is the routing path for tool calls from app UIs (identified by - ``_meta.fastmcp.app`` on the request). It skips the transform chain - entirely — the tool is found by its registered name and matched - against the app identity in its metadata. - - The default implementation checks this provider's own storage via - ``_get_tool``. Aggregate and wrapped providers override to - delegate to children. + Searches for a tool named ``tool_name`` tagged with the given app + name. Skips the transform chain entirely. Returns: The tool if found and tagged with the given app name, else None. @@ -207,6 +201,29 @@ class Provider: return tool return None + async def get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None: + """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. + Used by the dispatcher when receiving hashed backend-tool calls. + """ + tool = await self._get_tool(tool_name) + if tool is not None: + meta = tool.meta or {} + fastmcp_meta = meta.get("fastmcp") + ui_meta = meta.get("ui") + visibility = ( + ui_meta.get("visibility", []) if isinstance(ui_meta, dict) else [] + ) + if ( + isinstance(fastmcp_meta, dict) + and fastmcp_meta.get("_tool_hash") == tool_hash + and "app" in visibility + ): + return tool + return None + async def list_resources(self) -> Sequence[Resource]: """List resources with all transforms applied. diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index 953daf2b8..c04741eae 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -10,18 +10,16 @@ executed. from __future__ import annotations -import re from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any, overload -from urllib.parse import quote import mcp.types from mcp.types import AnyUrl from fastmcp.prompts.base import Prompt, PromptResult from fastmcp.resources.base import Resource, ResourceResult -from fastmcp.resources.template import ResourceTemplate +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 @@ -36,34 +34,6 @@ if TYPE_CHECKING: from fastmcp.server.server import FastMCP -def _expand_uri_template(template: str, params: dict[str, Any]) -> str: - """Expand a URI template with parameters. - - Handles both {name} path placeholders and RFC 6570 {?param1,param2} - query parameter syntax. - """ - result = template - - # Replace {name} path placeholders - for key, value in params.items(): - result = re.sub(rf"\{{{key}\}}", str(value), result) - - # Expand {?param1,param2,...} query parameter blocks - def _expand_query_block(match: re.Match[str]) -> str: - names = [n.strip() for n in match.group(1).split(",")] - parts = [] - for name in names: - if name in params: - parts.append(f"{quote(name)}={quote(str(params[name]))}") - if parts: - return "?" + "&".join(parts) - return "" - - result = re.sub(r"\{\?([^}]+)\}", _expand_query_block, result) - - return result - - # ----------------------------------------------------------------------------- # FastMCPProvider component classes # ----------------------------------------------------------------------------- @@ -138,14 +108,6 @@ class FastMCPProviderTool(Tool): # Pass exact version so child executes the correct version version = VersionSpec(eq=self.version) if self.version else None - # If this tool belongs to a FastMCPApp, pass app_name so the - # child server routes via get_app_tool (bypassing transforms). - app_name: str | None = None - meta = self.meta or {} - fastmcp_meta = meta.get("fastmcp") - if isinstance(fastmcp_meta, dict): - app_name = fastmcp_meta.get("app") - with delegate_span( self._original_name or "", "FastMCPProvider", self._original_name or "" ): @@ -154,7 +116,6 @@ class FastMCPProviderTool(Tool): arguments, version=version, task_meta=task_meta, - app_name=app_name, ) async def run(self, arguments: dict[str, Any]) -> ToolResult: @@ -166,14 +127,8 @@ class FastMCPProviderTool(Tool): # Pass exact version so child executes the correct version version = VersionSpec(eq=self.version) if self.version else None - app_name: str | None = None - meta = self.meta or {} - fastmcp_meta = meta.get("fastmcp") - if isinstance(fastmcp_meta, dict): - app_name = fastmcp_meta.get("app") - result = await self._server.call_tool( - self._original_name, arguments, version=version, app_name=app_name + self._original_name, arguments, version=version ) # Result from call_tool should always be ToolResult when no task_meta if isinstance(result, mcp.types.CreateTaskResult): @@ -409,7 +364,7 @@ class FastMCPProviderResourceTemplate(ResourceTemplate): URI that the nested server understands. """ # Expand the original template with params to get internal URI - original_uri = _expand_uri_template(self._original_uri_template or "", params) + original_uri = expand_uri_template(self._original_uri_template or "", params) return FastMCPProviderResource( server=self._server, original_uri=original_uri, @@ -439,7 +394,7 @@ class FastMCPProviderResourceTemplate(ResourceTemplate): 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) + original_uri = expand_uri_template(self._original_uri_template or "", params) # Pass exact version so child reads the correct version version = VersionSpec(eq=self.version) if self.version else None @@ -458,9 +413,7 @@ class FastMCPProviderResourceTemplate(ResourceTemplate): 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 - ) + 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 @@ -586,7 +539,20 @@ class FastMCPProvider(Provider): raw_tool = await self.server.get_app_tool(app_name, tool_name) if raw_tool is None: return None - return FastMCPProviderTool.wrap(self.server, raw_tool) + wrapped = FastMCPProviderTool.wrap(self.server, raw_tool) + from fastmcp.server.providers.addressing import hashed_backend_name + + wrapped._original_name = hashed_backend_name(app_name, tool_name) + return wrapped + + async def get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None: + """Delegate to nested server's get_tool_by_hash, wrapping for middleware.""" + raw_tool = await self.server.get_tool_by_hash(tool_hash, tool_name) + if raw_tool is None: + return None + wrapped = FastMCPProviderTool.wrap(self.server, raw_tool) + wrapped._original_name = f"{tool_hash}_{tool_name}" + return wrapped # ------------------------------------------------------------------------- # Resource methods diff --git a/src/fastmcp/server/providers/local_provider/decorators/tools.py b/src/fastmcp/server/providers/local_provider/decorators/tools.py index e79cc2c65..e7402dced 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/tools.py +++ b/src/fastmcp/server/providers/local_provider/decorators/tools.py @@ -73,60 +73,31 @@ def _has_prefab_return_type(tool: Tool) -> bool: return _is_prefab_type(rt) -def _ensure_prefab_renderer(provider: LocalProvider) -> None: - """Lazily register the shared prefab renderer as a ui:// resource.""" - from prefab_ui.renderer import get_renderer_csp, get_renderer_html +def _stamp_prefab_marker(tool: Tool) -> None: + """Mark a tool as needing a Prefab renderer resource. - from fastmcp.apps.config import ( - UI_MIME_TYPE, - AppConfig, - ResourceCSP, - app_config_to_meta_dict, - ) - from fastmcp.resources.types import TextResource + Sets ``meta["ui"]["resourceUri"]`` to a placeholder URI. The server + recognizes the placeholder at list_tools / list_resources / read_resource + time and synthesizes a per-tool resource on the fly with a hashed URI + derived from the tool's mount-point address. Nothing is stored — the + renderer HTML and CSP are generated on demand from the tool's own meta. + """ + from fastmcp.apps.config import AppConfig, app_config_to_meta_dict - renderer_key = f"resource:{PREFAB_RENDERER_URI}@" - if renderer_key in provider._components: - return - - csp = get_renderer_csp() - resource_app = AppConfig( - csp=ResourceCSP( - resource_domains=csp.get("resource_domains"), - connect_domains=csp.get("connect_domains"), - ) - ) - resource = TextResource( - uri=PREFAB_RENDERER_URI, # type: ignore[arg-type] # AnyUrl accepts ui:// scheme at runtime # ty:ignore[invalid-argument-type] - name="Prefab Renderer", - text=get_renderer_html(), - mime_type=UI_MIME_TYPE, - meta={"ui": app_config_to_meta_dict(resource_app)}, - ) - provider._add_component(resource) - - -def _expand_prefab_ui_meta(tool: Tool) -> None: - """Expand meta["ui"] = True into the full AppConfig dict for a prefab tool.""" - from prefab_ui.renderer import get_renderer_csp - - from fastmcp.apps.config import AppConfig, ResourceCSP, app_config_to_meta_dict - - csp = get_renderer_csp() - app_config = AppConfig( - resource_uri=PREFAB_RENDERER_URI, - csp=ResourceCSP( - resource_domains=csp.get("resource_domains"), - connect_domains=csp.get("connect_domains"), - ), - ) + app_config = AppConfig(resource_uri=PREFAB_RENDERER_URI) meta = dict(tool.meta) if tool.meta else {} meta["ui"] = app_config_to_meta_dict(app_config) tool.meta = meta def _maybe_apply_prefab_ui(provider: LocalProvider, tool: Tool) -> None: - """Auto-wire prefab UI metadata and renderer resource if needed.""" + """Mark a tool as a Prefab tool if its config or return type implies it. + + Per-tool renderer resources are synthesized lazily at list/read time; + here we only normalize the tool's meta so the synthesis pass can spot + it. ``app=True``, return-type inference, and ``PrefabAppConfig`` all + funnel through the same placeholder marker. + """ if not _HAS_PREFAB: return @@ -134,17 +105,14 @@ def _maybe_apply_prefab_ui(provider: LocalProvider, tool: Tool) -> None: ui = meta.get("ui") if ui is True: - # Explicit app=True: expand to full AppConfig and register renderer - _ensure_prefab_renderer(provider) - _expand_prefab_ui_meta(tool) + # 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): - # Inference: return type is a prefab type, auto-wire - _ensure_prefab_renderer(provider) - _expand_prefab_ui_meta(tool) - elif isinstance(ui, dict) and ui.get("resourceUri") == PREFAB_RENDERER_URI: - # PrefabAppConfig or manual config pointing to the Prefab renderer — - # ensure the renderer resource is registered (CSP already set by caller) - _ensure_prefab_renderer(provider) + # Inference: return type is a prefab type, stamp the placeholder. + _stamp_prefab_marker(tool) + # Otherwise the tool either has no ui meta at all (not a prefab tool) + # or it already has a fully-formed dict from FastMCP.tool(app=...) — the + # synthesizer picks up both flavors by looking for the placeholder URI. class ToolDecoratorMixin: diff --git a/src/fastmcp/server/providers/openapi/provider.py b/src/fastmcp/server/providers/openapi/provider.py index bc826d1df..dd9a73559 100644 --- a/src/fastmcp/server/providers/openapi/provider.py +++ b/src/fastmcp/server/providers/openapi/provider.py @@ -175,6 +175,9 @@ class OpenAPIProvider(Provider): "entry to the spec or provide an httpx.AsyncClient explicitly." ) base_url = servers[0]["url"] + variables = servers[0].get("variables", {}) + for name, var in variables.items(): + base_url = base_url.replace(f"{{{name}}}", var.get("default", "")) return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT) @asynccontextmanager diff --git a/src/fastmcp/server/providers/prefab_synthesis.py b/src/fastmcp/server/providers/prefab_synthesis.py new file mode 100644 index 000000000..ddbf83248 --- /dev/null +++ b/src/fastmcp/server/providers/prefab_synthesis.py @@ -0,0 +1,245 @@ +"""On-demand Prefab renderer resource synthesis. + +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 +resources on demand at ``list_resources`` and ``read_resource`` time +without storing or materializing anything. + +Each tool's resource URI is ``ui://prefab/tool//renderer.html`` +where the hash comes from the tool's own meta (set at registration from +the app name + tool name). CSP on the resource is the tool's +``meta.ui.csp`` merged with the renderer defaults across all four +``*_domains`` fields. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from fastmcp.server.providers.addressing import ( + HASH_LENGTH, + hash_tool, + parse_hashed_resource_uri, +) + +if TYPE_CHECKING: + from fastmcp.resources.base import Resource + from fastmcp.server.server import FastMCP + from fastmcp.tools.base import Tool + +#: The placeholder URI that decorators stamp on tools needing a renderer. +PREFAB_PLACEHOLDER_URI = "ui://prefab/renderer.html" + + +def _is_prefab_tool(tool: Tool) -> bool: + """True if *tool* was marked as needing a Prefab renderer at registration.""" + meta = tool.meta + if not meta: + return False + ui = meta.get("ui") + if not isinstance(ui, dict): + return False + return ui.get("resourceUri") == PREFAB_PLACEHOLDER_URI + + +def _get_tool_hash(tool: Tool) -> str | None: + """Read the stored hash from tool meta, or compute from app name + tool name.""" + meta = tool.meta or {} + fastmcp_meta = meta.get("fastmcp") + if isinstance(fastmcp_meta, dict): + h = fastmcp_meta.get("_tool_hash") + if isinstance(h, str) and len(h) == HASH_LENGTH: + return h + # Fall back to computing from app name + app = fastmcp_meta.get("app") + if isinstance(app, str): + return hash_tool(app, tool.name) + # Root-level prefab tool (no app name) — hash from empty prefix. + return hash_tool("", tool.name) + + +def _merge_domain_lists( + base: list[str] | None, extra: list[str] | None +) -> list[str] | None: + if base is None and extra is None: + return None + combined = list(base or []) + for item in extra or []: + if item not in combined: + combined.append(item) + return combined or None + + +def _build_resource_for_tool(tool: Tool) -> Resource | None: + """Synthesize a TextResource for a prefab tool. Returns None if prefab_ui isn't installed.""" + try: + from prefab_ui.renderer import get_renderer_csp, get_renderer_html + except ImportError: + return None + + from fastmcp.apps.config import ( + UI_MIME_TYPE, + AppConfig, + ResourceCSP, + app_config_to_meta_dict, + ) + from fastmcp.resources.types import TextResource + + tool_hash = _get_tool_hash(tool) + if tool_hash is None: + return None + + # Merge user CSP with renderer defaults — all four domain fields. + defaults: dict[str, Any] = get_renderer_csp() or {} + user_csp: dict[str, Any] = {} + if tool.meta and isinstance(tool.meta.get("ui"), dict): + raw = tool.meta["ui"].get("csp") + if isinstance(raw, dict): + user_csp = raw + + def _get(d: dict[str, Any], snake: str, camel: str) -> list[str] | None: + val = d.get(snake) + if val is None: + val = d.get(camel) + return val if isinstance(val, list) else None + + merged = { + "connect_domains": _merge_domain_lists( + defaults.get("connect_domains"), + _get(user_csp, "connect_domains", "connectDomains"), + ), + "resource_domains": _merge_domain_lists( + defaults.get("resource_domains"), + _get(user_csp, "resource_domains", "resourceDomains"), + ), + "frame_domains": _merge_domain_lists( + defaults.get("frame_domains"), + _get(user_csp, "frame_domains", "frameDomains"), + ), + "base_uri_domains": _merge_domain_lists( + defaults.get("base_uri_domains"), + _get(user_csp, "base_uri_domains", "baseUriDomains"), + ), + } + + resource_csp = ResourceCSP(**merged) if any(merged.values()) else None + + # Carry permissions from the tool's meta to the resource (same + # principle as CSP — belongs on the resource, not the tool). + user_permissions = None + if tool.meta and isinstance(tool.meta.get("ui"), dict): + raw_perms = tool.meta["ui"].get("permissions") + if isinstance(raw_perms, dict): + from fastmcp.apps.config import ResourcePermissions + + user_permissions = ResourcePermissions(**raw_perms) + + resource_app = AppConfig( + csp=resource_csp, + permissions=user_permissions, + ) + uri = f"ui://prefab/tool/{tool_hash}/renderer.html" + + return TextResource( + uri=uri, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + name=f"Prefab Renderer ({tool.name})", + text=get_renderer_html(), + mime_type=UI_MIME_TYPE, + meta={"ui": app_config_to_meta_dict(resource_app)}, + ) + + +def _walk_prefab_tools(server: FastMCP) -> list[Tool]: + """Enumerate all prefab tools across the server's providers (sync walk of _components).""" + from fastmcp.apps.app import FastMCPApp + from fastmcp.server.providers.base import Provider + from fastmcp.server.providers.local_provider import LocalProvider + from fastmcp.server.providers.wrapped_provider import _WrappedProvider + from fastmcp.tools.base import Tool + + results: list[Tool] = [] + + def _walk_provider(provider: Provider) -> None: + # Unwrap transform wrappers + inner = provider + while isinstance(inner, _WrappedProvider): + inner = inner._inner + + # Extract tools from local storage + sources: list[LocalProvider] = [] + if isinstance(inner, LocalProvider): + sources.append(inner) + if isinstance(inner, FastMCPApp): + sources.append(inner._local) + for src in sources: + for component in src._components.values(): + if isinstance(component, Tool) and _is_prefab_tool(component): + results.append(component) + + # Recurse into aggregate children + from fastmcp.server.providers.aggregate import AggregateProvider + from fastmcp.server.providers.fastmcp_provider import FastMCPProvider + + if isinstance(inner, AggregateProvider): + for child in inner.providers: + _walk_provider(child) + # Recurse into mounted FastMCP servers + if isinstance(inner, FastMCPProvider): + for child in inner.server.providers: + _walk_provider(child) + + for provider in server.providers: + _walk_provider(provider) + + return results + + +async def synthesize_prefab_resources(server: FastMCP) -> list[Resource]: + """Return fresh synthetic Prefab resources for all prefab tools. Pure.""" + resources: list[Resource] = [] + seen_hashes: set[str] = set() + for tool in _walk_prefab_tools(server): + h = _get_tool_hash(tool) + if h is None or h in seen_hashes: + continue + seen_hashes.add(h) + resource = _build_resource_for_tool(tool) + if resource is not None: + resources.append(resource) + return resources + + +async def synthesize_prefab_resource_by_uri( + server: FastMCP, uri: str +) -> Resource | None: + """Intercept a Prefab renderer URI and synthesize on demand.""" + digest = parse_hashed_resource_uri(uri) + if digest is None: + return None + for tool in _walk_prefab_tools(server): + if _get_tool_hash(tool) == digest: + return _build_resource_for_tool(tool) + return None + + +def rewrite_tool_meta_for_wire(tool: Tool) -> Tool: + """Return a model_copy with the per-tool URI and CSP stripped. + + Reads the hash from the tool's own meta. If no hash is found, + returns the tool unchanged. Produces a fresh copy — the original + Tool object is untouched. + """ + if not _is_prefab_tool(tool): + return tool + tool_hash = _get_tool_hash(tool) + if tool_hash is None: + return tool + assert tool.meta is not None + new_ui = dict(tool.meta["ui"]) + new_ui["resourceUri"] = f"ui://prefab/tool/{tool_hash}/renderer.html" + new_ui.pop("csp", None) + new_ui.pop("permissions", None) + new_meta = dict(tool.meta) + new_meta["ui"] = new_ui + return tool.model_copy(update={"meta": new_meta}) diff --git a/src/fastmcp/server/providers/proxy.py b/src/fastmcp/server/providers/proxy.py index f2df251cf..b59024c70 100644 --- a/src/fastmcp/server/providers/proxy.py +++ b/src/fastmcp/server/providers/proxy.py @@ -108,6 +108,7 @@ class ProxyTool(Tool): icons=mcp_tool.icons, meta=mcp_tool.meta, tags=get_fastmcp_metadata(mcp_tool.meta).get("tags", []), + execution=mcp_tool.execution, ) async def run( @@ -751,6 +752,15 @@ def _create_client_factory( """ if isinstance(target, Client): client = target + + # 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 + + if isinstance(client.transport, StreamableHttpTransport | SSETransport): + client.transport.forward_incoming_headers = True + if client.is_connected() and type(client) is ProxyClient: logger.info( "Proxy detected connected ProxyClient - creating fresh sessions for each " @@ -996,6 +1006,15 @@ class ProxyClient(Client[ClientTransportT]): kwargs["progress_handler"] = default_proxy_progress_handler super().__init__(**kwargs | {"transport": transport}) + # 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 + class StatefulProxyClient(ProxyClient[ClientTransportT]): """A proxy client that provides a stateful client factory for the proxy server. diff --git a/src/fastmcp/server/providers/wrapped_provider.py b/src/fastmcp/server/providers/wrapped_provider.py index 3ce097fff..f33e9d539 100644 --- a/src/fastmcp/server/providers/wrapped_provider.py +++ b/src/fastmcp/server/providers/wrapped_provider.py @@ -67,6 +67,10 @@ class _WrappedProvider(Provider): """Delegate to inner, bypassing this wrapper's transforms.""" return await self._inner.get_app_tool(app_name, tool_name) + async def get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None: + """Delegate to inner, bypassing this wrapper's transforms.""" + return await self._inner.get_tool_by_hash(tool_hash, tool_name) + async def _list_resources(self) -> Sequence[Resource]: """Delegate to inner's list_resources (includes inner's transforms).""" return await self._inner.list_resources() diff --git a/src/fastmcp/server/sampling/run.py b/src/fastmcp/server/sampling/run.py index 4ea31f0b6..0fe55ef0f 100644 --- a/src/fastmcp/server/sampling/run.py +++ b/src/fastmcp/server/sampling/run.py @@ -46,9 +46,16 @@ if TYPE_CHECKING: 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]): @@ -611,6 +618,9 @@ async def sample_impl( # 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, @@ -626,9 +636,11 @@ async def sample_impl( ) # 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) @@ -655,6 +667,13 @@ async def sample_impl( 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( @@ -678,15 +697,36 @@ async def sample_impl( ) ) + # 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: - raise RuntimeError( - f"Expected structured output of type {result_type.__name__}, " - "but the LLM returned a text response instead of calling " - "the final_response tool." + 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 ""), diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py index 7f9354bbb..217d2f21f 100644 --- a/src/fastmcp/server/sampling/sampling_tool.py +++ b/src/fastmcp/server/sampling/sampling_tool.py @@ -116,7 +116,7 @@ class SamplingTool(FastMCPBaseModel): return cls( name=name or parsed.name, - description=description or parsed.description, + description=description if description is not None else parsed.description, parameters=parsed.input_schema, fn=parsed.fn, sequential=sequential, diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index a08da16a8..967ef1ceb 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging import re import secrets import warnings @@ -100,6 +101,19 @@ if TYPE_CHECKING: logger = get_logger(__name__) + +# The MCP SDK warns "Tool X not listed, no validation will be performed" +# for every call to app-only tools (hidden from list_tools by design). +# This fires even when validate_input=False. Suppress it. +class _SuppressUnlistedToolWarning(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + return "not listed, no validation" not in record.getMessage() + + +logging.getLogger("mcp.server.lowlevel.server").addFilter( + _SuppressUnlistedToolWarning() +) + F = TypeVar("F", bound=Callable[..., Any]) DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] @@ -194,6 +208,31 @@ def _is_model_visible(tool: Tool) -> bool: return "model" in visibility +def _is_app_visible(tool: Tool) -> bool: + """Check whether a tool has explicitly opted into app-callable visibility. + + Gates the dispatcher's hashed-name routing path: only tools whose + ``meta.ui.visibility`` list contains ``"app"`` can be reached via + ``_`` calls. Tools without an explicit visibility + declaration are NOT app-callable — they must be reached by their + display name through the normal transform-aware resolution path. + + This is the inverse of the "everything is dot-callable" trap: the + hashed-name path is an opt-in mechanism for FastMCPApp backend tools, + not a general bypass for arbitrary tools. + """ + meta = tool.meta + if not meta: + return False + ui = meta.get("ui") + if not isinstance(ui, dict): + return False + visibility = ui.get("visibility") + if not isinstance(visibility, list): + return False + return "app" in visibility + + @asynccontextmanager async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]: """Default lifespan context manager that does nothing. @@ -223,7 +262,7 @@ def _lifespan_proxy( if not fastmcp_server._lifespan_result_set: raise RuntimeError( "FastMCP server has a lifespan defined but no lifespan result is set, which means the server's context manager was not entered. " - + " Are you running the server in a way that supports lifespans? If so, please file an issue at https://github.com/PrefectHQ/fastmcp/issues." + " Are you running the server in a way that supports lifespans? If so, please file an issue at https://github.com/PrefectHQ/fastmcp/issues." ) yield fastmcp_server._lifespan_result # ty:ignore[invalid-yield] @@ -455,6 +494,24 @@ class FastMCP( """ super().add_provider(provider, namespace=namespace) + def _rewrite_prefab_uris(self, tools: list[Tool]) -> list[Tool]: + """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`` + 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. + """ + from fastmcp.server.providers.prefab_synthesis import ( + _is_prefab_tool, + rewrite_tool_meta_for_wire, + ) + + return [ + rewrite_tool_meta_for_wire(t) if _is_prefab_tool(t) else t for t in tools + ] + # ------------------------------------------------------------------------- # Provider interface overrides - inherited from AggregateProvider # ------------------------------------------------------------------------- @@ -569,6 +626,13 @@ class FastMCP( tools = await apply_session_transforms(tools) tools = [t for t in tools if is_enabled(t) and _is_model_visible(t)] + # Rewrite per-tool Prefab renderer URIs based on the tool's + # mount-point address. The walk pairs each tool with the + # provider that yielded it, computes the hashed URI, and + # produces a model_copy with the URI in place. Original + # Tool objects are not mutated. + tools = self._rewrite_prefab_uris(tools) + skip_auth, token = _get_auth_context() authorized: list[Tool] = [] for tool in tools: @@ -695,6 +759,15 @@ class FastMCP( resources = await apply_session_transforms(resources) resources = [r for r in resources if is_enabled(r)] + # Append synthetic Prefab renderer resources — one per + # prefab tool, hashed by mount address. These don't live on + # any provider's storage; they're computed on demand. + from fastmcp.server.providers.prefab_synthesis import ( + synthesize_prefab_resources, + ) + + resources.extend(await synthesize_prefab_resources(self)) + skip_auth, token = _get_auth_context() authorized: list[Resource] = [] for resource in resources: @@ -1046,7 +1119,6 @@ class FastMCP( version: VersionSpec | None = None, run_middleware: bool = True, task_meta: None = None, - app_name: str | None = None, ) -> ToolResult: ... @overload @@ -1058,7 +1130,6 @@ class FastMCP( version: VersionSpec | None = None, run_middleware: bool = True, task_meta: TaskMeta, - app_name: str | None = None, ) -> mcp.types.CreateTaskResult: ... async def call_tool( @@ -1069,7 +1140,6 @@ class FastMCP( version: VersionSpec | None = None, run_middleware: bool = True, task_meta: TaskMeta | None = None, - app_name: str | None = None, ) -> ToolResult | mcp.types.CreateTaskResult: """Call a tool by name. @@ -1084,9 +1154,6 @@ class FastMCP( task_meta: If provided, execute as a background task and return CreateTaskResult. If None (default), execute synchronously and return ToolResult. - app_name: If set (from ``_meta.fastmcp.app``), the call originated - from an app UI and should be routed directly to the named app's - tool registry, bypassing transforms. Returns: ToolResult when task_meta is None. @@ -1101,6 +1168,18 @@ class FastMCP( # For mounted servers, the parent's provider sets fn_key to the # namespaced key before delegating, ensuring correct Docket routing. + from fastmcp.server.providers.addressing import ( + parse_hashed_backend_name, + ) + + # Two routing paths: + # 1. Hashed-name path — backend tools that opted into + # app-callable visibility. Recognized by their + # `_` format and resolved via the + # reverse-hash map. Address is known eagerly. + # 2. Display-name path — everything else. Goes through normal + # `get_tool` aggregation/transforms. Address is determined + # after resolution by walking the registry. async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: mw_context = MiddlewareContext[CallToolRequestParams]( @@ -1120,34 +1199,38 @@ class FastMCP( version=version, run_middleware=False, task_meta=task_meta, - app_name=app_name, ), ) - # Core logic: find and execute tool (providers queried in parallel) - # Use get_tool to apply transforms and filter disabled + # Core logic: find and execute tool with server_span( f"tools/call {name}", "tools/call", self.name, "tool", name ) as span: - # If the call came from an app UI (_meta.fastmcp.app), - # look up the tool via get_app_tool which walks the - # provider tree bypassing transforms. Otherwise use - # normal provider resolution. - tool: Tool | None = None - if app_name is not None: - tool = await self.get_app_tool(app_name, name) - if tool is not None: - # Auth still applies to app tools - skip_auth, token = _get_auth_context() - if not skip_auth and tool.auth is not None: - try: - ctx = AuthContext(token=token, component=tool) - if not await run_auth_checks(tool.auth, ctx): - raise NotFoundError(f"Unknown tool: {name!r}") - except AuthorizationError: - raise NotFoundError(f"Unknown tool: {name!r}") from None + # Try normal display-name resolution first. + tool: Tool | None = await self.get_tool(name, version=version) + + # If that fails, try hashed-name dispatch. This walks + # the provider tree recursively (same pattern as the old + # get_app_tool) looking for a tool whose stored hash + # matches the parsed prefix. if tool is None: - tool = await self.get_tool(name, version=version) + hashed = parse_hashed_backend_name(name) + if hashed is not None: + digest, local_name = hashed + tool = await self.get_tool_by_hash(digest, local_name) + if tool is not None: + # Auth still applies on the bypass path. + skip_auth, token = _get_auth_context() + if not skip_auth and tool.auth is not None: + try: + auth_ctx = AuthContext(token=token, component=tool) + if not await run_auth_checks(tool.auth, auth_ctx): + raise NotFoundError(f"Unknown tool: {name!r}") + except AuthorizationError: + raise NotFoundError( + f"Unknown tool: {name!r}" + ) from None + if tool is None: raise NotFoundError(f"Unknown tool: {name!r}") span.set_attributes(tool.get_span_attributes()) @@ -1265,6 +1348,18 @@ class FastMCP( uri, resource_uri=uri, ) as span: + # Intercept synthetic Prefab renderer URIs before normal + # resolution. The resource isn't stored anywhere — we + # build it on demand from the matching tool's CSP. + from fastmcp.server.providers.prefab_synthesis import ( + synthesize_prefab_resource_by_uri, + ) + + 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(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: @@ -1979,6 +2074,15 @@ class FastMCP( if not isinstance(server, FastMCPProxy): server = FastMCP.as_proxy(server) + # Warn if parent masks errors but child doesn't (or vice versa) + if self._mask_error_details and not server._mask_error_details: + logger.warning( + f"Parent server {self.name!r} has mask_error_details=True but " + f"mounted server {server.name!r} does not. Error details from " + f"{server.name!r} may leak through to clients. Set " + f"mask_error_details=True on the child server to prevent this." + ) + # Create provider and add it with namespace provider: Provider = FastMCPProvider(server) diff --git a/src/fastmcp/server/tasks/capabilities.py b/src/fastmcp/server/tasks/capabilities.py index 48c1f3d71..f30ef58d3 100644 --- a/src/fastmcp/server/tasks/capabilities.py +++ b/src/fastmcp/server/tasks/capabilities.py @@ -1,7 +1,5 @@ """SEP-1686 task capabilities declaration.""" -from importlib.util import find_spec - from mcp.types import ( ServerTasksCapability, ServerTasksRequestsCapability, @@ -12,23 +10,27 @@ from mcp.types import ( ) -def _is_docket_available() -> bool: - """Check if pydocket is installed (local to avoid circular import).""" - return find_spec("docket") is not None - - 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 pydocket is not installed (no task support). + 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. Note: prompts/resources are passed via extra_data since the SDK types don't include them yet (FastMCP supports them ahead of the spec). """ - if not _is_docket_available(): + # 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( diff --git a/src/fastmcp/server/tasks/context.py b/src/fastmcp/server/tasks/context.py new file mode 100644 index 000000000..667d2b213 --- /dev/null +++ b/src/fastmcp/server/tasks/context.py @@ -0,0 +1,389 @@ +"""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, Any + +from fastmcp.server.tasks.keys import parse_task_key, task_redis_prefix + +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(_snapshot_redis_key(task_scope, task_id)) + 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 _set_cached_snapshot(task_id: str, snapshot: TaskContextSnapshot) -> None: + """Cache a snapshot keyed by task_id.""" + _task_snapshot.set((task_id, snapshot)) + + +def _get_cached_snapshot(task_id: str) -> TaskContextSnapshot | None: + """Get cached snapshot if it belongs to this task.""" + 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 _snapshot_redis_key(task_scope: str | None, task_id: str) -> str: + """Build the Redis key suffix for a task snapshot.""" + return f"{task_redis_prefix(task_scope)}:{task_id}:snapshot" + + +async def _load_task_snapshot_async( + task_scope: str | None, task_id: str +) -> TaskContextSnapshot | None: + """Load task context snapshot from Redis (async) and cache it. + + Idempotent — returns the cached value if already loaded for this task. + """ + cached = _get_cached_snapshot(task_id) + if cached is not None: + return cached + + 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 None + + try: + async with docket.redis() as redis: + raw = await redis.get(docket.key(_snapshot_redis_key(task_scope, task_id))) + if raw is None: + return None + snapshot = TaskContextSnapshot.from_json(raw) + _set_cached_snapshot(task_id, snapshot) + return snapshot + except (OSError, json.JSONDecodeError, KeyError, ValueError): + _logger.warning( + "Failed to load task snapshot for %s:%s", + task_scope, + task_id, + exc_info=True, + ) + return None + + +def get_task_session_id() -> str | None: + """Get the session_id for the current background task, if available. + + Loads the task snapshot (from cache or Redis) and returns the session_id + that was captured at task submission time. Returns None if not in a task + context or if the snapshot isn't available. + """ + snapshot = _get_task_snapshot_sync() + return snapshot.session_id if snapshot else None + + +def _get_task_snapshot_sync() -> TaskContextSnapshot | None: + """Get the task snapshot using only sync operations. + + Fallback chain: + 1. ContextVar cache (keyed by task_id, set by async or sync loaders) + 2. Sync Redis GET (works for both memory:// and real Redis) + """ + task_info = get_task_context() + if task_info is None: + return None + + cached = _get_cached_snapshot(task_info.task_id) + if cached is not None: + return cached + + return _load_task_snapshot_sync(task_info.task_scope, task_info.task_id) + + +def _load_task_snapshot_sync( + task_scope: str | None, task_id: str +) -> TaskContextSnapshot | None: + """Load snapshot via sync Redis. + + For memory:// backends (fakeredis), shares the same FakeServer instance + that Docket uses so data is accessible. For real Redis, creates a standard + sync connection. + """ + try: + from docket.dependencies import current_docket as _docket_cv + + docket = _docket_cv.get() + except (LookupError, ImportError): + return None + if docket is None: + return None + + try: + sync_redis = _get_sync_redis(docket.url) + raw = sync_redis.get(docket.key(_snapshot_redis_key(task_scope, task_id))) + if raw is None: + return None + snapshot = TaskContextSnapshot.from_json(raw) + _set_cached_snapshot(task_id, snapshot) + return snapshot + except (OSError, json.JSONDecodeError, KeyError, ValueError, ImportError): + _logger.warning( + "Failed to load task snapshot via sync Redis for %s:%s", + task_scope, + task_id, + exc_info=True, + ) + return None + + +def _get_sync_redis(url: str) -> Any: + """Get a sync Redis client that shares the same backend as Docket. + + For memory:// URLs, connects to the same fakeredis FakeServer instance + so data written by the async Docket client is visible. For real Redis + URLs, creates a standard sync connection. + """ + from docket._redis import get_memory_server + + server = get_memory_server(url) + if server is not None: + from fakeredis import FakeRedis + + return FakeRedis(server=server) + + from redis import Redis + + return Redis.from_url(url) + + +# 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]] = {} + + +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. + """ + _task_sessions[session_id] = weakref.ref(session) + + +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/src/fastmcp/server/tasks/elicitation.py b/src/fastmcp/server/tasks/elicitation.py index cc6ac2624..7b5d76dfd 100644 --- a/src/fastmcp/server/tasks/elicitation.py +++ b/src/fastmcp/server/tasks/elicitation.py @@ -24,21 +24,26 @@ from typing import TYPE_CHECKING, Any, cast 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 -# Redis key patterns for task elicitation state -ELICIT_REQUEST_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:request" -ELICIT_RESPONSE_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:response" -ELICIT_STATUS_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:status" - # 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, @@ -75,26 +80,22 @@ async def elicit_for_task( # Generate a unique request ID for this elicitation request_id = str(uuid.uuid4()) - # Get session ID from task context (authoritative source for background tasks) - # This is extracted from the Docket execution key: {session_id}:{task_id}:... - from fastmcp.server.dependencies import get_task_context - task_context = get_task_context() if task_context is not None: - session_id = task_context.session_id + 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: - # Fallback: try to get from session attribute (shouldn't happen in background) - session_id = getattr(session, "_fastmcp_state_prefix", None) - if session_id is None: - raise RuntimeError( - "Cannot determine session_id for elicitation. " - "This typically means elicit_for_task() was called outside a Docket worker context." - ) + 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 = ELICIT_REQUEST_KEY.format(session_id=session_id, task_id=task_id) - response_key = ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id) - status_key = ELICIT_STATUS_KEY.format(session_id=session_id, task_id=task_id) + request_key, response_key, status_key = _elicit_keys(task_scope, task_id) elicit_request = { "request_id": request_id, @@ -138,6 +139,7 @@ async def elicit_for_task( "taskId": task_id, "status": "input_required", "statusMessage": message, + "task_scope": task_scope, "elicitation": { "requestId": request_id, "message": message, @@ -147,9 +149,12 @@ async def elicit_for_task( }, } - # Push notification to Redis queue (works from any process) - # Server's subscriber loop will forward to client - from fastmcp.server.tasks.notifications import push_notification + 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) @@ -233,7 +238,7 @@ async def elicit_for_task( async def relay_elicitation( session: ServerSession, - session_id: str, + task_scope: str | None, task_id: str, elicitation: dict[str, Any], fastmcp: FastMCP, @@ -247,7 +252,7 @@ async def relay_elicitation( Args: session: MCP ServerSession - session_id: Session identifier + task_scope: Authorization scope for Redis key construction task_id: Background task ID elicitation: Elicitation metadata (message, requestedSchema) fastmcp: FastMCP server instance @@ -259,7 +264,7 @@ async def relay_elicitation( ) await handle_task_input( task_id=task_id, - session_id=session_id, + task_scope=task_scope, action=result.action, content=result.content, fastmcp=fastmcp, @@ -274,7 +279,7 @@ async def relay_elicitation( # Push a cancel response so the worker's BLPOP doesn't block forever success = await handle_task_input( task_id=task_id, - session_id=session_id, + task_scope=task_scope, action="cancel", content=None, fastmcp=fastmcp, @@ -289,7 +294,7 @@ async def relay_elicitation( async def handle_task_input( task_id: str, - session_id: str, + task_scope: str | None, action: str, content: dict[str, Any] | None, fastmcp: FastMCP, @@ -301,7 +306,7 @@ async def handle_task_input( Args: task_id: The background task ID - session_id: The MCP session 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 @@ -313,8 +318,7 @@ async def handle_task_input( if docket is None: return False - response_key = ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id) - status_key = ELICIT_STATUS_KEY.format(session_id=session_id, task_id=task_id) + _, response_key, status_key = _elicit_keys(task_scope, task_id) response = { "action": action, diff --git a/src/fastmcp/server/tasks/handlers.py b/src/fastmcp/server/tasks/handlers.py index 034f133dd..aa9d73bdc 100644 --- a/src/fastmcp/server/tasks/handlers.py +++ b/src/fastmcp/server/tasks/handlers.py @@ -16,12 +16,16 @@ from mcp.types import INTERNAL_ERROR, ErrorData from fastmcp.server.dependencies import ( _current_docket, - get_access_token, get_context, - register_task_server, ) from fastmcp.server.tasks.config import TaskMeta -from fastmcp.server.tasks.keys import build_task_key +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.utilities.logging import get_logger if TYPE_CHECKING: @@ -69,14 +73,20 @@ async def submit_to_docket( # Record creation timestamp per SEP-1686 final spec (line 430) created_at = datetime.now(timezone.utc) - # Get session ID - use "internal" for programmatic calls without MCP session 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 = "internal" + session_id = None - docket = _current_docket.get() + # 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( ErrorData( @@ -92,7 +102,7 @@ async def submit_to_docket( register_task_server(server_task_id, ctx.fastmcp) # Build full task key with embedded metadata - task_key = build_task_key(session_id, server_task_id, task_type, key) + 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: @@ -102,44 +112,27 @@ async def submit_to_docket( ttl_seconds = int(ttl_ms / 1000) + TASK_MAPPING_TTL_BUFFER_SECONDS # Store task metadata in Redis for protocol handlers - task_meta_key = docket.key(f"fastmcp:task:{session_id}:{server_task_id}") - created_at_key = docket.key( - f"fastmcp:task:{session_id}:{server_task_id}:created_at" - ) - poll_interval_key = docket.key( - f"fastmcp:task:{session_id}:{server_task_id}:poll_interval" - ) - origin_request_id_key = docket.key( - f"fastmcp:task:{session_id}:{server_task_id}:origin_request_id" - ) + 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) - origin_request_id = ( - str(ctx.request_context.request_id) if ctx.request_context is not None else None - ) - # Snapshot the current access token (if any) for background task access (#3095) - access_token = get_access_token() - access_token_key = docket.key( - f"fastmcp:task:{session_id}:{server_task_id}:access_token" - ) + # 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) - if origin_request_id is not None: - await redis.set(origin_request_id_key, origin_request_id, ex=ttl_seconds) - if access_token is not None: - await redis.set( - access_token_key, access_token.model_dump_json(), 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 for "internal" sessions (programmatic calls without MCP session) - if session_id != "internal": - from fastmcp.server.dependencies import register_task_session - + # 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. @@ -171,7 +164,7 @@ async def submit_to_docket( # 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:{session}:{task_id}:tool: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] @@ -179,9 +172,10 @@ async def submit_to_docket( 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) + # Start subscription in session's task group (persists for connection lifetime) + # Deferred: subscriptions and notifications depend on docket at import time from fastmcp.server.tasks.subscriptions import subscribe_to_task_updates - # Start subscription in session's task group (persists for connection lifetime) if hasattr(ctx.session, "_subscription_task_group"): tg = ctx.session._subscription_task_group if tg: @@ -194,33 +188,34 @@ async def submit_to_docket( poll_interval_ms, ) - # Start notification subscriber for distributed elicitation (idempotent) - # This enables ctx.elicit() to work when workers run in separate processes - # Subscriber forwards notifications from Redis queue to client session + # Deferred: notifications depends on docket at import time from fastmcp.server.tasks.notifications import ( ensure_subscriber_running, stop_subscriber, ) - try: - await ensure_subscriber_running(session_id, ctx.session, docket, ctx.fastmcp) + if session_id is not None: + try: + await ensure_subscriber_running( + session_id, ctx.session, docket, ctx.fastmcp + ) - # Register cleanup callback on session exit (once per session) - # This ensures subscriber is stopped when the session disconnects - if ( - hasattr(ctx.session, "_exit_stack") - and ctx.session._exit_stack is not None - and not getattr(ctx.session, "_notification_cleanup_registered", False) - ): + # Register cleanup callback on session exit (once per session) + # This ensures subscriber is stopped when the session disconnects + if ( + hasattr(ctx.session, "_exit_stack") + and ctx.session._exit_stack is not None + and not getattr(ctx.session, "_notification_cleanup_registered", False) + ): - async def _cleanup_subscriber() -> None: - await stop_subscriber(session_id) + async def _cleanup_subscriber() -> None: + await stop_subscriber(session_id) # type: ignore[arg-type] - ctx.session._exit_stack.push_async_callback(_cleanup_subscriber) - ctx.session._notification_cleanup_registered = True # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - except Exception as e: - # Non-fatal: elicitation will still work via polling fallback - logger.debug("Failed to start notification subscriber: %s", e) + ctx.session._exit_stack.push_async_callback(_cleanup_subscriber) + ctx.session._notification_cleanup_registered = True # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + except Exception as e: + # Non-fatal: elicitation will still work via polling fallback + logger.debug("Failed to start notification subscriber: %s", e) # Return CreateTaskResult with proper Task object # Tasks MUST begin in "working" status per SEP-1686 final spec (line 381) diff --git a/src/fastmcp/server/tasks/keys.py b/src/fastmcp/server/tasks/keys.py index 0e28cf592..10af6a6f9 100644 --- a/src/fastmcp/server/tasks/keys.py +++ b/src/fastmcp/server/tasks/keys.py @@ -1,31 +1,56 @@ -"""Task key management for SEP-1686 background tasks. +"""Docket and Redis key encoding for background tasks. -Task keys encode security scoping and metadata in the Docket key format: - `{session_id}:{client_task_id}:{task_type}:{component_identifier}` +The compound Docket task key embeds the auth boundary so that the parser can +reject cross-scope access without consulting Redis. Authenticated and +anonymous tasks live in disjoint keyspaces: -This format provides: -- Session-based security scoping (prevents cross-session access) -- Task type identification (tool/prompt/resource) -- Component identification (name or URI for result conversion) + auth:{enc_scope}:{client_task_id}:{task_type}:{enc_identifier} + anon:{client_task_id}:{task_type}:{enc_identifier} + +The same `auth/anon` partition is used for the per-task Redis prefix +(``fastmcp:task:auth:{enc_scope}`` vs ``fastmcp:task:anon``) — see +``task_redis_prefix``. + +``task_scope`` is the raw scope identifier (typically derived from +``client_id`` or ``client_id|sub``); encoding happens once, at the boundary, +in this module. """ +from typing import TypedDict from urllib.parse import quote, unquote +class TaskKeyParts(TypedDict): + """Decoded segments of a Docket task key. + + ``task_scope`` is ``None`` for anonymous tasks, the raw scope string + otherwise. + """ + + task_scope: str | None + client_task_id: str + task_type: str + component_identifier: str + + +_AUTH_TAG = "auth" +_ANON_TAG = "anon" +_VALID_TAGS = (_AUTH_TAG, _ANON_TAG) + + def build_task_key( - session_id: str, + task_scope: str | None, client_task_id: str, task_type: str, component_identifier: str, ) -> str: """Build Docket task key with embedded metadata. - Format: `{session_id}:{client_task_id}:{task_type}:{component_identifier}` - - The component_identifier is URI-encoded to handle special characters (colons, slashes, etc.). + When ``task_scope`` is ``None`` the task is anonymous and lives in the + ``anon`` keyspace. Otherwise it lives under ``auth:{enc_scope}``. Args: - session_id: Session ID for security scoping + task_scope: Raw authorization scope, or ``None`` for anonymous tasks client_task_id: Client-provided task ID task_type: Type of task ("tool", "prompt", "resource") component_identifier: Tool name, prompt name, or resource URI @@ -34,44 +59,78 @@ def build_task_key( Encoded task key for Docket Examples: - >>> build_task_key("session123", "task456", "tool", "my_tool") - 'session123:task456:tool:my_tool' + >>> build_task_key("client-a", "task456", "tool", "my_tool") + 'auth:client-a:task456:tool:my_tool' - >>> build_task_key("session123", "task456", "resource", "file://data.txt") - 'session123:task456:resource:file%3A%2F%2Fdata.txt' + >>> build_task_key(None, "task456", "tool", "my_tool") + 'anon:task456:tool:my_tool' + + >>> build_task_key("client-a", "task456", "resource", "file://data.txt") + 'auth:client-a:task456:resource:file%3A%2F%2Fdata.txt' """ encoded_identifier = quote(component_identifier, safe="") - return f"{session_id}:{client_task_id}:{task_type}:{encoded_identifier}" + if task_scope is None: + return f"{_ANON_TAG}:{client_task_id}:{task_type}:{encoded_identifier}" + encoded_scope = quote(task_scope, safe="") + return ( + f"{_AUTH_TAG}:{encoded_scope}:{client_task_id}:{task_type}:{encoded_identifier}" + ) -def parse_task_key(task_key: str) -> dict[str, str]: +def parse_task_key(task_key: str) -> TaskKeyParts: """Parse Docket task key to extract metadata. Args: task_key: Encoded task key from Docket Returns: - Dict with keys: session_id, client_task_id, task_type, component_identifier + Dict with keys: ``task_scope`` (``str | None``), ``client_task_id``, + ``task_type``, ``component_identifier``. + + Raises: + ValueError: If the key has an unrecognized tag or wrong segment count. Examples: - >>> parse_task_key("session123:task456:tool:my_tool") - `{'session_id': 'session123', 'client_task_id': 'task456', 'task_type': 'tool', 'component_identifier': 'my_tool'}` + >>> parse_task_key("auth:client-a:task456:tool:my_tool") + `{'task_scope': 'client-a', 'client_task_id': 'task456', 'task_type': 'tool', 'component_identifier': 'my_tool'}` - >>> parse_task_key("session123:task456:resource:file%3A%2F%2Fdata.txt") - `{'session_id': 'session123', 'client_task_id': 'task456', 'task_type': 'resource', 'component_identifier': 'file://data.txt'}` + >>> parse_task_key("anon:task456:tool:my_tool") + `{'task_scope': None, 'client_task_id': 'task456', 'task_type': 'tool', 'component_identifier': 'my_tool'}` """ - parts = task_key.split(":", 3) - if len(parts) != 4: + tag, _, rest = task_key.partition(":") + if tag not in _VALID_TAGS or not rest: raise ValueError( f"Invalid task key format: {task_key}. " - f"Expected: {{session_id}}:{{client_task_id}}:{{task_type}}:{{component_identifier}}" + f"Expected leading tag in {_VALID_TAGS}." ) + if tag == _ANON_TAG: + parts = rest.split(":", 2) + if len(parts) != 3: + raise ValueError( + f"Invalid anonymous task key: {task_key}. " + f"Expected: anon:{{client_task_id}}:{{task_type}}:{{component_identifier}}" + ) + client_task_id, task_type, encoded_identifier = parts + return { + "task_scope": None, + "client_task_id": client_task_id, + "task_type": task_type, + "component_identifier": unquote(encoded_identifier), + } + + parts = rest.split(":", 3) + if len(parts) != 4: + raise ValueError( + f"Invalid authenticated task key: {task_key}. " + f"Expected: auth:{{enc_scope}}:{{client_task_id}}:{{task_type}}:{{component_identifier}}" + ) + encoded_scope, client_task_id, task_type, encoded_identifier = parts return { - "session_id": parts[0], - "client_task_id": parts[1], - "task_type": parts[2], - "component_identifier": unquote(parts[3]), + "task_scope": unquote(encoded_scope), + "client_task_id": client_task_id, + "task_type": task_type, + "component_identifier": unquote(encoded_identifier), } @@ -82,10 +141,25 @@ def get_client_task_id_from_key(task_key: str) -> str: task_key: Full encoded task key Returns: - Client-provided task ID (second segment) + Client-provided task ID - Example: - >>> get_client_task_id_from_key("session123:task456:tool:my_tool") + Examples: + >>> get_client_task_id_from_key("auth:client-a:task456:tool:my_tool") + 'task456' + + >>> get_client_task_id_from_key("anon:task456:tool:my_tool") 'task456' """ - return task_key.split(":", 3)[1] + return parse_task_key(task_key)["client_task_id"] + + +def task_redis_prefix(task_scope: str | None) -> str: + """Return the Redis key prefix that owns a given scope. + + Authenticated tasks live under ``fastmcp:task:auth:{enc_scope}``; + anonymous tasks live under ``fastmcp:task:anon``. Callers append + ``f":{task_id}:..."`` to compose the final key. + """ + if task_scope is None: + return f"fastmcp:task:{_ANON_TAG}" + return f"fastmcp:task:{_AUTH_TAG}:{quote(task_scope, safe='')}" diff --git a/src/fastmcp/server/tasks/notifications.py b/src/fastmcp/server/tasks/notifications.py index 6656bc361..852b2bb37 100644 --- a/src/fastmcp/server/tasks/notifications.py +++ b/src/fastmcp/server/tasks/notifications.py @@ -211,10 +211,18 @@ async def _send_mcp_notification( "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, session_id, task_id, elicitation, fastmcp), + relay_elicitation(session, task_scope, task_id, elicitation, fastmcp), name=f"elicitation-relay-{task_id[:8]}", ) _background_tasks.add(task) diff --git a/src/fastmcp/server/tasks/requests.py b/src/fastmcp/server/tasks/requests.py index 8743356e5..6c062de0c 100644 --- a/src/fastmcp/server/tasks/requests.py +++ b/src/fastmcp/server/tasks/requests.py @@ -29,7 +29,8 @@ from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.tasks.config import DEFAULT_POLL_INTERVAL_MS, DEFAULT_TTL_MS -from fastmcp.server.tasks.keys import parse_task_key +from fastmcp.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 @@ -70,7 +71,7 @@ def _parse_key_version(key_suffix: str) -> tuple[str, str | None]: async def _lookup_task_execution( docket: Any, - session_id: str, + task_scope: str | None, client_task_id: str, ) -> tuple[Any, str | None, int]: """Look up task execution and metadata from Redis. @@ -80,7 +81,7 @@ async def _lookup_task_execution( Args: docket: Docket instance - session_id: Session ID + task_scope: Authorization scope client_task_id: Client-provided task ID Returns: @@ -89,13 +90,10 @@ async def _lookup_task_execution( Raises: McpError: If task not found or execution not found """ - task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}") - created_at_key = docket.key( - f"fastmcp:task:{session_id}:{client_task_id}:created_at" - ) - poll_interval_key = docket.key( - f"fastmcp:task:{session_id}:{client_task_id}:poll_interval" - ) + 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: @@ -144,7 +142,7 @@ async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskR Returns: GetTaskResult: Task status response with spec-compliant fields """ - async with fastmcp.server.context.Context(fastmcp=server) as ctx: + async with fastmcp.server.context.Context(fastmcp=server): client_task_id = params.get("taskId") if not client_task_id: raise McpError( @@ -153,8 +151,8 @@ async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskR ) ) - # Get session ID from Context - session_id = ctx.session_id + # Get authorization scope for task lookup + task_scope = get_task_scope() # Get Docket instance docket = server._docket @@ -168,7 +166,7 @@ async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskR # Look up task execution and metadata execution, created_at, poll_interval_ms = await _lookup_task_execution( - docket, session_id, client_task_id + docket, task_scope, client_task_id ) # Sync state from Redis @@ -231,7 +229,7 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any: Returns: MCP result (CallToolResult, GetPromptResult, or ReadResourceResult) """ - async with fastmcp.server.context.Context(fastmcp=server) as ctx: + async with fastmcp.server.context.Context(fastmcp=server): client_task_id = params.get("taskId") if not client_task_id: raise McpError( @@ -240,8 +238,8 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any: ) ) - # Get session ID from Context - session_id = ctx.session_id + # 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 @@ -254,7 +252,7 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any: ) # Look up full task key from Redis - task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}") + 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) @@ -432,7 +430,7 @@ async def tasks_cancel_handler( Returns: CancelTaskResult: Task status response showing cancelled state """ - async with fastmcp.server.context.Context(fastmcp=server) as ctx: + async with fastmcp.server.context.Context(fastmcp=server): client_task_id = params.get("taskId") if not client_task_id: raise McpError( @@ -441,8 +439,8 @@ async def tasks_cancel_handler( ) ) - # Get session ID from Context - session_id = ctx.session_id + # Get authorization scope for task lookup + task_scope = get_task_scope() # Get Docket instance docket = server._docket @@ -456,7 +454,7 @@ async def tasks_cancel_handler( # Look up task execution and metadata execution, created_at, poll_interval_ms = await _lookup_task_execution( - docket, session_id, client_task_id + docket, task_scope, client_task_id ) # Cancel via Docket (now sets CANCELLED state natively) diff --git a/src/fastmcp/server/tasks/subscriptions.py b/src/fastmcp/server/tasks/subscriptions.py index 772b82671..37a4bf2ea 100644 --- a/src/fastmcp/server/tasks/subscriptions.py +++ b/src/fastmcp/server/tasks/subscriptions.py @@ -16,7 +16,7 @@ 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 +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 @@ -115,11 +115,11 @@ async def _send_status_notification( state_map = DOCKET_TO_MCP_STATE mcp_status = state_map.get(state, "failed") - # Extract session_id from task_key for Redis lookup + # Extract task_scope from task_key for Redis lookup key_parts = parse_task_key(task_key) - session_id = key_parts["session_id"] + task_scope = key_parts["task_scope"] - created_at_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:created_at") + 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) @@ -189,11 +189,11 @@ async def _send_progress_notification( state_map = DOCKET_TO_MCP_STATE mcp_status = state_map.get(execution.state, "failed") - # Extract session_id from task_key for Redis lookup + # Extract task_scope from task_key for Redis lookup key_parts = parse_task_key(task_key) - session_id = key_parts["session_id"] + task_scope = key_parts["task_scope"] - created_at_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:created_at") + 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) diff --git a/src/fastmcp/tools/base.py b/src/fastmcp/tools/base.py index 852a9c6c9..cad5fae64 100644 --- a/src/fastmcp/tools/base.py +++ b/src/fastmcp/tools/base.py @@ -290,6 +290,10 @@ class Tool(FastMCPComponent): content = _convert_to_content(raw_value, serializer=self.serializer) + # Bytes can't be represented as structured JSON content + if isinstance(raw_value, bytes): + return ToolResult(content=content) + # 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 ( @@ -303,7 +307,7 @@ class Tool(FastMCPComponent): try: structured = pydantic_core.to_jsonable_python(raw_value) - except pydantic_core.PydanticSerializationError: + except (pydantic_core.PydanticSerializationError, UnicodeDecodeError): return ToolResult(content=content) if self.output_schema is None: @@ -386,7 +390,7 @@ class Tool(FastMCPComponent): fn_key: str | None = None, task_key: str | None = None, **kwargs: Any, - ) -> Execution: # ty:ignore[invalid-method-override] + ) -> Execution: """Schedule this tool for background execution via docket. Args: @@ -492,33 +496,39 @@ def _convert_to_single_content_block( if isinstance(item, str): return TextContent(type="text", text=item) + if isinstance(item, bytes): + try: + return TextContent(type="text", text=item.decode("utf-8")) + except UnicodeDecodeError: + import base64 + + return TextContent(type="text", text=base64.b64encode(item).decode("ascii")) + return TextContent(type="text", text=_serialize_with_fallback(item, serializer)) _PREFAB_TEXT_FALLBACK = "[Rendered Prefab UI]" -def _get_tool_resolver() -> Callable[..., str] | None: - """Get the FastMCPApp callable resolver, if available.""" +def _get_tool_resolver(app_name: str | None = None) -> Callable[..., str] | None: + """Get the Prefab peer-reference resolver bound to an app name.""" try: - from fastmcp.apps.app import _resolve_tool_ref + from fastmcp.apps.app import _make_resolver - return _resolve_tool_ref + return _make_resolver(app_name) except ImportError: return None def _prefab_to_json(app: Any, fastmcp_app_name: str | None = None) -> dict[str, Any]: - """Call PrefabApp.to_json() with the FastMCPApp callable resolver. + """Call PrefabApp.to_json() with the hash-based resolver. - If ``fastmcp_app_name`` is set, injects ``_meta.fastmcp.app`` into the - serialized output so the renderer can tag subsequent tool calls with the - app identity for direct routing. + 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``. """ - data = app.to_json(tool_resolver=_get_tool_resolver()) - if fastmcp_app_name is not None: - meta = data.setdefault("_meta", {}) - meta.setdefault("fastmcp", {})["app"] = fastmcp_app_name + data = app.to_json(tool_resolver=_get_tool_resolver(fastmcp_app_name)) return data diff --git a/src/fastmcp/tools/function_parsing.py b/src/fastmcp/tools/function_parsing.py index 804dc6efd..9b9b0870e 100644 --- a/src/fastmcp/tools/function_parsing.py +++ b/src/fastmcp/tools/function_parsing.py @@ -18,6 +18,7 @@ from fastmcp.server.dependencies import ( without_injected_parameters, ) from fastmcp.tools.base import ToolResult +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.types import ( @@ -39,6 +40,16 @@ except ImportError: _PREFAB_TYPES = () +def _contains_bytes_type(tp: Any) -> bool: + """Check if *tp* is or contains bytes, recursing through unions and Annotated.""" + if tp is bytes: + return True + origin = get_origin(tp) + if origin is Union or origin is types.UnionType or origin is Annotated: + return any(_contains_bytes_type(a) for a in get_args(tp)) + return False + + def _contains_prefab_type(tp: Any) -> bool: """Check if *tp* is or contains a prefab type, recursing through unions and Annotated.""" if isinstance(tp, type) and issubclass(tp, _PREFAB_TYPES): @@ -154,9 +165,9 @@ class ParsedFunction: f"Parameter '{arg_name}' in exclude_args must have a default value." ) - # collect name and doc before we potentially modify the function + # collect name and description before we potentially modify the function fn_name = getattr(fn, "__name__", None) or fn.__class__.__name__ - fn_doc = inspect.getdoc(fn) + outer_docstring = parse_docstring(fn) # 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): @@ -165,6 +176,19 @@ class ParsedFunction: if isinstance(fn, staticmethod): fn = fn.__func__ + # For callable classes, parameter descriptions must come from + # __call__'s docstring — where the exposed parameters are actually + # declared. The class docstring's Args section, if any, typically + # describes __init__, so falling back to it would risk injecting + # constructor docs into __call__'s schema on overlapping names. + # The description, however, comes from the class docstring (which + # describes what the tool IS) when present. + inner_docstring = parse_docstring(fn) + parsed_docstring = ParsedDocstring( + description=outer_docstring.description or inner_docstring.description, + parameters=inner_docstring.parameters, + ) + # Transform Context type annotations to Depends() for unified DI fn = transform_context_annotations(fn) @@ -185,6 +209,18 @@ class ParsedFunction: input_schema, prune_params=prune_params, prune_titles=True ) + # Inject parameter descriptions from the docstring into the schema. + # Explicit annotations (Field(description=...), Annotated[x, "..."]) + # already have a "description" key and take precedence. + if parsed_docstring.parameters: + properties = input_schema.get("properties", {}) + for param_name, param_desc in parsed_docstring.parameters.items(): + if ( + param_name in properties + and "description" not in properties[param_name] + ): + properties[param_name]["description"] = param_desc + output_schema = None # Get the return annotation from the signature sig = inspect.signature(fn) @@ -205,6 +241,10 @@ class ParsedFunction: original_output_type = 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): + output_type = _UnserializableType + # Prefab component subclasses (Column, Card, etc.) shouldn't # produce output schemas — replace_type only does exact matching, # so we handle subclass matching explicitly here. We also need @@ -268,7 +308,7 @@ class ParsedFunction: return cls( fn=fn, name=fn_name, - description=fn_doc, + description=parsed_docstring.description, input_schema=input_schema, output_schema=output_schema or None, return_type=original_output_type, diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py index 37c0ad0a6..94e7a0b3e 100644 --- a/src/fastmcp/tools/function_tool.py +++ b/src/fastmcp/tools/function_tool.py @@ -222,7 +222,9 @@ class FunctionTool(Tool): name=metadata.name or parsed_fn.name, version=str(metadata.version) if metadata.version is not None else None, title=metadata.title, - description=metadata.description or parsed_fn.description, + description=metadata.description + if metadata.description is not None + else parsed_fn.description, icons=metadata.icons, parameters=parsed_fn.input_schema, output_schema=final_output_schema, @@ -255,6 +257,9 @@ class FunctionTool(Tool): # Handle sync wrappers that return awaitables if inspect.isawaitable(result): result = await result + # Materialize generators inside timeout scope so slow + # generators don't run past the configured timeout + result = await self._materialize_generator(result) except TimeoutError: logger.warning( f"Tool '{self.name}' timed out after {self.timeout}s. " @@ -277,14 +282,30 @@ class FunctionTool(Tool): ) if inspect.isawaitable(result): result = await result + result = await self._materialize_generator(result) return self.convert_result(result) + @staticmethod + async def _materialize_generator(result: Any) -> Any: + """Consume generators/async generators into lists. + + Without this, async generators pass through as objects (repr string), + and sync generators get consumed during text serialization but are + exhausted by the time structured content is built. + """ + if inspect.isasyncgen(result): + return [item async for item in result] + if inspect.isgenerator(result): + return list(result) + return result + def register_with_docket(self, docket: Docket) -> None: """Register this tool with docket for background execution. - FunctionTool registers the underlying function, which has the user's - Depends parameters for docket to resolve. + 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 diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index a1ec42302..1dcc52662 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -19,6 +19,10 @@ import fastmcp from fastmcp.exceptions import FastMCPDeprecationWarning from fastmcp.tools.base import Tool, ToolResult, _convert_to_content from fastmcp.tools.function_parsing import ParsedFunction +from fastmcp.utilities.async_utils import ( + call_sync_fn_in_threadpool, + is_coroutine_function, +) from fastmcp.utilities.components import _convert_set_default_none from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger @@ -310,7 +314,12 @@ class TransformedTool(Tool): token = _current_tool.set(self) try: - result = await self.fn(**arguments) + if is_coroutine_function(self.fn): + result = await self.fn(**arguments) + else: + result = await call_sync_fn_in_threadpool(self.fn, **arguments) + if inspect.isawaitable(result): + result = await result # If transform function returns ToolResult, respect our output_schema setting if isinstance(result, ToolResult): @@ -385,17 +394,14 @@ class TransformedTool(Tool): version: New version for the tool. Defaults to parent tool's version. title: New title for the tool. Defaults to parent tool's title. transform_args: Optional transformations for parent tool arguments. - Only specified arguments are transformed, others pass through unchanged: - - Simple rename (str) - - Complex transformation (rename/description/default/drop) (ArgTransform) - - Drop the argument (None) + Only specified arguments are transformed, others pass through unchanged. + Use ArgTransform for rename, description, default, or hide operations. description: New description. Defaults to parent's description. tags: New tags. Defaults to parent's tags. annotations: New annotations. Defaults to parent's annotations. output_schema: Control output schema for structured outputs: - None (default): Inherit from transform_fn if available, then parent tool - dict: Use custom output schema - - False: Disable output schema and structured outputs serializer: Deprecated. Return ToolResult from your tools for full control over serialization. meta: Control meta information: - NotSet (default): Inherit from parent tool @@ -629,9 +635,9 @@ class TransformedTool(Tool): """ # Build transformed schema and mapping - # Deep copy to prevent compress_schema from mutating parent tool's $defs + # Deep copy to prevent mutations from corrupting the parent tool's schema parent_defs = deepcopy(parent_tool.parameters.get("$defs", {})) - parent_props = parent_tool.parameters.get("properties", {}).copy() + parent_props = deepcopy(parent_tool.parameters.get("properties", {})) parent_required = set(parent_tool.parameters.get("required", [])) new_props = {} diff --git a/src/fastmcp/utilities/async_utils.py b/src/fastmcp/utilities/async_utils.py index 3f7e816fb..8fc24f49d 100644 --- a/src/fastmcp/utilities/async_utils.py +++ b/src/fastmcp/utilities/async_utils.py @@ -1,6 +1,5 @@ """Async utilities for FastMCP.""" -import asyncio import functools import inspect from collections.abc import Awaitable, Callable @@ -21,7 +20,7 @@ def is_coroutine_function(fn: Any) -> bool: """ while isinstance(fn, functools.partial): fn = fn.func - return inspect.iscoroutinefunction(fn) or asyncio.iscoroutinefunction(fn) + return inspect.iscoroutinefunction(fn) async def call_sync_fn_in_threadpool( diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py index cac3e65e3..ad03691c5 100644 --- a/src/fastmcp/utilities/cli.py +++ b/src/fastmcp/utilities/cli.py @@ -240,7 +240,6 @@ def log_server_banner(server: FastMCP[Any]) -> None: panel_content, border_style="dim", padding=(1, 4), - # expand=False, width=80, # Set max width for the panel ) diff --git a/src/fastmcp/utilities/components.py b/src/fastmcp/utilities/components.py index 34ab62c3b..732569e63 100644 --- a/src/fastmcp/utilities/components.py +++ b/src/fastmcp/utilities/components.py @@ -147,6 +147,13 @@ class FastMCPComponent(FastMCPBaseModel): Subclasses should override this to use their specific identifier. Base implementation uses name. + + Prefer `.key` over ad-hoc `name or uri or uri_template` logic for any + cross-component identity work (dedupe, grouping, collision detection, + lookup tables). It encodes type, identifier, and version, so variants + of the same component don't falsely collide with each other, and + cross-type identifiers (e.g. a tool and a resource both named "foo") + can't clash. """ base_key = self.make_key(self.name) return f"{base_key}@{self.version or ''}" diff --git a/src/fastmcp/utilities/docstring_parsing.py b/src/fastmcp/utilities/docstring_parsing.py new file mode 100644 index 000000000..babcb1e96 --- /dev/null +++ b/src/fastmcp/utilities/docstring_parsing.py @@ -0,0 +1,65 @@ +"""Extract descriptions from function docstrings. + +Uses griffelib to parse Google, NumPy, and Sphinx-style docstrings. The +interface is intentionally narrow — a single function returning a +`ParsedDocstring` — so the implementation can be swapped without touching +callers. +""" + +from __future__ import annotations + +import inspect +import logging +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") +# Griffe warns about missing type annotations in docstrings, which is noisy +# and irrelevant — we only care about descriptions. +logger.setLevel(logging.ERROR) + + +@dataclass(frozen=True) +class ParsedDocstring: + """The extracted description and per-parameter descriptions from a docstring.""" + + description: str | None = None + parameters: dict[str, str] = field(default_factory=dict) + + +def parse_docstring(fn: Callable[..., Any]) -> ParsedDocstring: + """Parse a function's docstring into a summary and parameter descriptions. + + Tries Google, NumPy, and Sphinx parsers in order, using the first one that + successfully extracts parameter descriptions. If none do, returns the full + docstring as the description with no parameter descriptions. + """ + doc = inspect.getdoc(fn) + if not doc: + return ParsedDocstring() + + # Try each parser and use the first one that finds parameters. + for parser in _PARSERS: + docstring = Docstring(doc, lineno=1, parser=parser) + sections = docstring.parse() + + description: str | None = None + parameters: dict[str, str] = {} + + for section in sections: + if section.kind == DocstringSectionKind.text and description is None: + description = section.value + elif section.kind == DocstringSectionKind.parameters: + for param in section.value: + parameters[param.name] = param.description + + if parameters: + return ParsedDocstring(description=description, parameters=parameters) + + # No parser found parameters — return the full docstring unchanged. + return ParsedDocstring(description=doc) diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py index 21d69b78d..b33c4f945 100644 --- a/src/fastmcp/utilities/json_schema.py +++ b/src/fastmcp/utilities/json_schema.py @@ -73,6 +73,33 @@ def _strip_remote_refs(obj: Any) -> Any: return obj +def _strip_discriminator(obj: Any) -> Any: + """Recursively remove OpenAPI ``discriminator`` keys from a schema. + + Pydantic emits ``discriminator.mapping`` with values like + ``#/$defs/ClassName``. After ``$defs`` are inlined and removed by + ``dereference_refs``, those mapping entries dangle. The keyword is an + OpenAPI extension — the ``anyOf`` variants already carry ``const`` on + the discriminant field, so the mapping is redundant. + + Only strips ``discriminator`` when it appears alongside ``anyOf`` or + ``oneOf``, which is where the OpenAPI keyword lives. A property + *named* ``discriminator`` (inside ``properties``) is left alone. + """ + if isinstance(obj, dict): + skip = "discriminator" in obj and ("anyOf" in obj or "oneOf" in obj) + # Keys that hold instance data, not sub-schemas — don't recurse. + _DATA_KEYS = {"default", "const", "examples", "enum"} + return { + k: (v if k in _DATA_KEYS else _strip_discriminator(v)) + for k, v in obj.items() + if not (k == "discriminator" and skip) + } + if isinstance(obj, list): + return [_strip_discriminator(item) for item in obj] + return obj + + def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]: """Resolve all $ref references in a JSON schema by inlining definitions. @@ -135,6 +162,13 @@ def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]: if "$defs" in dereferenced: dereferenced = {k: v for k, v in dereferenced.items() if k != "$defs"} + # Strip `discriminator` keys — they contain `mapping` values that + # point at `#/$defs/...` entries we just removed. `discriminator` + # is an OpenAPI extension; after inlining, the `anyOf` variants + # already carry `const` on the discriminant field, making the + # mapping redundant. + dereferenced = _strip_discriminator(dereferenced) + return dereferenced except JsonRefError: @@ -270,6 +304,85 @@ def _prune_param(schema: dict[str, Any], param: str) -> dict[str, Any]: return schema +# JSON Schema structural keywords — a node containing any of these is a +# schema, so a string "title" sibling is metadata we can safely drop. +_SCHEMA_KEYWORDS = frozenset( + { + "type", + "properties", + "$ref", + "items", + "allOf", + "oneOf", + "anyOf", + "required", + } +) + +# Pure schema-metadata keys. A node containing only these (e.g. Pydantic's +# `{"title": "X"}` for Any-typed fields) is also a schema, just one with no +# structural keywords alongside — still safe to strip title from. +_METADATA_KEYS = frozenset( + { + "title", + "description", + "deprecated", + "readOnly", + "writeOnly", + } +) + +# Keywords whose values are literal user data, not sub-schemas. Skipping +# recursion here prevents `default: {"title": "X"}` from losing the "title" +# data value because it happens to look metadata-shaped. Includes both +# `examples` (JSON Schema draft 7+) and `example` (OpenAPI/Swagger 2.0). +_LITERAL_KEYWORDS = frozenset({"default", "const", "examples", "example", "enum"}) + +# Keys whose values are dicts of arbitrary-name -> sub-schema. When we see +# these, we traverse into each sub-schema regardless of its name — the keys +# are user property/definition names, not schema keywords, so a property +# literally named "enum" or "default" must not be confused with the +# schema keywords of the same name. +# +# `dependencies` is a draft-07 keyword whose values can be sub-schemas OR +# lists of required property names; list values short-circuit in the list +# branch, so including it here is safe for both shapes. +_SUBSCHEMA_MAP_KEYS = frozenset( + { + "properties", + "patternProperties", + "$defs", + "definitions", + "dependentSchemas", + "dependencies", + } +) + +# Keys whose values are a single sub-schema (not a dict of sub-schemas). +# We traverse into them and treat the result as a schema node. +# `additionalItems` is the draft-07 predecessor of `unevaluatedItems`. +# `contentSchema` is a 2019-09+ keyword for typed string payloads. +_SUBSCHEMA_VALUE_KEYS = frozenset( + { + "items", + "additionalItems", + "additionalProperties", + "contains", + "contentSchema", + "propertyNames", + "unevaluatedItems", + "unevaluatedProperties", + "if", + "then", + "else", + "not", + } +) + +# Keys whose values are LISTS of sub-schemas. +_SUBSCHEMA_LIST_KEYS = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"}) + + def _single_pass_optimize( schema: dict[str, Any], prune_titles: bool = False, @@ -336,13 +449,25 @@ def _single_pass_optimize( current_def_name: str | None = None, skip_defs_section: bool = False, depth: int = 0, + in_schema: bool = True, ) -> None: - """Traverse schema tree, collecting $ref info and applying cleanups.""" + """Traverse schema tree, collecting $ref info and applying cleanups. + + The `in_schema` flag tracks whether the current node is reached via a + known JSON-Schema-valued position (root, `properties` value, `items`, + `allOf` element, etc.). When False — e.g. we descended through a user + extension key like `x-ui` whose payload is opaque to us — we still + collect `$ref` references (they may point at `$defs` the user cares + about) but we skip all cleanups so we don't mutate user data that + happens to look metadata-shaped. + """ if depth > 50: # Prevent infinite recursion return if isinstance(node, dict): - # Collect $ref references for unused definition removal + # Collect $ref references for unused definition removal. We do + # this regardless of `in_schema` — a $ref in a user extension + # still pins the referenced $def as "used". if prune_defs: ref = node.get("$ref") # type: ignore if isinstance(ref, str) and ref.startswith("#/$defs/"): @@ -354,58 +479,111 @@ def _single_pass_optimize( # We're in the main schema, so this is a root reference root_refs.add(referenced_def) - # Apply cleanups - # Only remove "title" if it's a schema metadata field - # Schema objects have keywords like "type", "properties", "$ref", etc. - # If we see these, then "title" is metadata, not a property name - if prune_titles and "title" in node: - # Only remove "title" if it's a string (schema metadata). - # In a "properties" dict, "title" would be a dict (a sub-schema - # for a parameter named "title"), which we must preserve. - if isinstance(node["title"], str) and any( # type: ignore - k in node - for k in [ - "type", - "properties", - "$ref", - "items", - "allOf", - "oneOf", - "anyOf", - "required", - ] + # Cleanups only run when we know this node is a schema, never on + # user extension payloads (`json_schema_extra={"x-ui": {...}}`). + if in_schema: + # Only remove "title" when it's schema metadata. A schema + # node is either (a) one containing a structural keyword or + # (b) one containing only metadata keys — Pydantic emits + # bare `{"title": "X"}` for Any-typed fields with no sibling + # type/properties, and Gemini 2.5 Flash rejects those with + # MALFORMED_FUNCTION_CALL. The `isinstance(str)` guard + # protects against deleting a user property literally named + # "title" (its value would be a dict, not a string). + if ( + prune_titles + and "title" in node + and isinstance(node["title"], str) # type: ignore + and ( + any(k in node for k in _SCHEMA_KEYWORDS) + or all(k in _METADATA_KEYS for k in node) + ) ): node.pop("title") # type: ignore - if ( - prune_additional_properties - and node.get("additionalProperties") is False # type: ignore - ): - node.pop("additionalProperties") # type: ignore + if ( + prune_additional_properties + and node.get("additionalProperties") is False # type: ignore + ): + node.pop("additionalProperties") # type: ignore # Recursive traversal for key, value in node.items(): if skip_defs_section and key == "$defs": continue # Skip $defs during main schema traversal - # Handle schema composition keywords with special traversal - if key in ["allOf", "oneOf", "anyOf"] and isinstance(value, list): + # If we're not in a schema context, keep $ref-collecting but + # don't promote sub-values to schema context — user extension + # payloads can contain anything and must not be interpreted + # as schemas. + if not in_schema: + traverse_and_clean( + value, current_def_name, depth=depth + 1, in_schema=False + ) + continue + + # Arbitrary-key dicts of sub-schemas. The keys are user names + # (property/definition names), not schema keywords, so we + # must NOT apply the literal-keyword skip to them — a user + # property named "enum" or "default" still needs its + # sub-schema traversed (e.g. to collect $ref references). + if key in _SUBSCHEMA_MAP_KEYS and isinstance(value, dict): + for sub_schema in value.values(): + traverse_and_clean( + sub_schema, + current_def_name, + depth=depth + 1, + in_schema=True, + ) + continue + + # Don't descend into keywords that carry literal data, not + # sub-schemas — `default: {"title": "X"}` is a user value, + # not schema metadata, and stripping "title" there would + # corrupt it. + if key in _LITERAL_KEYWORDS: + continue + + # Keywords whose values are sub-schemas (or lists thereof). + if key in _SUBSCHEMA_LIST_KEYS and isinstance(value, list): for item in value: - traverse_and_clean(item, current_def_name, depth=depth + 1) - else: - traverse_and_clean(value, current_def_name, depth=depth + 1) + traverse_and_clean( + item, + current_def_name, + depth=depth + 1, + in_schema=True, + ) + continue + + if key in _SUBSCHEMA_VALUE_KEYS: + traverse_and_clean( + value, + current_def_name, + depth=depth + 1, + in_schema=True, + ) + continue + + # Unknown keys (user extensions like `x-ui`, vendor + # metadata, etc.) — descend for $ref collection but mark + # in_schema=False so cleanups don't touch user payloads. + traverse_and_clean( + value, current_def_name, depth=depth + 1, in_schema=False + ) elif isinstance(node, list): for item in node: - traverse_and_clean(item, current_def_name, depth=depth + 1) + traverse_and_clean( + item, current_def_name, depth=depth + 1, in_schema=in_schema + ) # Phase 2: Traverse main schema (excluding $defs section) - traverse_and_clean(schema, skip_defs_section=True) + traverse_and_clean(schema, skip_defs_section=True, in_schema=True) # Phase 3: Traverse $defs to find inter-definition references if prune_defs and defs: for def_name, def_schema in defs.items(): - traverse_and_clean(def_schema, current_def_name=def_name) + traverse_and_clean(def_schema, current_def_name=def_name, in_schema=True) # Phase 4: Remove unused definitions def is_def_used(def_name: str, visiting: set[str] | None = None) -> bool: diff --git a/src/fastmcp/utilities/json_schema_type.py b/src/fastmcp/utilities/json_schema_type.py index e45bab71a..22b99b36a 100644 --- a/src/fastmcp/utilities/json_schema_type.py +++ b/src/fastmcp/utilities/json_schema_type.py @@ -36,11 +36,12 @@ from __future__ import annotations import hashlib import json +import keyword import re from collections.abc import Callable, Mapping from copy import deepcopy from dataclasses import MISSING, field, make_dataclass -from datetime import datetime +from datetime import date, datetime from typing import ( Annotated, Any, @@ -53,6 +54,7 @@ from typing import ( from pydantic import ( AnyUrl, BaseModel, + BeforeValidator, ConfigDict, EmailStr, Field, @@ -65,6 +67,36 @@ from typing_extensions import NotRequired, TypedDict __all__ = ["JSONSchema", "json_schema_to_type"] +def _normalize_yaml_types(obj: Any) -> Any: + """Convert YAML-parsed types back to JSON-native types. + + ``yaml.safe_load`` converts ISO date-time strings to ``datetime``/``date`` + objects. These crash ``json.dumps`` and produce wrong default values in + dataclass fields. This function recursively normalises them to strings. + """ + if isinstance(obj, datetime): + return obj.isoformat() + if isinstance(obj, date): + return obj.isoformat() + if isinstance(obj, dict): + return { + str(k) if not isinstance(k, str) else k: _normalize_yaml_types(v) + for k, v in obj.items() + } + if isinstance(obj, list): + return [_normalize_yaml_types(v) for v in obj] + return obj + + +def _reject_all(v: Any) -> Any: + """Validator that rejects every value, implementing JSON Schema `false`.""" + raise ValueError("No value is valid against a false schema") + + +# JSON Schema `false` means no value is valid. This type rejects everything +# during Pydantic validation. +_UnsatisfiableType = Annotated[Any, BeforeValidator(_reject_all)] + FORMAT_TYPES: dict[str, Any] = { "date-time": datetime, "email": EmailStr, @@ -109,13 +141,14 @@ class JSONSchema(TypedDict): def json_schema_to_type( - schema: Mapping[str, Any], + schema: Mapping[str, Any] | bool, name: str | None = None, ) -> type: """Convert JSON schema to appropriate Python type with validation. Args: - schema: A JSON Schema dictionary defining the type structure and validation rules + schema: A JSON Schema dictionary defining the type structure and validation rules. + Boolean schemas are also accepted (``True`` = any type, ``False`` = unsatisfiable). name: Optional name for object schemas. Only allowed when schema type is "object". If not provided for objects, name will be inferred from schema's "title" property or default to "Root". @@ -166,26 +199,19 @@ def json_schema_to_type( name: NameType ``` """ + # Boolean schemas (JSON Schema 2020-12 §4.3.2; also valid since draft-06) + if schema is True: + return Any + if schema is False: + return _UnsatisfiableType # type: ignore[return-value] # ty:ignore[invalid-return-type] + + # Normalise YAML-parsed types (datetime/date → str, non-str keys → str) + # so that downstream json.dumps/hashing and default values work correctly. + schema = _normalize_yaml_types(schema) + # Always use the top-level schema for references if schema.get("type") == "object": - # If no properties defined but has additionalProperties, return typed dict - if not schema.get("properties") and schema.get("additionalProperties"): - additional_props = schema["additionalProperties"] - if additional_props is True: - return dict[str, Any] - else: - # Handle typed dictionaries like dict[str, str] - value_type = _schema_to_type(additional_props, schemas=schema) - # value_type might be ForwardRef or type - cast to Any for dynamic type construction - return cast(type[Any], dict[str, value_type]) # type: ignore[valid-type] # ty:ignore[invalid-type-form] - # If no properties and no additionalProperties, default to dict[str, Any] for safety - elif not schema.get("properties") and not schema.get("additionalProperties"): - return dict[str, Any] - # If has properties AND additionalProperties is True, use Pydantic BaseModel - elif schema.get("properties") and schema.get("additionalProperties") is True: - return _create_pydantic_model(schema, name, schemas=schema) - # Otherwise use fast dataclass - return _create_dataclass(schema, name, schemas=schema) + return _object_schema_to_type(schema, schemas=schema, name=name) elif name: raise ValueError(f"Can not apply name to non-object schema: {name}") result = _schema_to_type(schema, schemas=schema) @@ -193,8 +219,19 @@ def json_schema_to_type( def _hash_schema(schema: Mapping[str, Any]) -> str: - """Generate a deterministic hash for schema caching.""" - return hashlib.sha256(json.dumps(schema, sort_keys=True).encode()).hexdigest() + """Generate a deterministic hash for schema caching. + + Handles non-JSON-native types (datetime, date, bool keys) that can + appear in schemas loaded from YAML, which auto-parses date strings. + Uses ``default=str`` for unserializable values and drops ``sort_keys`` + to avoid ``TypeError`` when dicts mix ``bool`` and ``str`` keys. + """ + try: + raw = json.dumps(schema, sort_keys=True, default=str) + except TypeError: + # Mixed key types (bool + str) can't be sorted; fall back + raw = json.dumps(schema, default=str) + return hashlib.sha256(raw.encode()).hexdigest() def _resolve_ref(ref: str, schemas: Mapping[str, Any]) -> Mapping[str, Any]: @@ -255,6 +292,11 @@ def _create_numeric_type( def _create_enum(name: str, values: list[Any]) -> type: """Create enum type from list of values.""" + if not values: + # Empty enum means no value is valid (same semantics as ``false`` + # schema). Return the unsatisfiable type instead of ``Literal[()]`` + # which triggers an AssertionError in Pydantic. + return _UnsatisfiableType # type: ignore[return-value] # ty:ignore[invalid-return-type] # Always return Literal for enum fields to preserve the literal nature return Literal[tuple(values)] # type: ignore[return-value] # ty:ignore[invalid-type-form] @@ -291,32 +333,76 @@ def _return_Any() -> Any: return Any +def _object_schema_to_type( + schema: Mapping[str, Any], + schemas: Mapping[str, Any], + name: str | None = None, +) -> type: + """Convert an object schema to the appropriate Python type. + + Single source of truth for the four object-schema cases, used by both the + top-level ``json_schema_to_type`` entry point and the recursive + ``_schema_to_type`` path: + + 1. No ``properties`` with ``additionalProperties`` truthy — ``dict[str, T]`` + (``T = Any`` when ``additionalProperties is True``, else the value schema's type) + 2. No ``properties`` and no ``additionalProperties`` — ``dict[str, Any]`` + 3. Has ``properties`` and ``additionalProperties is True`` — Pydantic model + (so ``extra="allow"`` can preserve unknown keys) + 4. Has ``properties`` otherwise — dataclass + + ``name`` is used as the generated class name for cases 3 and 4; it falls + back to the schema's ``title`` when not provided. + """ + has_properties = bool(schema.get("properties")) + additional_props = schema.get("additionalProperties") + class_name = name if name is not None else schema.get("title") + + if not has_properties and additional_props: + if additional_props is True: + return dict[str, Any] + value_type = _schema_to_type(additional_props, schemas) + return cast(type[Any], dict[str, value_type]) # type: ignore[valid-type] # ty:ignore[invalid-type-form] + + if not has_properties and not additional_props: + return dict[str, Any] + + if has_properties and additional_props is True: + return _create_pydantic_model(schema, class_name, schemas) + + return _create_dataclass(schema, class_name, schemas) + + def _get_from_type_handler( schema: Mapping[str, Any], schemas: Mapping[str, Any] ) -> Callable[..., Any]: """Get the appropriate type handler for the schema.""" - type_handlers: dict[str, Callable[..., Any]] = { # TODO + type_handlers: dict[str, Callable[..., Any]] = { "string": lambda s: _create_string_type(s), "integer": lambda s: _create_numeric_type(int, s), "number": lambda s: _create_numeric_type(float, s), "boolean": lambda _: bool, "null": lambda _: type(None), "array": lambda s: _create_array_type(s, schemas), - "object": lambda s: ( - _create_pydantic_model(s, s.get("title"), schemas) - if s.get("properties") and s.get("additionalProperties") is True - else _create_dataclass(s, s.get("title"), schemas) - ), + "object": lambda s: _object_schema_to_type(s, schemas), } return type_handlers.get(schema.get("type", None), _return_Any) def _schema_to_type( - schema: Mapping[str, Any], + schema: Mapping[str, Any] | bool, schemas: Mapping[str, Any], ) -> type | ForwardRef: """Convert schema to appropriate Python type.""" + # Boolean schemas are valid in JSON Schema draft-06+: + # true means "any value is valid" (equivalent to {}), + # false means "no value is valid" (unsatisfiable). + if schema is True: + return Any + if schema is False: + return _UnsatisfiableType # type: ignore[return-value] # ty:ignore[invalid-return-type] + if not schema: return object @@ -339,23 +425,9 @@ def _schema_to_type( # Handle anyOf unions if "anyOf" in schema: - types: list[type | Any] = [] - for subschema in schema["anyOf"]: - # Special handling for dict-like objects in unions - if ( - subschema.get("type") == "object" - and not subschema.get("properties") - and subschema.get("additionalProperties") - ): - # This is a dict type, handle it directly - additional_props = subschema["additionalProperties"] - if additional_props is True: - types.append(dict[str, Any]) - else: - value_type = _schema_to_type(additional_props, schemas) - types.append(dict[str, value_type]) # type: ignore - else: - types.append(_schema_to_type(subschema, schemas)) + types: list[type | Any] = [ + _schema_to_type(subschema, schemas) for subschema in schema["anyOf"] + ] # Check if one of the types is None (null) has_null = type(None) in types @@ -412,6 +484,9 @@ def _sanitize_name(name: str) -> str: # Step 5: only strip trailing underscores if they weren't in the original name if not original_name.endswith("_"): cleaned = cleaned.rstrip("_") + # Step 6: if result is a Python keyword, append an underscore (PEP 8 convention) + if keyword.iskeyword(cleaned): + cleaned = f"{cleaned}_" return cleaned @@ -475,7 +550,13 @@ def _create_pydantic_model( defaults = {} for prop_name, prop_schema in properties.items(): - field_type = _schema_to_type(prop_schema, schemas or {}) + # Boolean schemas (JSON Schema draft-06+): resolve type directly, + # then use an empty dict for .get() calls below. + if isinstance(prop_schema, bool): + field_type = _schema_to_type(prop_schema, schemas or {}) + prop_schema = {} + else: + field_type = _schema_to_type(prop_schema, schemas or {}) # Handle defaults default_value = prop_schema.get("default", MISSING) @@ -537,11 +618,25 @@ def _create_dataclass( required = schema.get("required", []) fields: list[tuple[Any, ...]] = [] + used_field_names: set[str] = set() for prop_name, prop_schema in properties.items(): field_name = _sanitize_name(prop_name) + # Deduplicate: if sanitized names collide (e.g. "foo-bar" and + # "foo_bar" both become "foo_bar"), append a numeric suffix. + base = field_name + counter = 2 + while field_name in used_field_names: + field_name = f"{base}_{counter}" + counter += 1 + used_field_names.add(field_name) - # Check for self-reference in property - if prop_schema.get("$ref") == "#": + # Boolean schemas (JSON Schema draft-06+): resolve type directly, + # then use an empty dict for .get() calls below. + if isinstance(prop_schema, bool): + field_type = _schema_to_type(prop_schema, schemas or {}) + prop_schema = {} + elif prop_schema.get("$ref") == "#": + # Check for self-reference in property field_type = ForwardRef(sanitized_name) else: field_type = _schema_to_type(prop_schema, schemas or {}) @@ -623,6 +718,10 @@ def _merge_defaults( # For each property in the schema for prop_name, prop_schema in schema.get("properties", {}).items(): + # Normalize boolean schemas (JSON Schema draft-06+) + if isinstance(prop_schema, bool): + continue + # If property is missing, apply defaults in priority order if prop_name not in result: if parent_default and prop_name in parent_default: diff --git a/src/fastmcp/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py index c2f3e92d4..8980e3d6a 100644 --- a/src/fastmcp/utilities/openapi/director.py +++ b/src/fastmcp/utilities/openapi/director.py @@ -259,6 +259,28 @@ class RequestDirector: param_info = param_lookup.get(key) if param_info is not None: explode = param_info.explode if param_info.explode is not None else True + if isinstance(value, dict): + if not value: + continue + if explode: + # form,explode=true on objects: each property becomes + # a separate query parameter. + # e.g. {"R": 100, "G": 200} → R=100&G=200 + for k, v in value.items(): + serialized[_query_scalar_to_str(k)] = _query_scalar_to_str( + v + ) + else: + style = param_info.style or "form" + delimiter = self._STYLE_DELIMITERS.get(style, ",") + # form,explode=false on objects: key,value pairs + # e.g. {"R": 100, "G": 200} → "R,100,G,200" + parts: list[str] = [] + for k, v in value.items(): + parts.append(_query_scalar_to_str(k)) + parts.append(_query_scalar_to_str(v)) + serialized[key] = delimiter.join(parts) + continue if not explode: style = param_info.style or "form" delimiter = self._STYLE_DELIMITERS.get(style, ",") @@ -269,17 +291,6 @@ class RequestDirector: _query_scalar_to_str(v) for v in value ) continue - elif isinstance(value, dict): - if not value: - continue - # form,explode=false on objects: key,value pairs - # e.g. {"R": 100, "G": 200} → "R,100,G,200" - parts: list[str] = [] - for k, v in value.items(): - parts.append(_query_scalar_to_str(k)) - parts.append(_query_scalar_to_str(v)) - serialized[key] = delimiter.join(parts) - continue serialized[key] = value return serialized diff --git a/src/fastmcp/utilities/openapi/json_schema_converter.py b/src/fastmcp/utilities/openapi/json_schema_converter.py index c92b2f394..5d0e02eed 100644 --- a/src/fastmcp/utilities/openapi/json_schema_converter.py +++ b/src/fastmcp/utilities/openapi/json_schema_converter.py @@ -26,6 +26,8 @@ OPENAPI_SPECIFIC_FIELDS = { # Fields that should be recursively processed RECURSIVE_FIELDS = { "properties": dict, + "$defs": dict, + "$definitions": dict, "items": dict, "additionalProperties": dict, "allOf": list, @@ -108,19 +110,19 @@ def convert_openapi_schema_to_json_schema( for field_name, field_type in RECURSIVE_FIELDS.items(): if field_name in result: if field_type is dict and isinstance(result[field_name], dict): - if field_name == "properties": - # Handle properties specially - each property is a schema + if field_name in ("properties", "$defs", "$definitions"): + # Handle maps of schemas (properties, $defs, $definitions) result[field_name] = { - prop_name: convert_openapi_schema_to_json_schema( - prop_schema, + name: convert_openapi_schema_to_json_schema( + sub_schema, openapi_version, remove_read_only, remove_write_only, convert_one_of_to_any_of, ) - if isinstance(prop_schema, dict) - else prop_schema - for prop_name, prop_schema in result[field_name].items() + if isinstance(sub_schema, dict) + else sub_schema + for name, sub_schema in result[field_name].items() } else: result[field_name] = convert_openapi_schema_to_json_schema( @@ -214,20 +216,20 @@ def _needs_recursive_processing( for field_name, field_type in RECURSIVE_FIELDS.items(): if field_name in schema: if field_type is dict and isinstance(schema[field_name], dict): - if field_name == "properties": - # Check if any property needs conversion - for prop_schema in schema[field_name].values(): - if isinstance(prop_schema, dict): + if field_name in ("properties", "$defs", "$definitions"): + # Check if any schema in the map needs conversion + for sub_schema in schema[field_name].values(): + if isinstance(sub_schema, dict): nested_needs_conversion = ( any( - field in prop_schema + field in sub_schema for field in OPENAPI_SPECIFIC_FIELDS ) - or (remove_read_only and prop_schema.get("readOnly")) - or (remove_write_only and prop_schema.get("writeOnly")) - or (convert_one_of_to_any_of and "oneOf" in prop_schema) + or (remove_read_only and sub_schema.get("readOnly")) + or (remove_write_only and sub_schema.get("writeOnly")) + or (convert_one_of_to_any_of and "oneOf" in sub_schema) or _needs_recursive_processing( - prop_schema, + sub_schema, openapi_version, remove_read_only, remove_write_only, diff --git a/src/fastmcp/utilities/openapi/schemas.py b/src/fastmcp/utilities/openapi/schemas.py index fa93b6c8d..dd2df6010 100644 --- a/src/fastmcp/utilities/openapi/schemas.py +++ b/src/fastmcp/utilities/openapi/schemas.py @@ -4,6 +4,7 @@ from typing import Any from fastmcp.utilities.logging import get_logger +from .json_schema_converter import convert_openapi_schema_to_json_schema from .models import HTTPRoute, JsonSchema, ResponseInfo logger = get_logger(__name__) @@ -246,7 +247,8 @@ def _combine_schemas_and_map_params( "header": set(), "cookie": set(), } - body_props = {} + body_schema: dict[str, Any] = {} + body_props: dict[str, Any] = {} for param in route.parameters: param_names_by_location[param.location].add(param.name) @@ -451,6 +453,9 @@ def _combine_schemas_and_map_params( # From parser - already converted and pruned result["$defs"] = schema_defs + if route.openapi_version and route.openapi_version.startswith("3"): + result = convert_openapi_schema_to_json_schema(result, route.openapi_version) + return result, parameter_map @@ -555,8 +560,6 @@ def extract_output_schema_from_responses( if openapi_version and openapi_version.startswith("3"): # Convert OpenAPI 3.x schema to JSON Schema format for proper handling # of constructs like oneOf, anyOf, and nullable fields - from .json_schema_converter import convert_openapi_schema_to_json_schema - output_schema = convert_openapi_schema_to_json_schema( output_schema, openapi_version ) @@ -584,8 +587,6 @@ def extract_output_schema_from_responses( # Convert OpenAPI schema definitions to JSON Schema format if needed if openapi_version and openapi_version.startswith("3"): - from .json_schema_converter import convert_openapi_schema_to_json_schema - for def_name in list(processed_defs.keys()): processed_defs[def_name] = convert_openapi_schema_to_json_schema( processed_defs[def_name], openapi_version diff --git a/tests/apps/__init__.py b/tests/apps/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/apps/test_approval.py b/tests/apps/test_approval.py new file mode 100644 index 000000000..d06128612 --- /dev/null +++ b/tests/apps/test_approval.py @@ -0,0 +1,56 @@ +"""Tests for the Approval provider.""" + +from fastmcp import FastMCP +from fastmcp.apps.approval import Approval + + +class TestApprovalProvider: + async def test_request_approval_returns_structured_content(self): + server = FastMCP("test", providers=[Approval()]) + + result = await server.call_tool( + "request_approval", + {"summary": "Delete 47 files"}, + ) + assert result.structured_content is not None + + async def test_request_approval_with_details(self): + server = FastMCP("test", providers=[Approval()]) + + result = await server.call_tool( + "request_approval", + {"summary": "Deploy to prod", "details": "Version 3.2.0"}, + ) + assert result.structured_content is not None + + async def test_tool_visible_to_model(self): + server = FastMCP("test", providers=[Approval()]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "request_approval" in tool_names + + async def test_custom_name(self): + server = FastMCP("test", providers=[Approval(name="Gate")]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "request_approval" in tool_names + + async def test_custom_button_text(self): + server = FastMCP( + "test", + providers=[ + Approval( + approve_text="Ship it", + reject_text="Nope", + title="Deploy Gate", + ) + ], + ) + + result = await server.call_tool( + "request_approval", + {"summary": "Deploy v3.2"}, + ) + assert result.structured_content is not None diff --git a/tests/apps/test_choice.py b/tests/apps/test_choice.py new file mode 100644 index 000000000..2de7b0a89 --- /dev/null +++ b/tests/apps/test_choice.py @@ -0,0 +1,50 @@ +"""Tests for the Choice provider.""" + +from fastmcp import FastMCP +from fastmcp.apps.choice import Choice + + +class TestChoiceProvider: + async def test_choose_returns_structured_content(self): + server = FastMCP("test", providers=[Choice()]) + + result = await server.call_tool( + "choose", + {"prompt": "Pick one", "options": ["A", "B", "C"]}, + ) + assert result.structured_content is not None + + async def test_tool_visible_to_model(self): + server = FastMCP("test", providers=[Choice()]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "choose" in tool_names + + async def test_custom_name(self): + server = FastMCP("test", providers=[Choice(name="Picker")]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "choose" in tool_names + + async def test_custom_title(self): + server = FastMCP("test", providers=[Choice(title="Select Strategy")]) + + result = await server.call_tool( + "choose", + {"prompt": "How?", "options": ["Fast", "Slow"]}, + ) + assert result.structured_content is not None + + async def test_many_options(self): + server = FastMCP("test", providers=[Choice()]) + + result = await server.call_tool( + "choose", + { + "prompt": "Pick a color", + "options": ["Red", "Blue", "Green", "Yellow", "Purple"], + }, + ) + assert result.structured_content is not None diff --git a/tests/apps/test_file_upload.py b/tests/apps/test_file_upload.py new file mode 100644 index 000000000..f8b65538b --- /dev/null +++ b/tests/apps/test_file_upload.py @@ -0,0 +1,233 @@ +"""Tests for the FileUpload provider.""" + +import base64 + +import pytest + +from fastmcp import FastMCP +from fastmcp.apps.file_upload import FileUpload, _b64_decoded_size +from fastmcp.server.providers.addressing import hashed_backend_name + + +class TestB64DecodedSize: + """Unit tests for the _b64_decoded_size helper.""" + + @pytest.mark.parametrize("size", [0, 1, 2, 3, 4, 50, 99, 100, 101, 1000]) + def test_matches_actual_decode(self, size: int): + data = b"x" * size + b64 = base64.b64encode(data).decode() + assert _b64_decoded_size(b64) == size + + def test_empty_string(self): + assert _b64_decoded_size("") == 0 + + def test_no_padding(self): + # 3 bytes → 4 base64 chars, no padding + assert _b64_decoded_size(base64.b64encode(b"abc").decode()) == 3 + + def test_one_pad(self): + # 2 bytes → 4 base64 chars with 1 '=' + assert _b64_decoded_size(base64.b64encode(b"ab").decode()) == 2 + + def test_two_pads(self): + # 1 byte → 4 base64 chars with 2 '=' + assert _b64_decoded_size(base64.b64encode(b"a").decode()) == 1 + + +def _make_file( + name: str = "test.txt", + content: str = "hello world", + mime_type: str = "text/plain", +) -> dict: + data = base64.b64encode(content.encode()).decode() + return { + "name": name, + "size": len(content), + "type": mime_type, + "data": data, + } + + +class TestFileUploadProvider: + async def test_basic_store_and_list(self): + server = FastMCP("test", providers=[FileUpload()]) + files = [_make_file()] + + result = await server.call_tool( + hashed_backend_name("Files", "store_files"), {"files": files} + ) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "test.txt" in text + + result = await server.call_tool("list_files", {}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "test.txt" in text + + async def test_read_text_file(self): + server = FastMCP("test", providers=[FileUpload()]) + files = [_make_file(content="DON'T PANIC")] + + await server.call_tool( + hashed_backend_name("Files", "store_files"), {"files": files} + ) + + result = await server.call_tool("read_file", {"name": "test.txt"}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "DON'T PANIC" in text + + async def test_read_binary_file(self): + server = FastMCP("test", providers=[FileUpload()]) + data = base64.b64encode(b"\x00\x01\x02\xff").decode() + files = [{"name": "image.png", "size": 4, "type": "image/png", "data": data}] + + await server.call_tool( + hashed_backend_name("Files", "store_files"), {"files": files} + ) + + result = await server.call_tool("read_file", {"name": "image.png"}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "content_base64" in text + + async def test_read_missing_file_raises(self): + server = FastMCP("test", providers=[FileUpload()]) + + with pytest.raises(Exception, match="not found"): + await server.call_tool("read_file", {"name": "nope.txt"}) + + async def test_multiple_files(self): + server = FastMCP("test", providers=[FileUpload()]) + files = [ + _make_file("a.txt", "aaa"), + _make_file("b.txt", "bbb"), + ] + + await server.call_tool( + hashed_backend_name("Files", "store_files"), {"files": files} + ) + + result = await server.call_tool("list_files", {}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "a.txt" in text + assert "b.txt" in text + + async def test_overwrite_file(self): + server = FastMCP("test", providers=[FileUpload()]) + + await server.call_tool( + hashed_backend_name("Files", "store_files"), + {"files": [_make_file(content="version 1")]}, + ) + await server.call_tool( + hashed_backend_name("Files", "store_files"), + {"files": [_make_file(content="version 2")]}, + ) + + result = await server.call_tool("read_file", {"name": "test.txt"}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "version 2" in text + + async def test_custom_name(self): + server = FastMCP("test", providers=[FileUpload(name="Uploads")]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "file_manager" in tool_names + + # The hash uses the app's actual name ("Uploads"), not the default. + files = [_make_file()] + result = await server.call_tool( + hashed_backend_name("Uploads", "store_files"), {"files": files} + ) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "test.txt" in text + + async def test_ui_tool_visible_backend_hidden(self): + server = FastMCP("test", providers=[FileUpload()]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + + assert "file_manager" in tool_names + assert "list_files" in tool_names + assert "read_file" in tool_names + assert "store_files" not in tool_names + + async def test_max_file_size_enforced_server_side(self): + server = FastMCP("test", providers=[FileUpload(max_file_size=100)]) + big_file = _make_file(content="x" * 200) + + with pytest.raises(Exception, match="exceeds max size"): + await server.call_tool( + hashed_backend_name("Files", "store_files"), {"files": [big_file]} + ) + + async def test_max_file_size_checks_actual_data_not_reported_size(self): + """Size limit should be enforced on actual base64 payload, not the + client-reported ``size`` field which can be spoofed.""" + server = FastMCP("test", providers=[FileUpload(max_file_size=100)]) + + big_content = "x" * 200 + big_b64 = base64.b64encode(big_content.encode()).decode() + spoofed_file = { + "name": "spoofed.bin", + "size": 1, # lies about size + "type": "application/octet-stream", + "data": big_b64, + } + + with pytest.raises(Exception, match="exceeds max size"): + await server.call_tool( + hashed_backend_name("Files", "store_files"), {"files": [spoofed_file]} + ) + + +class TestFileUploadSubclass: + async def test_custom_storage(self): + """Subclassing lets users provide their own persistence.""" + stored: dict[str, dict] = {} + + class MemoryUpload(FileUpload): + def on_store(self, files: list[dict], ctx) -> list[dict]: + for f in files: + stored[f["name"]] = f + return [ + { + "name": f["name"], + "type": f["type"], + "size": f["size"], + "size_display": "?", + "uploaded_at": "now", + } + for f in files + ] + + def on_list(self, ctx) -> list[dict]: + return [ + { + "name": f["name"], + "type": f["type"], + "size": f["size"], + "size_display": "?", + "uploaded_at": "now", + } + for f in stored.values() + ] + + def on_read(self, name: str, ctx) -> dict: + if name not in stored: + raise ValueError(f"Not found: {name}") + f = stored[name] + return {"name": f["name"], "content": "custom read"} + + server = FastMCP("test", providers=[MemoryUpload()]) + files = [_make_file()] + + await server.call_tool( + hashed_backend_name("Files", "store_files"), {"files": files} + ) + + assert "test.txt" in stored + + result = await server.call_tool("read_file", {"name": "test.txt"}) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "custom read" in text diff --git a/tests/apps/test_form.py b/tests/apps/test_form.py new file mode 100644 index 000000000..5d3af18af --- /dev/null +++ b/tests/apps/test_form.py @@ -0,0 +1,170 @@ +"""Tests for the FormInput provider.""" + +import json + +import pydantic +import pytest + +from fastmcp import FastMCP +from fastmcp.apps.form import FormInput, _backfill_boolean_defaults +from fastmcp.server.providers.addressing import hashed_backend_name + + +class Contact(pydantic.BaseModel): + name: str + email: str + phone: str | None = None + + +class NoteForm(pydantic.BaseModel): + title: str + content: str + archived: bool = False + + +class TestFormInputProvider: + async def test_collect_returns_structured_content(self): + server = FastMCP("test", providers=[FormInput(model=Contact)]) + + result = await server.call_tool( + "collect_contact", + {"prompt": "Enter your details"}, + ) + assert result.structured_content is not None + + async def test_tool_name_derived_from_model(self): + server = FastMCP("test", providers=[FormInput(model=Contact)]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "collect_contact" in tool_names + + async def test_custom_tool_name(self): + server = FastMCP( + "test", + providers=[FormInput(model=Contact, tool_name="new_contact")], + ) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "new_contact" in tool_names + + async def test_submit_validates_and_returns_json(self): + server = FastMCP("test", providers=[FormInput(model=Contact)]) + + result = await server.call_tool( + hashed_backend_name("Contact", "submit_form"), + {"data": {"name": "Alice", "email": "alice@example.com"}}, + ) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + parsed = json.loads(text) + assert parsed["name"] == "Alice" + assert parsed["email"] == "alice@example.com" + assert parsed["phone"] is None + + async def test_submit_with_callback(self): + saved: list[Contact] = [] + + def on_submit(contact: Contact) -> str: + saved.append(contact) + return f"Saved {contact.name}" + + server = FastMCP( + "test", + providers=[FormInput(model=Contact, on_submit=on_submit)], + ) + + result = await server.call_tool( + hashed_backend_name("Contact", "submit_form"), + {"data": {"name": "Bob", "email": "bob@example.com"}}, + ) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + assert "Saved Bob" in text + assert len(saved) == 1 + assert saved[0].name == "Bob" + + async def test_backend_tool_hidden(self): + server = FastMCP("test", providers=[FormInput(model=Contact)]) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "_submit_form" not in tool_names + + async def test_submit_boolean_false_omitted(self): + """Unchecked checkboxes omit the field; submit_form should still succeed.""" + server = FastMCP("test", providers=[FormInput(model=NoteForm)]) + + result = await server.call_tool( + hashed_backend_name("NoteForm", "submit_form"), + {"data": {"title": "My Note", "content": "Hello"}}, + ) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + parsed = json.loads(text) + assert parsed["title"] == "My Note" + assert parsed["archived"] is False + + async def test_submit_boolean_true_preserved(self): + """When a boolean field is explicitly True, it should be preserved.""" + server = FastMCP("test", providers=[FormInput(model=NoteForm)]) + + result = await server.call_tool( + hashed_backend_name("NoteForm", "submit_form"), + {"data": {"title": "My Note", "content": "Hello", "archived": True}}, + ) + text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + parsed = json.loads(text) + assert parsed["archived"] is True + + async def test_submit_no_data_does_not_crash(self): + """When data is omitted entirely, the tool should not raise a missing argument error.""" + server = FastMCP("test", providers=[FormInput(model=NoteForm)]) + + # Should reach model validation (not crash with "missing required argument" + # for the data parameter itself). Pydantic will still reject missing + # required fields like title/content, but that's expected. + with pytest.raises(pydantic.ValidationError, match="title"): + await server.call_tool(hashed_backend_name("NoteForm", "submit_form"), {}) + + async def test_multiple_models(self): + class Address(pydantic.BaseModel): + street: str + city: str + + server = FastMCP( + "test", + providers=[ + FormInput(model=Contact), + FormInput(model=Address), + ], + ) + + tools = await server.list_tools() + tool_names = [t.name for t in tools] + assert "collect_contact" in tool_names + assert "collect_address" in tool_names + + +class TestBackfillBooleanDefaults: + def test_missing_bool_with_default_gets_backfilled(self): + data = {"title": "Note", "content": "Body"} + result = _backfill_boolean_defaults(NoteForm, data) + assert result["archived"] is False + + def test_present_bool_not_overwritten(self): + data = {"title": "Note", "content": "Body", "archived": True} + result = _backfill_boolean_defaults(NoteForm, data) + assert result["archived"] is True + + def test_required_bool_without_default_gets_false(self): + class FormWithRequiredBool(pydantic.BaseModel): + name: str + active: bool + + data = {"name": "Test"} + result = _backfill_boolean_defaults(FormWithRequiredBool, data) + assert result["active"] is False + + def test_non_bool_fields_untouched(self): + data = {"title": "Note"} + result = _backfill_boolean_defaults(NoteForm, data) + assert "content" not in result diff --git a/tests/client/client/test_client.py b/tests/client/client/test_client.py index d0d44bd2a..7257490f8 100644 --- a/tests/client/client/test_client.py +++ b/tests/client/client/test_client.py @@ -13,6 +13,7 @@ from pydantic import AnyUrl import fastmcp from fastmcp.client import Client +from fastmcp.client.tasks import TaskNotificationHandler from fastmcp.client.transports import ( ClientTransport, FastMCPTransport, @@ -439,6 +440,32 @@ class _DelayedConnectTransport(ClientTransport): await self._inner.close() +class _DelayedDisconnectTransport(ClientTransport): + def __init__( + self, + inner: ClientTransport, + disconnect_started: anyio.Event, + allow_disconnect: anyio.Event, + ) -> None: + self._inner = inner + self._disconnect_started = disconnect_started + self._allow_disconnect = allow_disconnect + + @contextlib.asynccontextmanager + async def connect_session( + self, **session_kwargs: Any + ) -> AsyncIterator[ClientSession]: + async with self._inner.connect_session(**session_kwargs) as session: + try: + yield session + finally: + self._disconnect_started.set() + await self._allow_disconnect.wait() + + async def close(self) -> None: + await self._inner.close() + + async def test_client_nested_context_manager(fastmcp_server): """Test that the client connects and disconnects once in nested context manager.""" @@ -552,6 +579,45 @@ async def test_cancelled_context_entry_waiter_does_not_close_active_session( assert await a == 3 +async def test_force_close_cancelled_wait_starts_fresh_session(fastmcp_server): + disconnect_started = anyio.Event() + allow_disconnect = anyio.Event() + client = Client( + transport=_DelayedDisconnectTransport( + FastMCPTransport(fastmcp_server), + disconnect_started=disconnect_started, + allow_disconnect=allow_disconnect, + ) + ) + + await client._connect() + original_session_task = client._session_state.session_task + assert original_session_task is not None + + close_task = asyncio.create_task(client.close()) + await disconnect_started.wait() + + close_task.cancel() + + async def reconnect_and_count_tools() -> int: + async with client: + assert client._session_state.session_task is not original_session_task + tools = await client.list_tools() + return len(tools) + + reconnect_task = asyncio.create_task(reconnect_and_count_tools()) + await asyncio.sleep(0) + assert not reconnect_task.done() + + allow_disconnect.set() + + with contextlib.suppress(asyncio.CancelledError): + await close_task + + assert await reconnect_task == 3 + assert original_session_task.done() + + async def test_concurrent_client_context_managers(): """ Test that concurrent client usage doesn't cause cross-task cancel scope issues. @@ -749,3 +815,52 @@ async def test_client_does_not_unwrap_dict_result(): assert result.structured_content == {"a": 1} assert result.data == {"a": 1} assert result.meta is None + + +async def test_client_list_dict_return_type(): + """list[dict] return type should produce list of dicts, not Root() objects (issue #3867).""" + server = FastMCP() + + @server.tool + def get_temperatures() -> list[dict]: + """Get current temperatures for all cities""" + return [ + {"city": "NYC", "temp": 72}, + {"city": "LA", "temp": 85}, + ] + + client = Client(transport=FastMCPTransport(server)) + async with client: + result = await client.call_tool("get_temperatures", {}) + assert result.data == [{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}] + + +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)) + + 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 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 5f5c22175..49a488022 100644 --- a/tests/client/client/test_error_handling.py +++ b/tests/client/client/test_error_handling.py @@ -1,10 +1,12 @@ """Client error handling tests.""" +import mcp.types import pytest from mcp.types import TextContent from pydantic import AnyUrl from fastmcp.client import Client +from fastmcp.client.mixins.tools import _parse_call_tool_result from fastmcp.client.transports import FastMCPTransport from fastmcp.exceptions import ResourceError, ToolError from fastmcp.server.server import FastMCP @@ -164,3 +166,102 @@ class TestErrorHandling: with pytest.raises(Exception) as excinfo: await client.read_resource(AnyUrl("error://resource/123")) assert "This is a resource error (xyz)" in str(excinfo.value) + + +class TestCallToolRaiseOnError: + """Tests for call_tool error handling with raise_on_error.""" + + async def test_call_tool_raises_tool_error_by_default(self): + mcp = FastMCP("TestServer") + + @mcp.tool + def failing_tool() -> str: + raise ValueError("something broke") + + async with Client(transport=FastMCPTransport(mcp)) as client: + with pytest.raises(ToolError, match="something broke"): + await client.call_tool("failing_tool", {}) + + async def test_call_tool_no_raise_returns_error_result(self): + mcp = FastMCP("TestServer") + + @mcp.tool + def failing_tool() -> str: + raise ValueError("something broke") + + async with Client(transport=FastMCPTransport(mcp)) as client: + result = await client.call_tool("failing_tool", {}, raise_on_error=False) + assert result.is_error is True + assert result.data is None + + +class TestParseToolResultEdgeCases: + """Unit tests for _parse_call_tool_result with non-standard error payloads. + + These edge cases can't be triggered through the public Client API because + FastMCP's server always produces TextContent on errors. Testing the parser + directly covers defensive handling of third-party MCP servers. + """ + + async def test_error_with_empty_content_raises_with_fallback_message(self): + result = mcp.types.CallToolResult(content=[], isError=True) + + with pytest.raises(ToolError, match="Tool 'my_tool' returned an error"): + await _parse_call_tool_result( + name="my_tool", + result=result, + tool_output_schemas={}, + list_tools_fn=None, + raise_on_error=True, + ) + + async def test_error_with_non_text_content_raises_with_fallback_message(self): + result = mcp.types.CallToolResult( + content=[ + mcp.types.ImageContent(type="image", data="abc", mimeType="image/png") + ], + isError=True, + ) + + with pytest.raises(ToolError, match="Tool 'my_tool' returned an error"): + await _parse_call_tool_result( + name="my_tool", + result=result, + tool_output_schemas={}, + list_tools_fn=None, + raise_on_error=True, + ) + + async def test_error_with_text_content_raises_with_message(self): + result = mcp.types.CallToolResult( + content=[mcp.types.TextContent(type="text", text="custom error msg")], + isError=True, + ) + + with pytest.raises(ToolError, match="custom error msg"): + await _parse_call_tool_result( + name="my_tool", + result=result, + tool_output_schemas={}, + list_tools_fn=None, + raise_on_error=True, + ) + + async def test_error_with_structured_content_does_not_parse_data(self): + result = mcp.types.CallToolResult( + content=[mcp.types.TextContent(type="text", text="error happened")], + isError=True, + structuredContent={"key": "value"}, + ) + + parsed = await _parse_call_tool_result( + name="my_tool", + result=result, + tool_output_schemas={}, + list_tools_fn=None, + raise_on_error=False, + ) + + assert parsed.is_error is True + assert parsed.data is None + assert parsed.structured_content == {"key": "value"} diff --git a/tests/client/sampling/handlers/test_anthropic_handler.py b/tests/client/sampling/handlers/test_anthropic_handler.py index 5910eb92b..757ec2eeb 100644 --- a/tests/client/sampling/handlers/test_anthropic_handler.py +++ b/tests/client/sampling/handlers/test_anthropic_handler.py @@ -8,14 +8,17 @@ from mcp.types import ( AudioContent, CreateMessageResult, CreateMessageResultWithTools, + EmbeddedResource, ImageContent, ModelHint, ModelPreferences, SamplingMessage, TextContent, + TextResourceContents, ToolResultContent, ToolUseContent, ) +from pydantic import AnyUrl from fastmcp.client.sampling.handlers.anthropic import ( AnthropicSamplingHandler, @@ -372,3 +375,28 @@ def test_convert_messages_with_tool_result_content(): "is_error": False, } ] + + +def test_convert_messages_raises_on_unsupported_content_type(): + """Unsupported content types should raise ValueError. + + SamplingMessage validates content against a union of known types, so + we use model_construct to bypass validation and simulate a future + SDK content type that the handler doesn't know about yet. + """ + embedded = EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=AnyUrl("file:///test.txt"), text="hello", mimeType="text/plain" + ), + ) + # Must be inside a list content — single-content messages hit a + # different check. Use model_construct to bypass Pydantic's + # union validation (EmbeddedResource is not in the content union). + msg = SamplingMessage.model_construct( + role="user", + content=[TextContent(type="text", text="prefix"), embedded], + ) + + with pytest.raises(ValueError, match="Unsupported content type for Anthropic"): + AnthropicSamplingHandler._convert_to_anthropic_messages([msg]) diff --git a/tests/client/sampling/handlers/test_google_genai_handler.py b/tests/client/sampling/handlers/test_google_genai_handler.py index 7eb0da188..d1b86d6c1 100644 --- a/tests/client/sampling/handlers/test_google_genai_handler.py +++ b/tests/client/sampling/handlers/test_google_genai_handler.py @@ -432,3 +432,170 @@ def test_response_to_result_with_tools_mixed_content(): assert isinstance(tool_use, ToolUseContent) assert tool_use.type == "tool_use" assert tool_use.name == "search" + + +# ──────────────────────────────────────────────────────────── +# Test for title stripping (PR #3860) +# ──────────────────────────────────────────────────────────── + + +def test_convert_tool_strips_titles(): + """_convert_tool_to_google_genai should strip titles from inputSchema. + + We test via compress_schema directly rather than constructing a + FunctionDeclaration because older google-genai versions may not + support the parameters_json_schema field. + """ + from fastmcp.utilities.json_schema import compress_schema + + input_schema = { + "title": "Params", + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + }, + } + + schema = compress_schema(input_schema, prune_titles=True) + assert "title" not in schema + assert "title" not in schema["properties"]["query"] + assert schema["properties"]["query"]["type"] == "string" + + +# ──────────────────────────────────────────────────────────── +# Tests for thought-part filtering and improved error messages +# (PR #3849) +# ──────────────────────────────────────────────────────────── + + +def test_thought_parts_filtered_on_tool_path(): + """Thought parts should be excluded; only the real text part should appear.""" + mock_candidate = MagicMock(spec=Candidate) + mock_candidate.content = MagicMock() + mock_candidate.content.parts = [ + Part(text="thinking about the problem...", thought=True), + Part(text="Here is the real answer"), + ] + mock_candidate.finish_reason = "STOP" + + mock_response = MagicMock(spec=GenerateContentResponse) + mock_response.candidates = [mock_candidate] + + result = _response_to_result_with_tools(mock_response, model="gemini-2.5-flash") + + assert len(result.content) == 1 # ty: ignore[invalid-argument-type] + assert isinstance(result.content[0], TextContent) # ty: ignore[not-subscriptable] + assert result.content[0].text == "Here is the real answer" # ty: ignore[not-subscriptable] + + +def test_thought_only_response_on_tool_path_raises(): + """When the response contains ONLY thought parts the tool path should raise + a ValueError whose message includes the finish_reason.""" + mock_candidate = MagicMock(spec=Candidate) + mock_candidate.content = MagicMock() + mock_candidate.content.parts = [ + Part(text="deep thinking...", thought=True), + ] + mock_candidate.finish_reason = "STOP" + + mock_response = MagicMock(spec=GenerateContentResponse) + mock_response.candidates = [mock_candidate] + + with pytest.raises(ValueError, match="finish_reason=STOP"): + _response_to_result_with_tools(mock_response, model="gemini-2.5-flash") + + +def test_thought_only_response_on_non_tool_path_raises(): + """When the non-tool path receives only thoughts the error message should + mention 'thinking/reasoning content'.""" + mock_candidate = MagicMock(spec=Candidate) + mock_candidate.content = MagicMock() + mock_candidate.content.parts = [ + Part(text="internal reasoning...", thought=True), + ] + mock_candidate.finish_reason = "STOP" + + mock_response = MagicMock(spec=GenerateContentResponse) + mock_response.text = None # No real text available + mock_response.candidates = [mock_candidate] + + with pytest.raises(ValueError, match="thinking/reasoning content"): + _response_to_create_message_result(mock_response, model="gemini-2.5-flash") + + +def test_safety_filtered_response_on_tool_path_raises(): + """A safety-filtered response (no parts) should raise with the finish_reason + included in the error message.""" + mock_candidate = MagicMock(spec=Candidate) + mock_candidate.content = MagicMock() + mock_candidate.content.parts = [] # Empty parts after safety filtering + mock_candidate.finish_reason = "SAFETY" + + mock_response = MagicMock(spec=GenerateContentResponse) + mock_response.candidates = [mock_candidate] + + with pytest.raises(ValueError, match="finish_reason=SAFETY"): + _response_to_result_with_tools(mock_response, model="gemini-2.5-flash") + + +def test_safety_filtered_response_on_non_tool_path_raises(): + """A safety-filtered response on the non-tool path should include + finish_reason in the error.""" + mock_candidate = MagicMock(spec=Candidate) + mock_candidate.content = MagicMock() + mock_candidate.content.parts = [] + mock_candidate.finish_reason = "SAFETY" + + mock_response = MagicMock(spec=GenerateContentResponse) + mock_response.text = None + mock_response.candidates = [mock_candidate] + + with pytest.raises(ValueError, match="finish_reason=SAFETY"): + _response_to_create_message_result(mock_response, model="gemini-2.5-flash") + + +def test_normal_response_text_and_function_call(): + """A normal response with both real text and a function call should + produce both TextContent and ToolUseContent in the result.""" + mock_candidate = MagicMock(spec=Candidate) + mock_candidate.content = MagicMock() + mock_candidate.content.parts = [ + Part(text="Let me look that up."), + Part(function_call=FunctionCall(name="lookup", args={"q": "test"})), + ] + mock_candidate.finish_reason = "STOP" + + mock_response = MagicMock(spec=GenerateContentResponse) + mock_response.candidates = [mock_candidate] + + result = _response_to_result_with_tools(mock_response, model="gemini-2.5-flash") + + assert len(result.content) == 2 # ty: ignore[invalid-argument-type] + assert isinstance(result.content[0], TextContent) # ty: ignore[not-subscriptable] + assert result.content[0].text == "Let me look that up." # ty: ignore[not-subscriptable] + assert isinstance(result.content[1], ToolUseContent) # ty: ignore[not-subscriptable] + assert result.content[1].name == "lookup" # ty: ignore[not-subscriptable] + assert result.content[1].input == {"q": "test"} # ty: ignore[not-subscriptable] + assert result.stopReason == "toolUse" + + +def test_thought_with_function_call_keeps_function_call(): + """When thinking parts accompany a function call, only the function call + should appear in the content (thought parts filtered out).""" + mock_candidate = MagicMock(spec=Candidate) + mock_candidate.content = MagicMock() + mock_candidate.content.parts = [ + Part(text="reasoning about what tool to use...", thought=True), + Part(function_call=FunctionCall(name="get_weather", args={"city": "NYC"})), + ] + mock_candidate.finish_reason = "STOP" + + mock_response = MagicMock(spec=GenerateContentResponse) + mock_response.candidates = [mock_candidate] + + result = _response_to_result_with_tools(mock_response, model="gemini-2.5-flash") + + assert len(result.content) == 1 # ty: ignore[invalid-argument-type] + assert isinstance(result.content[0], ToolUseContent) # ty: ignore[not-subscriptable] + assert result.content[0].name == "get_weather" # ty: ignore[not-subscriptable] + assert result.stopReason == "toolUse" diff --git a/tests/client/sampling/handlers/test_openai_handler.py b/tests/client/sampling/handlers/test_openai_handler.py index e80ba3292..4cfb9d606 100644 --- a/tests/client/sampling/handlers/test_openai_handler.py +++ b/tests/client/sampling/handlers/test_openai_handler.py @@ -6,11 +6,13 @@ from mcp.types import ( AudioContent, CreateMessageRequestParams, CreateMessageResult, + EmbeddedResource, ImageContent, ModelHint, ModelPreferences, SamplingMessage, TextContent, + TextResourceContents, ToolUseContent, ) from openai import AsyncOpenAI @@ -25,6 +27,7 @@ from openai.types.chat import ( ChatCompletionUserMessageParam, ) from openai.types.chat.chat_completion import Choice +from pydantic import AnyUrl from fastmcp.client.sampling.handlers.openai import ( OpenAISamplingHandler, @@ -316,3 +319,25 @@ async def test_chat_completion_to_create_message_result(): role="assistant", model="gpt-4o-mini", ) + + +def test_convert_messages_raises_on_unsupported_content_type(): + """Unsupported content types should raise ValueError. + + SamplingMessage validates content against a union of known types, so + we use model_construct to bypass validation and simulate a future + SDK content type that the handler doesn't know about yet. + """ + embedded = EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=AnyUrl("file:///test.txt"), text="hello", mimeType="text/plain" + ), + ) + msg = SamplingMessage.model_construct( + role="user", + content=[TextContent(type="text", text="prefix"), embedded], + ) + + with pytest.raises(ValueError, match="Unsupported content type for OpenAI"): + OpenAISamplingHandler._convert_to_openai_messages(None, [msg]) diff --git a/tests/client/tasks/test_client_task_notifications.py b/tests/client/tasks/test_client_task_notifications.py index 8fba3aad2..b02d149fc 100644 --- a/tests/client/tasks/test_client_task_notifications.py +++ b/tests/client/tasks/test_client_task_notifications.py @@ -7,6 +7,7 @@ and invoke user callbacks. import asyncio import time +from datetime import datetime, timezone import pytest from mcp.types import GetTaskResult @@ -207,3 +208,28 @@ async def test_notification_with_failed_task(task_notification_server): assert ( status.statusMessage 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 + now = datetime.now(timezone.utc) + input_required_status = GetTaskResult( + taskId=task._task_id, + status="input_required", + statusMessage="Waiting for user input", + createdAt=now, + lastUpdatedAt=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/test_elicitation.py b/tests/client/test_elicitation.py index 7b8179f82..3ca43a507 100644 --- a/tests/client/test_elicitation.py +++ b/tests/client/test_elicitation.py @@ -144,6 +144,24 @@ async def test_elicitation_cancel_action(): class TestScalarResponseTypes: + async def test_scalar_handler_return_is_auto_wrapped(self): + """Scalar handler returns are wrapped as {"value": ...} for scalar schemas.""" + mcp = FastMCP("TestServer") + + @mcp.tool + async def my_tool(context: Context) -> str: + result = await context.elicit(message="", response_type=str) + assert isinstance(result, AcceptedElicitation) + assert isinstance(result.data, str) + return result.data + + async def elicitation_handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content="Alice") + + 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") diff --git a/tests/client/test_sampling_result_types.py b/tests/client/test_sampling_result_types.py index 73d6fbddc..879db107f 100644 --- a/tests/client/test_sampling_result_types.py +++ b/tests/client/test_sampling_result_types.py @@ -1,4 +1,5 @@ -from mcp.types import TextContent +import pytest +from mcp.types import CreateMessageResultWithTools, TextContent, ToolUseContent from fastmcp import Client, Context, FastMCP from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams @@ -440,3 +441,241 @@ class TestSampleStep: 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", + stopReason="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", + stopReason="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", + stopReason="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", + stopReason="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/transports/test_transports.py b/tests/client/transports/test_transports.py index b2f319e09..fa9dd710c 100644 --- a/tests/client/transports/test_transports.py +++ b/tests/client/transports/test_transports.py @@ -203,7 +203,7 @@ class TestSSLVerify: assert isinstance(client.transport.auth, OAuth) async with client.transport.auth.httpx_client_factory() as httpx_client: assert ( - httpx_client._transport._pool._ssl_context.verify_mode + httpx_client._transport._pool._ssl_context.verify_mode # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] == VerifyMode.CERT_NONE ) @@ -226,7 +226,7 @@ class TestSSLVerify: assert isinstance(client.transport.auth, OAuth) async with client.transport.auth.httpx_client_factory() as httpx_client: assert ( - httpx_client._transport._pool._ssl_context.verify_mode + httpx_client._transport._pool._ssl_context.verify_mode # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] != VerifyMode.CERT_NONE ) diff --git a/tests/docs/test_doc_examples.py b/tests/docs/test_doc_examples.py new file mode 100644 index 000000000..9e1a8e4ea --- /dev/null +++ b/tests/docs/test_doc_examples.py @@ -0,0 +1,146 @@ +"""Validate Python code examples in FastMCP documentation. + +Extracts code blocks from .mdx docs and checks: +1. Syntax — every example parses as valid Python +2. FastMCP imports — every ``from fastmcp.x import y`` resolves + +Supports tags in code fence prefix (for future use): + ```python test="skip" — skip all checks + ```python lint="skip" — skip all checks + +Run: + uv run pytest tests/docs/test_doc_examples.py -v -s +""" + +from __future__ import annotations + +import ast +from pathlib import Path +from uuid import uuid4 + +from pytest_examples import CodeExample +from pytest_examples.find_examples import _extract_code_chunks + +DOCS_DIR = Path("docs") +_SKIP_DIRS = {"python-sdk", "public"} + +# Snapshot baselines — ratchet DOWN as doc examples are fixed. +MAX_SYNTAX_FAILURES = 0 +MAX_IMPORT_FAILURES = 0 + + +def _find_mdx_examples() -> list[CodeExample]: + """Find Python code examples in .mdx files. + + pytest-examples only supports ``.md``; we call its internal + ``_extract_code_chunks`` directly so ``.mdx`` works without copying. + """ + examples: list[CodeExample] = [] + for mdx_file in sorted(DOCS_DIR.rglob("*.mdx")): + rel = mdx_file.relative_to(DOCS_DIR) + if rel.parts and rel.parts[0] in _SKIP_DIRS: + continue + code = mdx_file.read_text("utf-8") + group = uuid4() + examples.extend(_extract_code_chunks(mdx_file, code, group)) + return examples + + +def _should_skip(example: CodeExample) -> bool: + settings = example.prefix_settings() + return settings.get("lint") == "skip" or settings.get("test") == "skip" + + +def _check_syntax(example: CodeExample) -> str | None: + """Return error description if syntax is invalid, else None.""" + try: + ast.parse(example.source) + return None + except SyntaxError as e: + rel = Path(example.path).relative_to(DOCS_DIR) + return f"{rel}:{example.start_line}: line {e.lineno}: {e.msg}" + + +def _check_fastmcp_imports(example: CodeExample) -> list[str]: + """Return list of broken fastmcp import descriptions.""" + try: + tree = ast.parse(example.source) + except SyntaxError: + return [] + + errors: list[str] = [] + rel = Path(example.path).relative_to(DOCS_DIR) + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("fastmcp"): + try: + __import__(alias.name) + except ImportError: + errors.append( + f"{rel}:{example.start_line}: cannot import '{alias.name}'" + ) + elif isinstance(node, ast.ImportFrom): + if node.module and node.module.startswith("fastmcp"): + names = [a.name for a in node.names] + try: + mod = __import__(node.module, fromlist=names) + except ImportError: + errors.append( + f"{rel}:{example.start_line}: " + f"cannot import module '{node.module}'" + ) + continue + for name in names: + if not hasattr(mod, name): + errors.append( + f"{rel}:{example.start_line}: " + f"'{node.module}' has no '{name}'" + ) + return errors + + +def test_doc_examples_quality(): + """Doc examples should not regress in syntax or import correctness. + + Checks every Python code block in ``docs/*.mdx`` (excluding + auto-generated ``python-sdk/`` and ``public/`` directories). + Reports all failures and asserts counts don't exceed known baselines. + """ + examples = _find_mdx_examples() + syntax_failures: list[str] = [] + import_failures: list[str] = [] + + for ex in examples: + if _should_skip(ex): + continue + + err = _check_syntax(ex) + if err: + syntax_failures.append(err) + continue + + import_failures.extend(_check_fastmcp_imports(ex)) + + total = len(examples) + print(f"\nDoc examples checked: {total}") + print(f"Syntax failures: {len(syntax_failures)}") + print(f"Import failures: {len(import_failures)}") + + if syntax_failures: + print("\nSyntax errors:") + for f in syntax_failures: + print(f" {f}") + + if import_failures: + print("\nBroken imports:") + for f in import_failures: + print(f" {f}") + + assert len(syntax_failures) <= MAX_SYNTAX_FAILURES, ( + f"Syntax failures regressed: {len(syntax_failures)} > {MAX_SYNTAX_FAILURES}" + ) + assert len(import_failures) <= MAX_IMPORT_FAILURES, ( + f"Import failures regressed: {len(import_failures)} > {MAX_IMPORT_FAILURES}" + ) diff --git a/tests/integration_tests/auth/test_keycloak_provider_integration.py b/tests/integration_tests/auth/test_keycloak_provider_integration.py new file mode 100644 index 000000000..3b3a8fafb --- /dev/null +++ b/tests/integration_tests/auth/test_keycloak_provider_integration.py @@ -0,0 +1,360 @@ +"""Integration tests for Keycloak OAuth provider - Minimal implementation.""" + +import os +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest + +from fastmcp import FastMCP +from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider + +TEST_REALM_URL = "https://keycloak.example.com/realms/test" +TEST_BASE_URL = "https://fastmcp.example.com" +TEST_REQUIRED_SCOPES = ["openid", "profile", "email"] + + +class TestKeycloakProviderIntegration: + """Integration tests for KeycloakAuthProvider with minimal implementation.""" + + async def test_oauth_discovery_endpoints_integration(self): + """Test OAuth discovery endpoints work correctly together.""" + with patch("httpx.get") as mock_get: + mock_response = Mock() + mock_response.json.return_value = { + "issuer": TEST_REALM_URL, + "authorization_endpoint": f"{TEST_REALM_URL}/protocol/openid-connect/auth", + "token_endpoint": f"{TEST_REALM_URL}/protocol/openid-connect/token", + "jwks_uri": f"{TEST_REALM_URL}/.well-known/jwks.json", + "registration_endpoint": f"{TEST_REALM_URL}/clients-registrations/openid-connect", + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + provider = KeycloakAuthProvider( + realm_url=TEST_REALM_URL, + base_url=TEST_BASE_URL, + required_scopes=TEST_REQUIRED_SCOPES, + ) + + mcp = FastMCP("test-server", auth=provider) + mcp_http_app = mcp.http_app() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=mcp_http_app), + base_url=TEST_BASE_URL, + ) as client: + # Test protected resource metadata + resource_response = await client.get( + "/.well-known/oauth-protected-resource/mcp" + ) + assert resource_response.status_code == 200 + resource_data = resource_response.json() + + # Verify resource server metadata + assert resource_data["resource"] == f"{TEST_BASE_URL}/mcp" + # authorization_servers points directly to the Keycloak realm + assert TEST_REALM_URL in [ + s.rstrip("/") for s in resource_data["authorization_servers"] + ] + + async def test_no_register_proxy_route(self): + """Test that KeycloakAuthProvider does not expose a /register proxy route. + + Keycloak 26.6.0+ handles DCR natively and correctly, so no proxy is needed. + MCP clients register directly with Keycloak's DCR endpoint. + """ + provider = KeycloakAuthProvider( + realm_url=TEST_REALM_URL, + base_url=TEST_BASE_URL, + required_scopes=TEST_REQUIRED_SCOPES, + ) + + mcp = FastMCP("test-server", auth=provider) + mcp_http_app = mcp.http_app() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=mcp_http_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 + + @pytest.mark.skip( + reason="Mock conflicts with ASGI transport - verified working in production" + ) + async def test_authorization_server_metadata_forwards_keycloak(self): + """Test that authorization server metadata is forwarded from Keycloak. + + Note: This test is skipped because mocking httpx.AsyncClient conflicts with the + ASGI transport used by the test client. The functionality has been verified to + work correctly in production (see user testing logs showing successful DCR proxy). + """ + with patch("httpx.get") as mock_get: + # Mock OIDC discovery + mock_discovery = Mock() + mock_discovery.json.return_value = { + "issuer": TEST_REALM_URL, + "authorization_endpoint": f"{TEST_REALM_URL}/protocol/openid-connect/auth", + "token_endpoint": f"{TEST_REALM_URL}/protocol/openid-connect/token", + "jwks_uri": f"{TEST_REALM_URL}/.well-known/jwks.json", + "registration_endpoint": f"{TEST_REALM_URL}/clients-registrations/openid-connect", + } + mock_discovery.raise_for_status.return_value = None + mock_get.return_value = mock_discovery + + provider = KeycloakAuthProvider( + realm_url=TEST_REALM_URL, + base_url=TEST_BASE_URL, + required_scopes=TEST_REQUIRED_SCOPES, + ) + + mcp = FastMCP("test-server", auth=provider) + mcp_http_app = mcp.http_app() + + # Mock the metadata forwarding request + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + mock_metadata_response = Mock() + mock_metadata_response.status_code = 200 + mock_metadata_response.json.return_value = { + "issuer": TEST_REALM_URL, + "authorization_endpoint": f"{TEST_REALM_URL}/protocol/openid-connect/auth", + "token_endpoint": f"{TEST_REALM_URL}/protocol/openid-connect/token", + "jwks_uri": f"{TEST_REALM_URL}/.well-known/jwks.json", + "registration_endpoint": f"{TEST_REALM_URL}/clients-registrations/openid-connect", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + } + mock_metadata_response.raise_for_status = Mock() + mock_client.get.return_value = mock_metadata_response + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=mcp_http_app), + base_url=TEST_BASE_URL, + ) as client: + # Test authorization server metadata forwarding + auth_server_response = await client.get( + "/.well-known/oauth-authorization-server" + ) + assert auth_server_response.status_code == 200 + auth_data = auth_server_response.json() + + # Verify metadata is forwarded from Keycloak but registration_endpoint is rewritten + assert ( + auth_data["authorization_endpoint"] + == f"{TEST_REALM_URL}/protocol/openid-connect/auth" + ) + assert ( + auth_data["registration_endpoint"] + == f"{TEST_BASE_URL}/register" + ) # Rewritten to our DCR proxy + assert auth_data["issuer"] == TEST_REALM_URL + assert ( + auth_data["jwks_uri"] + == f"{TEST_REALM_URL}/.well-known/jwks.json" + ) + + # Verify we called Keycloak's metadata endpoint + mock_client.get.assert_called_once_with( + f"{TEST_REALM_URL}/.well-known/oauth-authorization-server" + ) + + async def test_initialization_without_network_call(self): + """Test that provider initialization doesn't require network call to Keycloak. + + Since we use hard-coded Keycloak URL patterns, initialization succeeds + even if Keycloak is unavailable. Network errors only occur at runtime + when actually fetching metadata or registering clients. + """ + # Should succeed without any network calls + provider = KeycloakAuthProvider( + realm_url=TEST_REALM_URL, + base_url=TEST_BASE_URL, + ) + + # Verify provider is configured with hard-coded patterns + assert provider.realm_url == TEST_REALM_URL + assert str(provider.base_url) == TEST_BASE_URL + "/" + + @pytest.mark.skip( + reason="Mock conflicts with ASGI transport - error handling verified in code" + ) + async def test_metadata_forwarding_error_handling(self): + """Test error handling when metadata forwarding fails. + + Note: This test is skipped because mocking httpx.AsyncClient conflicts with the + ASGI transport. Error handling code is present and follows standard patterns. + """ + with patch("httpx.get") as mock_get: + mock_response = Mock() + mock_response.json.return_value = { + "issuer": TEST_REALM_URL, + "authorization_endpoint": f"{TEST_REALM_URL}/protocol/openid-connect/auth", + "token_endpoint": f"{TEST_REALM_URL}/protocol/openid-connect/token", + "jwks_uri": f"{TEST_REALM_URL}/.well-known/jwks.json", + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + provider = KeycloakAuthProvider( + realm_url=TEST_REALM_URL, + base_url=TEST_BASE_URL, + ) + + mcp = FastMCP("test-server", auth=provider) + mcp_http_app = mcp.http_app() + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + # Simulate Keycloak error + mock_client.get.side_effect = httpx.RequestError("Connection failed") + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=mcp_http_app), + base_url=TEST_BASE_URL, + ) as client: + response = await client.get( + "/.well-known/oauth-authorization-server" + ) + + # Should return 500 error with error details + assert response.status_code == 500 + data = response.json() + assert "error" in data + assert data["error"] == "server_error" + + +class TestKeycloakProviderEnvironmentConfiguration: + """Test configuration from environment variables in integration context.""" + + def test_provider_loads_all_settings_from_environment(self): + """Test that provider can be fully configured from environment.""" + env_vars = { + "FASTMCP_SERVER_AUTH_KEYCLOAK_REALM_URL": TEST_REALM_URL, + "FASTMCP_SERVER_AUTH_KEYCLOAK_BASE_URL": TEST_BASE_URL, + "FASTMCP_SERVER_AUTH_KEYCLOAK_REQUIRED_SCOPES": "openid,profile,email,custom:scope", + } + + with ( + patch.dict(os.environ, env_vars), + patch("httpx.get") as mock_get, + ): + mock_response = Mock() + mock_response.json.return_value = { + "issuer": TEST_REALM_URL, + "authorization_endpoint": f"{TEST_REALM_URL}/protocol/openid-connect/auth", + "token_endpoint": f"{TEST_REALM_URL}/protocol/openid-connect/token", + "jwks_uri": f"{TEST_REALM_URL}/.well-known/jwks.json", + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + # Explicitly read from environment and pass to provider + provider = KeycloakAuthProvider( + realm_url=os.environ["FASTMCP_SERVER_AUTH_KEYCLOAK_REALM_URL"], + base_url=os.environ["FASTMCP_SERVER_AUTH_KEYCLOAK_BASE_URL"], + required_scopes=os.environ[ + "FASTMCP_SERVER_AUTH_KEYCLOAK_REQUIRED_SCOPES" + ], + ) + + assert provider.realm_url == TEST_REALM_URL + assert str(provider.base_url) == TEST_BASE_URL + "/" + assert provider.token_verifier.required_scopes == [ + "openid", + "profile", + "email", + "custom:scope", + ] + + @pytest.mark.skip( + reason="Mock conflicts with ASGI transport - verified working in production" + ) + async def test_provider_works_in_production_like_environment(self): + """Test provider configuration that mimics production deployment. + + Note: This test is skipped because mocking httpx.AsyncClient conflicts with the + ASGI transport used by the test client. The functionality has been verified to + work correctly in production (see user testing logs showing successful DCR proxy). + """ + production_env = { + "FASTMCP_SERVER_AUTH_KEYCLOAK_REALM_URL": "https://auth.company.com/realms/production", + "FASTMCP_SERVER_AUTH_KEYCLOAK_BASE_URL": "https://api.company.com", + "FASTMCP_SERVER_AUTH_KEYCLOAK_REQUIRED_SCOPES": "openid,profile,email,api:read,api:write", + } + + with ( + patch.dict(os.environ, production_env), + patch("httpx.get") as mock_get, + ): + mock_response = Mock() + mock_response.json.return_value = { + "issuer": "https://auth.company.com/realms/production", + "authorization_endpoint": "https://auth.company.com/realms/production/protocol/openid-connect/auth", + "token_endpoint": "https://auth.company.com/realms/production/protocol/openid-connect/token", + "jwks_uri": "https://auth.company.com/realms/production/.well-known/jwks.json", + "registration_endpoint": "https://auth.company.com/realms/production/clients-registrations/openid-connect", + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + # Explicitly read from environment and pass to provider + provider = KeycloakAuthProvider( + realm_url=os.environ["FASTMCP_SERVER_AUTH_KEYCLOAK_REALM_URL"], + base_url=os.environ["FASTMCP_SERVER_AUTH_KEYCLOAK_BASE_URL"], + required_scopes=os.environ[ + "FASTMCP_SERVER_AUTH_KEYCLOAK_REQUIRED_SCOPES" + ], + ) + mcp = FastMCP("production-server", auth=provider) + mcp_http_app = mcp.http_app() + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + mock_metadata = Mock() + mock_metadata.status_code = 200 + mock_metadata.json.return_value = { + "issuer": "https://auth.company.com/realms/production", + "authorization_endpoint": "https://auth.company.com/realms/production/protocol/openid-connect/auth", + "token_endpoint": "https://auth.company.com/realms/production/protocol/openid-connect/token", + "jwks_uri": "https://auth.company.com/realms/production/.well-known/jwks.json", + "registration_endpoint": "https://auth.company.com/realms/production/clients-registrations/openid-connect", + } + mock_metadata.raise_for_status = Mock() + mock_client.get.return_value = mock_metadata + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=mcp_http_app), + base_url="https://api.company.com", + ) as client: + # Test discovery endpoints work + response = await client.get( + "/.well-known/oauth-authorization-server" + ) + assert response.status_code == 200 + data = response.json() + + # Minimal proxy: endpoints from Keycloak but registration_endpoint rewritten + assert ( + data["issuer"] == "https://auth.company.com/realms/production" + ) + assert ( + data["authorization_endpoint"] + == "https://auth.company.com/realms/production/protocol/openid-connect/auth" + ) + assert ( + data["registration_endpoint"] + == "https://api.company.com/register" + ) # Our DCR proxy diff --git a/tests/prompts/test_prompt.py b/tests/prompts/test_prompt.py index ec95126e5..ebfbde63c 100644 --- a/tests/prompts/test_prompt.py +++ b/tests/prompts/test_prompt.py @@ -277,7 +277,7 @@ class TestPromptTypeConversion: with pytest.raises(PromptError) as exc_info: await prompt.render(arguments={"numbers": "not valid json"}) - assert f"Error rendering prompt {prompt.name}" in str(exc_info.value) + assert f"Error rendering prompt {prompt.name!r}" in str(exc_info.value) async def test_json_parsing_fallback(self): """Test that JSON parsing falls back to direct validation when needed.""" @@ -430,6 +430,135 @@ class TestPromptArgumentDescriptions: not in arg.description ) + def test_docstring_populates_argument_descriptions(self): + """Google-style docstrings should populate PromptArgument descriptions.""" + + def greet(name: str, topic: str) -> str: + """Generate a greeting. + + Args: + name: The person's name. + topic: The topic to discuss. + """ + return f"Hello {name}, let's talk about {topic}" + + prompt = Prompt.from_function(greet) + + # Description is summary-only — Args section stripped + assert prompt.description == "Generate a greeting." + + assert prompt.arguments is not None + name_arg = next(arg for arg in prompt.arguments if arg.name == "name") + topic_arg = next(arg for arg in prompt.arguments if arg.name == "topic") + assert name_arg.description == "The person's name." + assert topic_arg.description == "The topic to discuss." + + def test_docstring_works_with_numpy_and_sphinx_styles(self): + def numpy_prompt(a: str) -> str: + """Do something. + + Parameters + ---------- + a + The first argument. + """ + return a + + def sphinx_prompt(a: str) -> str: + """Do something. + + :param a: The first argument. + """ + return a + + numpy = Prompt.from_function(numpy_prompt) + sphinx = Prompt.from_function(sphinx_prompt) + + for prompt in (numpy, sphinx): + assert prompt.description == "Do something." + assert prompt.arguments is not None + a_arg = next(arg for arg in prompt.arguments if arg.name == "a") + assert a_arg.description == "The first argument." + + def test_explicit_field_description_overrides_docstring(self): + """Field(description=...) takes precedence over docstring.""" + from typing import Annotated + + from pydantic import Field + + def greet( + name: Annotated[str, Field(description="From Field")], + ) -> str: + """Greet. + + Args: + name: From docstring (ignored). + """ + return f"Hello {name}" + + prompt = Prompt.from_function(greet) + assert prompt.arguments is not None + name_arg = next(arg for arg in prompt.arguments if arg.name == "name") + # Field description wins over the docstring's "From docstring (ignored)". + # (The existing schema-hint suffix for Annotated params is appended + # afterwards and is unrelated to precedence.) + assert name_arg.description is not None + assert name_arg.description.startswith("From Field") + assert "From docstring" not in name_arg.description + + def test_explicit_description_keeps_docstring_arg_descriptions(self): + """Overriding the prompt description does not drop docstring-sourced + argument descriptions — they come from separate parsing paths.""" + + def greet(name: str) -> str: + """Greet. + + Args: + name: The person's name. + """ + return f"Hello {name}" + + prompt = Prompt.from_function(greet, description="Custom description") + assert prompt.description == "Custom description" + assert prompt.arguments is not None + name_arg = next(arg for arg in prompt.arguments if arg.name == "name") + assert name_arg.description == "The person's name." + + def test_docstring_without_args_section(self): + """Summary-only docstrings produce a description with no arg descriptions.""" + + def greet(name: str) -> str: + """Just a summary.""" + return f"Hello {name}" + + prompt = Prompt.from_function(greet) + assert prompt.description == "Just a summary." + assert prompt.arguments is not None + name_arg = next(arg for arg in prompt.arguments if arg.name == "name") + assert name_arg.description is None + + def test_callable_class_sources_description_from_class(self): + """Class docstring drives the prompt description, while __call__'s + Args section drives per-argument descriptions (since the arguments + are __call__'s, not the class's).""" + + class MyPrompt: + """Class-level description.""" + + def __call__(self, name: str) -> str: + """Internal call doc. + + Args: + name: From call. + """ + return f"Hello {name}" + + prompt = Prompt.from_function(MyPrompt()) + assert prompt.description == "Class-level description." + assert prompt.arguments is not None + name_arg = next(arg for arg in prompt.arguments if arg.name == "name") + assert name_arg.description == "From call." + def test_prompt_meta_parameter(self): """Test that meta parameter is properly handled.""" diff --git a/tests/resources/test_function_resources.py b/tests/resources/test_function_resources.py index c3490abe3..67bedc044 100644 --- a/tests/resources/test_function_resources.py +++ b/tests/resources/test_function_resources.py @@ -66,8 +66,8 @@ class TestFunctionResource: result = await resource._read() assert result.contents[0].content == b"Hello, world!" - async def test_dict_return_raises_type_error(self): - """Returning dict from read() raises TypeError - use ResourceResult.""" + async def test_dict_return_auto_serializes(self): + """Returning dict from read() auto-serializes to JSON.""" def get_data() -> dict: return {"key": "value"} @@ -81,9 +81,10 @@ class TestFunctionResource: result = await resource.read() assert result == {"key": "value"} - # _read() raises TypeError - must return str, bytes, or ResourceResult - with pytest.raises(TypeError, match="must be str, bytes, or list"): - await resource._read() + # _read() auto-serializes dict to JSON text + resource_result = await resource._read() + assert len(resource_result.contents) == 1 + assert '"key"' in str(resource_result.contents[0].content) async def test_error_handling(self): """Test error handling in FunctionResource.""" diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 3b88545c5..d15c8da63 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -7,7 +7,11 @@ from pydantic import BaseModel from fastmcp import Context, FastMCP from fastmcp.resources import ResourceTemplate from fastmcp.resources.function_resource import FunctionResource -from fastmcp.resources.template import build_regex, match_uri_template +from fastmcp.resources.template import ( + build_regex, + expand_uri_template, + match_uri_template, +) class TestResourceTemplate: @@ -806,3 +810,86 @@ class TestMalformedURITemplates: assert match is not None assert match.group("name") == "foo" assert match.group("id") == "123" + + +class TestExpandUriTemplate: + """Test expand_uri_template — the inverse of match_uri_template.""" + + @pytest.mark.parametrize( + "template, params, expected", + [ + ("test://{x}", {"x": "foo"}, "test://foo"), + ("test://{x}/{y}", {"x": "foo", "y": "bar"}, "test://foo/bar"), + ("test://a/{x}/b", {"x": "mid"}, "test://a/mid/b"), + ], + ) + def test_expand_simple_params( + self, template: str, params: dict[str, str], expected: str + ): + assert expand_uri_template(template, params) == expected + + @pytest.mark.parametrize( + "template, params, expected", + [ + ("test://{path*}", {"path": "a/b/c"}, "test://a/b/c"), + ("test://{path*}", {"path": "single"}, "test://single"), + ("test://pre/{rest*}", {"rest": "x/y"}, "test://pre/x/y"), + ( + "test://{a*}/mid/{b*}", + {"a": "x/y", "b": "p/q"}, + "test://x/y/mid/p/q", + ), + ("test://{x}/{path*}", {"x": "foo", "path": "a/b"}, "test://foo/a/b"), + ], + ) + def test_expand_wildcard_params( + self, template: str, params: dict[str, str], expected: str + ): + assert expand_uri_template(template, params) == expected + + def test_expand_query_params(self): + result = expand_uri_template( + "test://data{?format,verbose}", + {"format": "json", "verbose": "true"}, + ) + assert result in ( + "test://data?format=json&verbose=true", + "test://data?verbose=true&format=json", + ) + + def test_expand_query_params_partial(self): + result = expand_uri_template( + "test://data{?format,verbose}", + {"format": "json"}, + ) + assert result == "test://data?format=json" + + def test_expand_query_params_none(self): + result = expand_uri_template("test://data{?format,verbose}", {}) + assert result == "test://data" + + def test_expand_ignores_extra_params(self): + result = expand_uri_template("test://{x}", {"x": "foo", "unused": "bar"}) + assert result == "test://foo" + + +class TestMatchExpandRoundTrip: + """match_uri_template and expand_uri_template must agree on the template grammar.""" + + @pytest.mark.parametrize( + "template, uri", + [ + ("test://{x}", "test://foo"), + ("test://{x}/{y}", "test://foo/bar"), + ("test://a/{x}/b", "test://a/mid/b"), + ("test://{path*}", "test://a/b/c"), + ("test://{path*}", "test://single"), + ("test://pre/{rest*}", "test://pre/x/y/z"), + ("test://{x}/{path*}", "test://foo/a/b/c"), + ], + ) + def test_expand_then_match_is_identity(self, template: str, uri: str): + """Extracting params from a URI and expanding them back reproduces the URI.""" + params = match_uri_template(uri, template) + assert params is not None + assert expand_uri_template(template, params) == uri diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py index 0507e9188..7d7b70b64 100644 --- a/tests/resources/test_resources.py +++ b/tests/resources/test_resources.py @@ -208,10 +208,13 @@ class TestResourceResult: assert result.contents[0].content == b"\xff\xfe" assert result.contents[0].mime_type == "application/octet-stream" - def test_init_from_dict_raises_type_error(self): - """Dict input raises TypeError - must use ResourceContent for serialization.""" - with pytest.raises(TypeError, match="must be str, bytes, or list"): - ResourceResult({"page": 1, "total": 100}) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + def test_init_from_dict_auto_serializes(self): + """Dict input is auto-serialized to JSON text.""" + result = ResourceResult({"page": 1, "total": 100}) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + assert len(result.contents) == 1 + text = str(result.contents[0].content) + assert '"page"' in text + assert '"total"' in text def test_init_from_single_resource_content_raises_type_error(self): """Single ResourceContent raises TypeError - must be in a list.""" @@ -354,3 +357,16 @@ class TestResourceMetaPropagation: result = await client.read_resource_mcp("test://both-meta") 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): + """JSON-native returns should propagate component-level meta to content.""" + mcp = FastMCP() + + @mcp.resource("test://json-meta", meta={"csp": "default-src 'none'"}) + def json_resource() -> dict[str, str]: + return {"hello": "world"} + + async with Client(mcp) as client: + result = await client.read_resource_mcp("test://json-meta") + assert len(result.contents) == 1 + assert result.contents[0].meta == {"csp": "default-src 'none'"} diff --git a/tests/server/auth/oauth_proxy/test_client_registration.py b/tests/server/auth/oauth_proxy/test_client_registration.py index b865b4336..49e2eceac 100644 --- a/tests/server/auth/oauth_proxy/test_client_registration.py +++ b/tests/server/auth/oauth_proxy/test_client_registration.py @@ -41,3 +41,29 @@ class TestOAuthProxyClientRegistration: """Test that unregistered clients return None.""" client = await oauth_proxy.get_client("unknown-client") assert client is None + + async def test_enforcing_allowed_redirect_uris(self, oauth_proxy): + """Test enforcing allowed redirect uris configuration.""" + + oauth_proxy._allowed_client_redirect_uris = ["http://localhost:12345/callback"] + + client_info = OAuthClientInformationFull( + client_id="original-client", + client_secret="original-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + await oauth_proxy.register_client(client_info) + retrieved = await oauth_proxy.get_client("original-client") + assert retrieved.allowed_redirect_uri_patterns == [ + "http://localhost:12345/callback" + ] + + oauth_proxy._allowed_client_redirect_uris = [ + "http://localhost:12345/updated_callback" + ] + + retrieved = await oauth_proxy.get_client("original-client") + assert retrieved.allowed_redirect_uri_patterns == [ + "http://localhost:12345/updated_callback" + ] diff --git a/tests/server/auth/oauth_proxy/test_config.py b/tests/server/auth/oauth_proxy/test_config.py index 0b88a0ae5..c5e8e3078 100644 --- a/tests/server/auth/oauth_proxy/test_config.py +++ b/tests/server/auth/oauth_proxy/test_config.py @@ -410,6 +410,44 @@ class TestResourceURLValidation: assert proxy.jwt_issuer.audience == "https://proxy.example.com/" + def test_set_mcp_path_uses_resource_base_url_for_audience(self, jwt_verifier): + """Test that resource_base_url controls the protected resource audience.""" + proxy = OAuthProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com/oauth", + resource_base_url="https://api.example.com", + jwt_signing_key="test-secret", + client_storage=MemoryStore(), + ) + + proxy.set_mcp_path("/mcp") + + assert proxy.jwt_issuer.issuer == "https://proxy.example.com/oauth" + assert proxy.jwt_issuer.audience == "https://api.example.com/mcp" + + def test_set_mcp_path_none_uses_resource_base_url_for_audience(self, jwt_verifier): + """Test that resource_base_url is used as audience when mcp_path is None.""" + proxy = OAuthProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com/oauth", + resource_base_url="https://api.example.com", + jwt_signing_key="test-secret", + client_storage=MemoryStore(), + ) + + proxy.set_mcp_path(None) + + assert proxy.jwt_issuer.issuer == "https://proxy.example.com/oauth" + assert proxy.jwt_issuer.audience == "https://api.example.com/" + def test_jwt_issuer_property_raises_if_not_initialized(self, jwt_verifier): """Test that jwt_issuer property raises if set_mcp_path not called.""" proxy = OAuthProxy( diff --git a/tests/server/auth/oauth_proxy/test_oauth_proxy.py b/tests/server/auth/oauth_proxy/test_oauth_proxy.py index 087a99a38..549d78dad 100644 --- a/tests/server/auth/oauth_proxy/test_oauth_proxy.py +++ b/tests/server/auth/oauth_proxy/test_oauth_proxy.py @@ -63,6 +63,39 @@ class TestOAuthProxyInitialization: assert proxy.client_registration_options is not None assert proxy.client_registration_options.valid_scopes == ["custom", "scopes"] + def test_default_scope_str_prefers_valid_scopes(self, jwt_verifier): + """When valid_scopes is provided, _default_scope_str should use it + instead of required_scopes. This ensures CIMD clients (which bypass + RegistrationHandler) get registered with the full set of valid scopes.""" + jwt_verifier.required_scopes = ["openid"] + 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", + valid_scopes=["openid", "email", "calendar"], + jwt_signing_key="test-secret", + client_storage=MemoryStore(), + ) + assert proxy._default_scope_str == "openid email calendar" + + def test_default_scope_str_falls_back_to_required_scopes(self, jwt_verifier): + """Without valid_scopes, _default_scope_str falls back to required_scopes.""" + jwt_verifier.required_scopes = ["openid"] + 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(), + ) + assert proxy._default_scope_str == "openid" + def test_redirect_path_normalization(self, jwt_verifier): """Test that redirect_path is normalized with leading slash.""" proxy = OAuthProxy( diff --git a/tests/server/auth/oauth_proxy/test_tokens.py b/tests/server/auth/oauth_proxy/test_tokens.py index 739abf43e..9e820589e 100644 --- a/tests/server/auth/oauth_proxy/test_tokens.py +++ b/tests/server/auth/oauth_proxy/test_tokens.py @@ -7,11 +7,16 @@ import pytest from key_value.aio.stores.memory import MemoryStore from mcp.server.auth.handlers.token import TokenErrorResponse from mcp.server.auth.handlers.token import TokenHandler as SDKTokenHandler -from mcp.server.auth.provider import AccessToken, AuthorizationCode +from mcp.server.auth.provider import AuthorizationCode from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl -from fastmcp.server.auth.auth import RefreshToken, TokenHandler, TokenVerifier +from fastmcp.server.auth.auth import ( + AccessToken, + RefreshToken, + TokenHandler, + TokenVerifier, +) from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.oauth_proxy.models import ( DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS, @@ -581,14 +586,22 @@ class TestTransparentUpstreamRefresh: verifier = Mock(spec=TokenVerifier) verifier.required_scopes = ["read"] + # Cache tokens to test mutation of returned objects + cache: dict[str, AccessToken] = {} + async def verify(token: str) -> AccessToken | None: - if token.startswith("refreshed-"): - return AccessToken( + if token in cache: + return cache[token] + + if token.startswith("refreshed-") or token.startswith("valid-"): + t = AccessToken( token=token, client_id="test-client", scopes=["read"], expires_at=int(time.time() + 3600), ) + cache[token] = t + return t return None verifier.verify_token = AsyncMock(side_effect=verify) @@ -660,6 +673,44 @@ class TestTransparentUpstreamRefresh: ) return fastmcp_jwt + async def _setup_session_with_claims(self, proxy, *, upstream_claims=None): + """Set up a proxy JWT pointing at a valid upstream token, with optional upstream_claims.""" + upstream_token_id = "upstream-tok-id" + access_jti = "test-claims-jti" + + upstream_token_set = UpstreamTokenSet( + upstream_token_id=upstream_token_id, + access_token="valid-upstream-access", + refresh_token=None, + refresh_token_expires_at=None, + expires_at=time.time() + 3600, + token_type="Bearer", + scope="read", + client_id="test-client", + created_at=time.time(), + ) + await proxy._upstream_token_store.put( + key=upstream_token_id, + value=upstream_token_set, + ttl=3600, + ) + await proxy._jti_mapping_store.put( + key=access_jti, + value=JTIMapping( + jti=access_jti, + upstream_token_id=upstream_token_id, + created_at=time.time(), + ), + ttl=3600, + ) + return proxy.jwt_issuer.issue_access_token( + client_id="test-client", + scopes=["read"], + jti=access_jti, + expires_in=3600, + upstream_claims=upstream_claims, + ) + async def test_transparent_refresh_on_expired_upstream(self, proxy): """load_access_token refreshes upstream token when validation fails.""" fastmcp_jwt = await self._setup_expired_session(proxy) @@ -855,3 +906,26 @@ class TestTransparentUpstreamRefresh: assert result is not None assert result.token == "refreshed-upstream-access" + + async def test_upstream_claims_propagated(self, proxy): + jwt = await self._setup_session_with_claims( + proxy, upstream_claims={"sub": "user-123"} + ) + result = await proxy.load_access_token(jwt) + assert result is not None + assert result.claims["upstream_claims"] == {"sub": "user-123"} + + async def test_upstream_claims_not_mutated_on_cached_token( + self, proxy, mock_verifier + ): + jwt = await self._setup_session_with_claims( + proxy, upstream_claims={"sub": "user-123"} + ) + result = await proxy.load_access_token(jwt) + assert result is not None + assert result.claims["upstream_claims"] == {"sub": "user-123"} + # Original verifier result must not be mutated + for call in list(mock_verifier.verify_token.call_args_list): + returned = await mock_verifier.verify_token(call.args[0]) + if returned: + assert "upstream_claims" not in returned.claims diff --git a/tests/server/auth/providers/test_aws.py b/tests/server/auth/providers/test_aws.py index 8bbde78f7..4f5232b53 100644 --- a/tests/server/auth/providers/test_aws.py +++ b/tests/server/auth/providers/test_aws.py @@ -103,8 +103,8 @@ class TestAWSCognitoProvider: assert provider._upstream_token_endpoint is not None assert "amazoncognito.com" in provider._upstream_authorization_endpoint - def test_token_verifier_defaults_audience_to_client_id(self): - """Test Cognito token verifier enforces the configured client ID by default.""" + def test_token_verifier_checks_client_id_not_aud(self): + """Cognito verifier should check client_id claim, not aud.""" with mock_cognito_oidc_discovery(): provider = AWSCognitoProvider( user_pool_id="us-east-1_XXXXXXXXX", @@ -116,10 +116,11 @@ class TestAWSCognitoProvider: verifier = provider.get_token_verifier() - assert verifier.audience == "test_client" + assert verifier._expected_client_id == "test_client" + assert verifier.audience is None def test_token_verifier_supports_audience_override(self): - """Test Cognito token verifier still allows explicit audience overrides.""" + """Audience param maps to client_id validation in Cognito verifier.""" with mock_cognito_oidc_discovery(): provider = AWSCognitoProvider( user_pool_id="us-east-1_XXXXXXXXX", @@ -131,7 +132,8 @@ class TestAWSCognitoProvider: verifier = provider.get_token_verifier(audience="custom-audience") - assert verifier.audience == "custom-audience" + assert verifier._expected_client_id == "custom-audience" + assert verifier.audience is None # Token verification functionality is now tested as part of the OIDC provider integration diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index d83bc8860..54b119ce9 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -9,7 +9,7 @@ from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl from fastmcp.server.auth.providers.azure import AzureProvider -from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair @pytest.fixture @@ -190,8 +190,6 @@ class TestAzureProvider: self, memory_storage: MemoryStore ): """When audience is provided, JWTVerifier is configured with JWKS and issuer.""" - from fastmcp.server.auth.providers.jwt import JWTVerifier - provider = AzureProvider( client_id="test_client", client_secret="test_secret", @@ -211,11 +209,100 @@ class TestAzureProvider: "https://login.microsoftonline.com/my-tenant/discovery/v2.0/keys" ) assert verifier.issuer == "https://login.microsoftonline.com/my-tenant/v2.0" - assert verifier.audience == "test_client" + assert verifier.audience == ["test_client", "api://my-api"] # Scopes are stored unprefixed for token validation # (Azure returns unprefixed scopes like ".default" in JWT tokens) assert verifier.required_scopes == [".default"] + async def test_token_accepted_with_client_id_audience( + self, memory_storage: MemoryStore + ): + """Azure AD v2 tokens use the bare client_id as aud — must be accepted.""" + key_pair = RSAKeyPair.generate() + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="my-tenant", + base_url="https://myserver.com", + identifier_uri="api://my-api", + required_scopes=["read"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert isinstance(provider._token_validator, JWTVerifier) + verifier = provider._token_validator + verifier.public_key = key_pair.public_key + verifier.jwks_uri = None + + token = key_pair.create_token( + subject="test-user", + issuer="https://login.microsoftonline.com/my-tenant/v2.0", + audience="test_client", + additional_claims={"scp": "read"}, + ) + result = await verifier.load_access_token(token) + assert result is not None + + async def test_token_accepted_with_identifier_uri_audience( + self, memory_storage: MemoryStore + ): + """Azure AD v1 tokens use the identifier_uri as aud — must be accepted.""" + key_pair = RSAKeyPair.generate() + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="my-tenant", + base_url="https://myserver.com", + identifier_uri="api://my-api", + required_scopes=["read"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert isinstance(provider._token_validator, JWTVerifier) + verifier = provider._token_validator + verifier.public_key = key_pair.public_key + verifier.jwks_uri = None + + token = key_pair.create_token( + subject="test-user", + issuer="https://login.microsoftonline.com/my-tenant/v2.0", + audience="api://my-api", + additional_claims={"scp": "read"}, + ) + result = await verifier.load_access_token(token) + assert result is not None + + async def test_token_rejected_with_wrong_audience( + self, memory_storage: MemoryStore + ): + """Tokens for a different application must be rejected.""" + key_pair = RSAKeyPair.generate() + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="my-tenant", + base_url="https://myserver.com", + required_scopes=["read"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert isinstance(provider._token_validator, JWTVerifier) + verifier = provider._token_validator + verifier.public_key = key_pair.public_key + verifier.jwks_uri = None + + token = key_pair.create_token( + subject="test-user", + issuer="https://login.microsoftonline.com/my-tenant/v2.0", + audience="wrong-app-id", + additional_claims={"scp": "read"}, + ) + result = await verifier.load_access_token(token) + assert result is None + async def test_authorize_filters_resource_and_stores_unprefixed_scopes( self, memory_storage: MemoryStore ): diff --git a/tests/server/auth/providers/test_azure_scopes.py b/tests/server/auth/providers/test_azure_scopes.py index edbfa3165..8ab35431d 100644 --- a/tests/server/auth/providers/test_azure_scopes.py +++ b/tests/server/auth/providers/test_azure_scopes.py @@ -413,7 +413,7 @@ class TestAzureJWTVerifier: == "https://login.microsoftonline.com/my-tenant-id/discovery/v2.0/keys" ) assert verifier.issuer == "https://login.microsoftonline.com/my-tenant-id/v2.0" - assert verifier.audience == "my-client-id" + assert verifier.audience == ["my-client-id", "api://my-client-id"] assert verifier.algorithm == "RS256" assert verifier.required_scopes == ["access_as_user"] @@ -428,6 +428,27 @@ class TestAzureJWTVerifier: verifier.public_key = key_pair.public_key verifier.jwks_uri = None + token = key_pair.create_token( + subject="test-user", + issuer="https://login.microsoftonline.com/my-tenant-id/v2.0", + audience="api://my-client-id", + additional_claims={"scp": "access_as_user"}, + ) + result = await verifier.load_access_token(token) + assert result is not None + assert "access_as_user" in result.scopes + + async def test_validates_token_with_client_id_audience(self): + """Azure AD v2 tokens use the bare client_id GUID as audience.""" + key_pair = RSAKeyPair.generate() + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="my-tenant-id", + required_scopes=["access_as_user"], + ) + verifier.public_key = key_pair.public_key + verifier.jwks_uri = None + token = key_pair.create_token( subject="test-user", issuer="https://login.microsoftonline.com/my-tenant-id/v2.0", @@ -438,6 +459,48 @@ 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): + """Custom identifier_uri (e.g. Bicep deployments) accepted as audience.""" + key_pair = RSAKeyPair.generate() + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="my-tenant-id", + required_scopes=["read"], + identifier_uri="api://my-app-name", + ) + verifier.public_key = key_pair.public_key + verifier.jwks_uri = None + + token = key_pair.create_token( + subject="test-user", + issuer="https://login.microsoftonline.com/my-tenant-id/v2.0", + audience="api://my-app-name", + additional_claims={"scp": "read"}, + ) + result = await verifier.load_access_token(token) + assert result is not None + assert "read" in result.scopes + + async def test_rejects_token_with_wrong_audience(self): + """Tokens for a different application must be rejected.""" + key_pair = RSAKeyPair.generate() + verifier = AzureJWTVerifier( + client_id="my-client-id", + tenant_id="my-tenant-id", + required_scopes=["read"], + ) + verifier.public_key = key_pair.public_key + verifier.jwks_uri = None + + token = key_pair.create_token( + subject="test-user", + issuer="https://login.microsoftonline.com/my-tenant-id/v2.0", + audience="some-other-app-id", + additional_claims={"scp": "read"}, + ) + result = await verifier.load_access_token(token) + assert result is None + def test_scopes_supported_returns_prefixed_form(self): verifier = AzureJWTVerifier( client_id="my-client-id", diff --git a/tests/server/auth/providers/test_clerk.py b/tests/server/auth/providers/test_clerk.py new file mode 100644 index 000000000..323b36572 --- /dev/null +++ b/tests/server/auth/providers/test_clerk.py @@ -0,0 +1,572 @@ +"""Tests for Clerk OAuth provider.""" + +import re + +import httpx +import pytest +from key_value.aio.stores.memory import MemoryStore +from pytest_httpx import HTTPXMock + +from fastmcp.server.auth.providers.clerk import ClerkProvider, ClerkTokenVerifier + +CLERK_DOMAIN = "test-instance.clerk.accounts.dev" + +_USERINFO_RE = re.compile(rf"https://{re.escape(CLERK_DOMAIN)}/oauth/userinfo") +_INTROSPECTION_RE = re.compile(rf"https://{re.escape(CLERK_DOMAIN)}/oauth/token_info") + + +@pytest.fixture +def memory_storage() -> MemoryStore: + """Provide a MemoryStore for tests to avoid SQLite initialization on Windows.""" + return MemoryStore() + + +class TestClerkProvider: + """Test Clerk OAuth provider functionality.""" + + def test_init_with_explicit_params(self, memory_storage: MemoryStore): + """Test ClerkProvider initialization with explicit parameters.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + required_scopes=["openid", "email", "profile"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert provider._upstream_client_id == "clerk-client-id" + assert provider._upstream_client_secret is not None + assert ( + provider._upstream_client_secret.get_secret_value() == "clerk-client-secret" + ) + assert str(provider.base_url) == "https://myserver.com/" + + def test_init_defaults(self, memory_storage: MemoryStore): + """Test that default values are applied correctly.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert provider._redirect_path == "/auth/callback" + + def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore): + """Test that OAuth endpoints are derived from the domain.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert ( + provider._upstream_authorization_endpoint + == f"https://{CLERK_DOMAIN}/oauth/authorize" + ) + assert ( + provider._upstream_token_endpoint == f"https://{CLERK_DOMAIN}/oauth/token" + ) + assert provider._upstream_revocation_endpoint is None + + def test_domain_trailing_slash_stripped(self, memory_storage: MemoryStore): + """Test that trailing slashes are stripped from the domain.""" + provider = ClerkProvider( + domain=f"{CLERK_DOMAIN}/", + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert ( + provider._upstream_authorization_endpoint + == f"https://{CLERK_DOMAIN}/oauth/authorize" + ) + + def test_default_scopes(self, memory_storage: MemoryStore): + """Test that default required scopes are openid, email, profile.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert provider is not None + + def test_custom_scopes(self, memory_storage: MemoryStore): + """Test that custom scopes are accepted.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + required_scopes=["openid", "email", "profile", "public_metadata"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert provider is not None + + def test_no_extra_authorize_params_by_default(self, memory_storage: MemoryStore): + """Test that no extra authorize params are set by default.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert provider._extra_authorize_params in (None, {}) + + def test_extra_authorize_params_passed_through(self, memory_storage: MemoryStore): + """Test that extra authorize params are forwarded.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + extra_authorize_params={"prompt": "login"}, + client_storage=memory_storage, + ) + + assert provider._extra_authorize_params == {"prompt": "login"} + + def test_valid_scopes_passed_through(self, memory_storage: MemoryStore): + """Test that valid_scopes is passed to OAuthProxy.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + required_scopes=["openid"], + valid_scopes=["openid", "email", "profile", "public_metadata"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + reg_options = provider.client_registration_options + assert reg_options is not None + assert reg_options.valid_scopes is not None + assert set(reg_options.valid_scopes) == { + "openid", + "email", + "profile", + "public_metadata", + } + + def test_issuer_url_defaults_to_base_url(self, memory_storage: MemoryStore): + """Test that issuer_url defaults to base_url when not provided.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert str(provider.issuer_url) == "https://myserver.com/" + + def test_custom_issuer_url(self, memory_storage: MemoryStore): + """Test that a custom issuer_url is used when provided.""" + provider = ClerkProvider( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + base_url="https://myserver.com/mcp", + issuer_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert str(provider.issuer_url) == "https://myserver.com/" + + +class TestClerkTokenVerifier: + """Test ClerkTokenVerifier.verify_token() using introspection + userinfo.""" + + async def test_valid_token_basic(self, httpx_mock: HTTPXMock): + """A valid token returns an AccessToken with user claims from userinfo.""" + httpx_mock.add_response( + url=_USERINFO_RE, + json={ + "sub": "user_abc123", + "email": "user@example.com", + "email_verified": True, + "name": "Test User", + "picture": "https://img.clerk.com/photo.jpg", + "given_name": "Test", + "family_name": "User", + "preferred_username": "testuser", + "iss": f"https://{CLERK_DOMAIN}", + }, + ) + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={ + "active": True, + "scope": "openid email profile", + "aud": "clerk-client-id", + "exp": 9999999999, + }, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + ) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.client_id == "clerk-client-id" + assert result.scopes == ["openid", "email", "profile"] + assert result.expires_at == 9999999999 + assert result.claims["sub"] == "user_abc123" + assert result.claims["email"] == "user@example.com" + assert result.claims["name"] == "Test User" + assert result.claims["picture"] == "https://img.clerk.com/photo.jpg" + assert result.claims["given_name"] == "Test" + assert result.claims["family_name"] == "User" + assert result.claims["preferred_username"] == "testuser" + assert result.claims["aud"] == "clerk-client-id" + + async def test_invalid_token_returns_none(self, httpx_mock: HTTPXMock): + """Token marked inactive by introspection is rejected.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": False}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("expired-token") + + assert result is None + + async def test_missing_sub_returns_none(self, httpx_mock: HTTPXMock): + """Token with no 'sub' in introspection or userinfo is rejected.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True}, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={"email": "user@example.com"}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("token-without-sub") + + assert result is None + + async def test_introspection_inactive_token_returns_none( + self, httpx_mock: HTTPXMock + ): + """Token marked inactive by introspection is rejected before userinfo.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": False}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + ) + result = await verifier.verify_token("inactive-token") + + assert result is None + + async def test_introspection_missing_active_field_returns_none( + self, httpx_mock: HTTPXMock + ): + """RFC 7662 requires the 'active' field; a missing field is malformed and rejected.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"scope": "openid email profile", "aud": "clerk-client-id"}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + ) + result = await verifier.verify_token("token-malformed-response") + + assert result is None + + async def test_introspection_failure_rejects_when_scopes_required( + self, httpx_mock: HTTPXMock + ): + """When introspection fails (non-200), token is rejected regardless of scopes.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + status_code=500, + json={"error": "internal_server_error"}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + required_scopes=["openid", "email"], + ) + result = await verifier.verify_token("valid-token") + + assert result is None + + async def test_empty_scopes_rejects_when_required(self, httpx_mock: HTTPXMock): + """When introspection returns no scopes and required_scopes are set, token is rejected.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": ""}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + required_scopes=["openid", "email", "profile"], + ) + result = await verifier.verify_token("valid-token") + + assert result is None + + async def test_required_scopes_not_satisfied_returns_none( + self, httpx_mock: HTTPXMock + ): + """Token without required scopes is rejected before userinfo.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid"}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + required_scopes=["openid", "email", "profile"], + ) + result = await verifier.verify_token("token-missing-scopes") + + assert result is None + + async def test_uses_bearer_header_for_userinfo(self, httpx_mock: HTTPXMock): + """verify_token sends the token as a Bearer header to userinfo.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid", "sub": "user_abc123"}, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "user_abc123"}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + await verifier.verify_token("my-access-token") + + requests = httpx_mock.get_requests() + userinfo_req = requests[1] + assert userinfo_req.headers["Authorization"] == "Bearer my-access-token" + + async def test_introspection_sends_client_credentials(self, httpx_mock: HTTPXMock): + """Introspection request sends credentials via HTTP Basic Auth when both are set.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid", "aud": "clerk-client-id"}, + ) + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "user_abc123"}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + client_id="clerk-client-id", + client_secret="clerk-client-secret", + ) + await verifier.verify_token("my-access-token") + + requests = httpx_mock.get_requests() + introspect_req = requests[0] + body = introspect_req.content.decode() + assert "token=my-access-token" in body + assert introspect_req.headers.get("Authorization", "").startswith("Basic ") + + async def test_expires_at_from_introspection(self, httpx_mock: HTTPXMock): + """expires_at is set from the 'exp' claim in the introspection response.""" + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "user_abc123"}, + ) + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid", "exp": 1700000000}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.expires_at == 1700000000 + + async def test_client_id_falls_back_to_sub(self, httpx_mock: HTTPXMock): + """When introspection has no aud/client_id, client_id falls back to sub.""" + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "user_abc123"}, + ) + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid"}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.client_id == "user_abc123" + + async def test_aud_from_introspection_client_id_field(self, httpx_mock: HTTPXMock): + """When introspection returns client_id but not aud, client_id is used.""" + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "user_abc123"}, + ) + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid", "client_id": "my-app-id"}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.client_id == "my-app-id" + assert result.claims["aud"] == "my-app-id" + + async def test_no_required_scopes_accepts_any(self, httpx_mock: HTTPXMock): + """When no required_scopes are set, any valid token is accepted.""" + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "user_abc123"}, + ) + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid custom_scope"}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.scopes == ["openid", "custom_scope"] + + async def test_clerk_user_data_in_claims(self, httpx_mock: HTTPXMock): + """The full userinfo response is stored in clerk_user_data claim.""" + user_data = { + "sub": "user_abc123", + "email": "user@example.com", + "name": "Test User", + } + httpx_mock.add_response( + url=_USERINFO_RE, + json=user_data, + ) + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.claims["clerk_user_data"] == user_data + + async def test_network_error_returns_none(self, httpx_mock: HTTPXMock): + """Network errors during introspection return None instead of raising.""" + httpx_mock.add_exception( + httpx.ConnectError("Connection refused"), + url=_INTROSPECTION_RE, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is None + + async def test_introspection_failure_rejects_without_required_scopes( + self, httpx_mock: HTTPXMock + ): + """Introspection failure (non-200) rejects the token even without required_scopes.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + status_code=500, + json={"error": "internal_server_error"}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is None + + async def test_audience_mismatch_returns_none(self, httpx_mock: HTTPXMock): + """Token with wrong audience is rejected before userinfo is called.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid", "aud": "wrong-client-id"}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + client_id="my-client-id", + client_secret="my-client-secret", + ) + result = await verifier.verify_token("valid-token") + + assert result is None + + async def test_audience_missing_returns_none_when_client_id_set( + self, httpx_mock: HTTPXMock + ): + """Token without audience is rejected before userinfo is called.""" + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid"}, + ) + + verifier = ClerkTokenVerifier( + domain=CLERK_DOMAIN, + client_id="my-client-id", + client_secret="my-client-secret", + ) + result = await verifier.verify_token("valid-token") + + assert result is None + + async def test_audience_not_checked_without_client_id(self, httpx_mock: HTTPXMock): + """Without client_id configured, any audience is accepted.""" + httpx_mock.add_response( + url=_USERINFO_RE, + json={"sub": "user_abc123"}, + ) + httpx_mock.add_response( + url=_INTROSPECTION_RE, + json={"active": True, "scope": "openid", "aud": "some-other-id"}, + ) + + verifier = ClerkTokenVerifier(domain=CLERK_DOMAIN) + result = await verifier.verify_token("valid-token") + + assert result is not None + assert result.claims["aud"] == "some-other-id" diff --git a/tests/server/auth/providers/test_github.py b/tests/server/auth/providers/test_github.py index 11683f8b6..a0cc4b9cd 100644 --- a/tests/server/auth/providers/test_github.py +++ b/tests/server/auth/providers/test_github.py @@ -57,6 +57,23 @@ class TestGitHubProvider: # The required_scopes should be passed to the token verifier assert provider._token_validator.required_scopes == ["user"] + def test_init_with_resource_base_url(self, memory_storage: MemoryStore): + """Test that resource_base_url overrides the advertised protected resource.""" + provider = GitHubProvider( + client_id="test_client", + client_secret="test_secret", + base_url="https://auth.example.com/proxy", + resource_base_url="https://api.example.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + provider.set_mcp_path("/mcp") + + assert str(provider.base_url) == "https://auth.example.com/proxy" + assert str(provider.resource_base_url) == "https://api.example.com/" + assert provider.jwt_issuer.audience == "https://api.example.com/mcp" + class TestGitHubTokenVerifier: """Test GitHubTokenVerifier.""" diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py index 4af76c52a..229f04ad3 100644 --- a/tests/server/auth/providers/test_google.py +++ b/tests/server/auth/providers/test_google.py @@ -249,7 +249,7 @@ class TestGoogleTokenVerifier: USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" async def test_valid_token_openid_only(self, httpx_mock: HTTPXMock): - """A token with only openid scope is accepted; client_id comes from 'aud'.""" + """A token with only openid scope is accepted; client_id comes from 'sub'.""" httpx_mock.add_response( url=_TOKENINFO_RE, json={ @@ -268,7 +268,7 @@ class TestGoogleTokenVerifier: result = await verifier.verify_token("valid-token") assert result is not None - assert result.client_id == "123.apps.googleusercontent.com" + assert result.client_id == "12345" assert result.scopes == ["openid"] assert result.expires_at is not None assert result.claims["sub"] == "12345" @@ -305,7 +305,7 @@ class TestGoogleTokenVerifier: result = await verifier.verify_token("valid-token") assert result is not None - assert result.client_id == "123.apps.googleusercontent.com" + assert result.client_id == "12345" assert "openid" in result.scopes assert "https://www.googleapis.com/auth/userinfo.email" in result.scopes assert "https://www.googleapis.com/auth/userinfo.profile" in result.scopes diff --git a/tests/server/auth/providers/test_keycloak.py b/tests/server/auth/providers/test_keycloak.py new file mode 100644 index 000000000..4312e0103 --- /dev/null +++ b/tests/server/auth/providers/test_keycloak.py @@ -0,0 +1,135 @@ +"""Unit tests for Keycloak OAuth provider.""" + +import pytest + +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider + +TEST_REALM_URL = "https://keycloak.example.com/realms/test" +TEST_BASE_URL = "https://example.com:8000" +TEST_REQUIRED_SCOPES = ["openid", "profile"] + + +class TestKeycloakAuthProvider: + """Test KeycloakAuthProvider initialization.""" + + def test_init_with_explicit_params(self): + """Test initialization with explicit parameters.""" + provider = KeycloakAuthProvider( + realm_url=TEST_REALM_URL, + base_url=TEST_BASE_URL, + required_scopes=TEST_REQUIRED_SCOPES, + ) + + assert provider.realm_url == TEST_REALM_URL + assert str(provider.base_url) == TEST_BASE_URL + "/" + assert isinstance(provider.token_verifier, JWTVerifier) + assert provider.token_verifier.required_scopes == TEST_REQUIRED_SCOPES + jwt_verifier = provider.token_verifier + assert isinstance(jwt_verifier, JWTVerifier) + assert ( + jwt_verifier.jwks_uri == f"{TEST_REALM_URL}/protocol/openid-connect/certs" + ) + assert jwt_verifier.issuer == TEST_REALM_URL + + def test_init_with_string_scopes(self): + """Test initialization with scopes as comma-separated string.""" + provider = KeycloakAuthProvider( + realm_url=TEST_REALM_URL, + base_url=TEST_BASE_URL, + required_scopes="openid,profile,email", + ) + + assert provider.token_verifier.required_scopes == ["openid", "profile", "email"] + + def test_init_with_custom_token_verifier(self): + """Test initialization with custom token verifier.""" + custom_verifier = JWTVerifier( + jwks_uri=f"{TEST_REALM_URL}/protocol/openid-connect/certs", + issuer=TEST_REALM_URL, + audience="custom-client-id", + required_scopes=["custom:scope"], + ) + + provider = KeycloakAuthProvider( + realm_url=TEST_REALM_URL, + base_url=TEST_BASE_URL, + token_verifier=custom_verifier, + ) + + assert provider.token_verifier is custom_verifier + assert provider.token_verifier.audience == "custom-client-id" + assert provider.token_verifier.required_scopes == ["custom:scope"] + + def test_authorization_servers_point_to_keycloak(self): + """Test that authorization_servers points directly to the Keycloak realm.""" + provider = KeycloakAuthProvider( + realm_url=TEST_REALM_URL, + base_url=TEST_BASE_URL, + ) + + assert len(provider.authorization_servers) == 1 + assert str(provider.authorization_servers[0]).rstrip("/") == TEST_REALM_URL + + +class TestKeycloakHardCodedEndpoints: + """Test hard-coded Keycloak endpoint patterns.""" + + def test_uses_standard_keycloak_url_patterns(self): + """Test that provider uses Keycloak-specific URL patterns.""" + provider = KeycloakAuthProvider( + realm_url=TEST_REALM_URL, + base_url=TEST_BASE_URL, + ) + + jwt_verifier = provider.token_verifier + assert isinstance(jwt_verifier, JWTVerifier) + assert ( + jwt_verifier.jwks_uri == f"{TEST_REALM_URL}/protocol/openid-connect/certs" + ) + assert jwt_verifier.issuer == TEST_REALM_URL + + +class TestKeycloakRoutes: + """Test Keycloak auth provider routes.""" + + @pytest.fixture + def keycloak_provider(self): + """Create a KeycloakAuthProvider for testing.""" + return KeycloakAuthProvider( + realm_url=TEST_REALM_URL, + base_url=TEST_BASE_URL, + required_scopes=TEST_REQUIRED_SCOPES, + ) + + def test_get_routes(self, keycloak_provider): + """Test that get_routes returns only protected resource metadata (no proxy routes).""" + routes = keycloak_provider.get_routes() + + paths = [route.path for route in routes] + assert "/.well-known/oauth-protected-resource" in paths + assert "/register" not in paths + assert "/authorize" not in paths + + +class TestKeycloakEdgeCases: + """Test edge cases for KeycloakAuthProvider.""" + + def test_empty_required_scopes_handling(self): + """Test handling of empty required scopes.""" + provider = KeycloakAuthProvider( + realm_url=TEST_REALM_URL, + base_url=TEST_BASE_URL, + required_scopes=[], + ) + + assert provider.token_verifier.required_scopes == [] + + def test_realm_url_with_trailing_slash(self): + """Test handling of realm URL with trailing slash.""" + provider = KeycloakAuthProvider( + realm_url=TEST_REALM_URL + "/", + base_url=TEST_BASE_URL, + ) + + assert provider.realm_url == TEST_REALM_URL diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py index cc6ac742a..2e87956c1 100644 --- a/tests/server/auth/providers/test_workos.py +++ b/tests/server/auth/providers/test_workos.py @@ -9,6 +9,7 @@ from pytest_httpx import HTTPXMock from fastmcp import Client, FastMCP from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.server.auth.providers.workos import ( AuthKitProvider, WorkOSProvider, @@ -173,6 +174,97 @@ class TestAuthKitProvider: # assert "add" in tools +class TestAuthKitAudienceBinding: + """RFC 8707 resource-indicator audience binding. + + AuthKit mints tokens with ``aud`` equal to the resource URL the client + requested — which must equal the URL FastMCP advertises in its protected + resource metadata. AuthKitProvider auto-wires that equality: once the + MCP mount path is known, ``JWTVerifier.audience`` is set to + ``_get_resource_url(mcp_path)``. + """ + + def test_audience_binds_to_resource_url_on_set_mcp_path(self): + provider = AuthKitProvider( + authkit_domain="https://test.authkit.app", + base_url="http://127.0.0.1:8000", + ) + + verifier = provider.token_verifier + assert isinstance(verifier, JWTVerifier) + # Audience unset before the path is known — provider has no way to + # compute the resource URL yet. + assert verifier.audience is None + + provider.set_mcp_path("/mcp") + + expected = str(provider._get_resource_url("/mcp")) + assert verifier.audience == expected + assert expected == "http://127.0.0.1:8000/mcp" + + def test_set_mcp_path_none_binds_to_base_url(self): + """When no MCP path is provided, the resource URL is ``base_url`` + itself (an MCP-at-root server) and the audience binds to that.""" + provider = AuthKitProvider( + authkit_domain="https://test.authkit.app", + base_url="http://127.0.0.1:8000", + ) + + provider.set_mcp_path(None) + + verifier = provider.token_verifier + assert isinstance(verifier, JWTVerifier) + # Matches _get_resource_url(None) which returns base_url unchanged. + assert verifier.audience == "http://127.0.0.1:8000/" + + def test_audience_respects_resource_base_url(self): + """When ``resource_base_url`` differs from ``base_url``, the audience + follows the advertised resource URL, not the OAuth-surface URL.""" + provider = AuthKitProvider( + authkit_domain="https://test.authkit.app", + 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, JWTVerifier) + assert verifier.audience == "https://api.example.com/mcp" + + def test_custom_token_verifier_audience_not_overwritten(self): + """If the caller supplies their own verifier, we treat its audience + as intentional and do not touch it.""" + custom_audience = "https://some-other-resource.example.com" + custom = JWTVerifier( + jwks_uri="https://test.authkit.app/oauth2/jwks", + issuer="https://test.authkit.app", + audience=custom_audience, + ) + provider = AuthKitProvider( + authkit_domain="https://test.authkit.app", + base_url="http://127.0.0.1:8000", + token_verifier=custom, + ) + provider.set_mcp_path("/mcp") + + assert provider.token_verifier is custom + assert custom.audience == custom_audience + + def test_audience_binds_through_http_app(self): + """End-to-end: mounting a FastMCP server triggers the lifecycle hook + that populates ``JWTVerifier.audience``.""" + auth = AuthKitProvider( + authkit_domain="https://test.authkit.app", + base_url="http://127.0.0.1:8000", + ) + mcp = FastMCP("test", auth=auth) + mcp.http_app(path="/mcp") + + verifier = auth.token_verifier + assert isinstance(verifier, JWTVerifier) + assert verifier.audience == "http://127.0.0.1:8000/mcp" + + class TestWorkOSTokenVerifierScopes: async def test_verify_token_rejects_missing_required_scopes( self, httpx_mock: HTTPXMock diff --git a/tests/server/auth/test_auth_provider.py b/tests/server/auth/test_auth_provider.py index ab6ab36cf..22f98e0ab 100644 --- a/tests/server/auth/test_auth_provider.py +++ b/tests/server/auth/test_auth_provider.py @@ -5,13 +5,36 @@ import pytest from pydantic import AnyHttpUrl from fastmcp import FastMCP -from fastmcp.server.auth import RemoteAuthProvider +from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier +from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.providers.jwt import StaticTokenVerifier +class LegacyTokenVerifier(TokenVerifier): + """Mimics custom verifiers that still call the old positional super().__init__.""" + + def __init__( + self, + base_url: AnyHttpUrl | str | None = None, + required_scopes: list[str] | None = None, + ): + super().__init__(base_url, required_scopes) + + async def verify_token(self, token: str) -> AccessToken | None: + return None + + class TestAuthProviderBase: """Test suite for base AuthProvider behaviors that apply to all auth providers.""" + def test_token_verifier_preserves_legacy_positional_required_scopes(self): + """Legacy positional super().__init__(base_url, required_scopes) should keep working.""" + verifier = LegacyTokenVerifier("https://my-server.com", ["read"]) + + assert verifier.base_url == AnyHttpUrl("https://my-server.com/") + assert verifier.required_scopes == ["read"] + assert verifier.resource_base_url is None + @pytest.fixture def basic_remote_provider(self): """Basic RemoteAuthProvider fixture for testing base AuthProvider behaviors.""" diff --git a/tests/server/auth/test_multi_auth.py b/tests/server/auth/test_multi_auth.py index 369da86a5..727158d1c 100644 --- a/tests/server/auth/test_multi_auth.py +++ b/tests/server/auth/test_multi_auth.py @@ -66,6 +66,34 @@ class TestMultiAuthInit: auth = MultiAuth(server=provider, base_url="https://override.example.com") assert auth.base_url == AnyHttpUrl("https://override.example.com/") + def test_resource_base_url_from_server(self): + verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) + provider = RemoteAuthProvider( + token_verifier=verifier, + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + base_url="https://auth.example.com/proxy", + resource_base_url="https://api.example.com", + ) + auth = MultiAuth(server=provider) + assert auth.resource_base_url == AnyHttpUrl("https://api.example.com/") + + def test_resource_base_url_override(self): + verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) + provider = RemoteAuthProvider( + token_verifier=verifier, + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + base_url="https://auth.example.com/proxy", + resource_base_url="https://api.example.com", + ) + auth = MultiAuth( + server=provider, + resource_base_url="https://override.example.com", + ) + assert auth.resource_base_url == AnyHttpUrl("https://override.example.com/") + # Override must propagate to the wrapped server so get_routes() + # serves metadata consistent with the outer auth challenge URL. + assert provider.resource_base_url == AnyHttpUrl("https://override.example.com/") + def test_required_scopes_from_server(self): verifier = StaticTokenVerifier( tokens={"t": {"client_id": "c", "scopes": ["read"]}}, @@ -328,6 +356,65 @@ class TestMultiAuthIntegration: data = response.json() assert data["resource"] == "https://api.example.com/mcp" + async def test_multi_auth_uses_server_resource_base_url_in_auth_challenge(self): + """Auth challenges should advertise resource metadata from resource_base_url.""" + verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) + server = RemoteAuthProvider( + token_verifier=verifier, + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + base_url="https://auth.example.com/proxy", + resource_base_url="https://api.example.com", + ) + + auth = MultiAuth(server=server) + mcp = FastMCP("test", auth=auth) + app = mcp.http_app(path="/mcp") + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://localhost", + ) as client: + response = await client.get("/mcp") + assert response.status_code == 401 + assert ( + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"' + in 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": []}}) + server = RemoteAuthProvider( + token_verifier=verifier, + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + base_url="https://auth.example.com/proxy", + ) + + auth = MultiAuth(server=server, resource_base_url="https://api.example.com") + mcp = FastMCP("test", auth=auth) + app = mcp.http_app(path="/mcp") + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://localhost", + ) as client: + response = await client.get("/mcp") + assert response.status_code == 401 + assert ( + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"' + in response.headers["www-authenticate"] + ) + + metadata_response = await client.get( + "/.well-known/oauth-protected-resource/mcp" + ) + assert metadata_response.status_code == 200 + assert metadata_response.json()["resource"] == "https://api.example.com/mcp" + old_path_response = await client.get( + "/.well-known/oauth-protected-resource/proxy/mcp" + ) + assert old_path_response.status_code == 404 + async def test_multi_auth_accepts_valid_verifier_token(self): """MultiAuth accepts tokens from verifiers (not just the server). diff --git a/tests/server/auth/test_remote_auth_provider.py b/tests/server/auth/test_remote_auth_provider.py index 5d56c5b5c..bc37decf8 100644 --- a/tests/server/auth/test_remote_auth_provider.py +++ b/tests/server/auth/test_remote_auth_provider.py @@ -150,6 +150,36 @@ class TestRemoteAuthProvider: "https://api.example.com/.well-known/oauth-protected-resource/mcp" ) + def test_get_resource_url_uses_resource_base_url_when_provided(self, test_tokens): + """Test protected resource URLs are derived from resource_base_url when provided.""" + token_verifier = StaticTokenVerifier(tokens=test_tokens) + provider = RemoteAuthProvider( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + base_url="https://auth.example.com/proxy", + resource_base_url="https://api.example.com", + ) + + assert provider._get_resource_url("/mcp") == AnyHttpUrl( + "https://api.example.com/mcp" + ) + + def test_init_preserves_legacy_positional_scopes_supported_slot(self, test_tokens): + """Legacy positional scopes_supported should not bind to resource_base_url.""" + token_verifier = StaticTokenVerifier(tokens=test_tokens) + provider = RemoteAuthProvider( + token_verifier, + [AnyHttpUrl("https://auth.example.com")], + "https://api.example.com", + ["read"], + ) + + assert provider._scopes_supported == ["read"] + assert provider.resource_base_url is None + assert provider._get_resource_url("/mcp") == AnyHttpUrl( + "https://api.example.com/mcp" + ) + class TestRemoteAuthProviderIntegration: """Integration tests for RemoteAuthProvider with FastMCP server.""" diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index e637af269..f1a1e6f47 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -2,10 +2,11 @@ import json import pytest from mcp.types import TextContent, TextResourceContents +from starlette.requests import Request from fastmcp.client import Client from fastmcp.client.transports import SSETransport, StreamableHttpTransport -from fastmcp.server.dependencies import get_http_request +from fastmcp.server.dependencies import CurrentHeaders, CurrentRequest, get_http_request from fastmcp.server.server import FastMCP from fastmcp.utilities.tests import run_server_async @@ -166,3 +167,53 @@ async def test_get_http_headers_excludes_content_type(sse_server: str): # Custom headers should be included assert "x-custom-header" in headers assert headers["x-custom-header"] == "should-be-included" + + +async def test_background_task_can_read_snapshotted_request_headers(): + """Background tools can still access request headers via get_http_request().""" + server = FastMCP() + + @server.tool(task=True) + async def check_request_header() -> str: + request = get_http_request() + return request.headers.get("x-tenant-id", "missing") + + async with run_server_async(server, transport="sse") as url: + async with Client( + transport=SSETransport(url, headers={"X-Tenant-ID": "tenant-123"}) + ) as client: + task = await client.call_tool("check_request_header", task=True) + result = await task.result() + assert result.data == "tenant-123" + + +async def test_background_task_current_http_dependencies_restore_headers(): + """CurrentHeaders/CurrentRequest work in task workers without explicit Context.""" + server = FastMCP() + + @server.tool(task=True) + async def check_headers( + headers: dict[str, str] = CurrentHeaders(), + request: Request = CurrentRequest(), + ) -> dict[str, str]: + return { + "authorization": headers.get("authorization", "missing"), + "tenant": request.headers.get("x-tenant-id", "missing"), + } + + async with run_server_async(server, transport="sse") as url: + async with Client( + transport=SSETransport( + url, + headers={ + "Authorization": "Bearer tenant-token", + "X-Tenant-ID": "tenant-456", + }, + ) + ) as client: + task = await client.call_tool("check_headers", task=True) + result = await task.result() + assert result.data == { + "authorization": "Bearer tenant-token", + "tenant": "tenant-456", + } diff --git a/tests/server/http/test_http_middleware.py b/tests/server/http/test_http_middleware.py index ba2de63a1..94b263d88 100644 --- a/tests/server/http/test_http_middleware.py +++ b/tests/server/http/test_http_middleware.py @@ -55,10 +55,9 @@ async def test_sse_app_with_custom_middleware(): server = FastMCP(name="TestServer") # Create custom middleware - # TODO(ty): remove when Starlette Middleware typing is supported custom_middleware = [ Middleware( - HeaderMiddleware, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + HeaderMiddleware, # type: ignore[arg-type] header_name="X-Custom-Header", header_value="test-value", ) @@ -88,10 +87,9 @@ async def test_streamable_http_app_with_custom_middleware(): server = FastMCP(name="TestServer") # Create custom middleware - # TODO(ty): remove when Starlette Middleware typing is supported custom_middleware = [ Middleware( - HeaderMiddleware, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + HeaderMiddleware, # type: ignore[arg-type] header_name="X-Custom-Header", header_value="test-value", ) @@ -121,10 +119,9 @@ async def test_create_sse_app_with_custom_middleware(): server = FastMCP(name="TestServer") # Create custom middleware - # TODO(ty): remove when Starlette Middleware typing is supported custom_middleware = [ Middleware( - RequestModifierMiddleware, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + RequestModifierMiddleware, # type: ignore[arg-type] key="modified_by", value="middleware", ) @@ -161,10 +158,9 @@ async def test_create_streamable_http_app_with_custom_middleware(): server = FastMCP(name="TestServer") # Create custom middleware - # TODO(ty): remove when Starlette Middleware typing is supported custom_middleware = [ Middleware( - RequestModifierMiddleware, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + RequestModifierMiddleware, # type: ignore[arg-type] key="modified_by", value="middleware", ) @@ -200,15 +196,14 @@ async def test_multiple_middleware_ordering(): server = FastMCP(name="TestServer") # Create multiple middleware - # TODO(ty): remove when Starlette Middleware typing is supported custom_middleware = [ Middleware( - HeaderMiddleware, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + HeaderMiddleware, # type: ignore[arg-type] header_name="X-First-Header", header_value="first", ), Middleware( - HeaderMiddleware, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + HeaderMiddleware, # type: ignore[arg-type] header_name="X-Second-Header", header_value="second", ), diff --git a/tests/server/middleware/test_response_limiting.py b/tests/server/middleware/test_response_limiting.py index b2c6cd810..bbf615c16 100644 --- a/tests/server/middleware/test_response_limiting.py +++ b/tests/server/middleware/test_response_limiting.py @@ -2,12 +2,18 @@ import pytest from mcp.types import ImageContent, TextContent +from pydantic import BaseModel from fastmcp import Client, FastMCP from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware from fastmcp.tools.base import ToolResult +# Regression test model for #3717 +class Answer(BaseModel): + text: str + + class TestResponseLimitingMiddleware: """Tests for ResponseLimitingMiddleware.""" @@ -143,6 +149,26 @@ class TestResponseLimitingMiddleware: with pytest.raises(ValueError, match="max_size must be positive"): ResponseLimitingMiddleware(max_size=-100) + async def test_truncation_does_not_break_output_schema_tools( + self, mcp_server: FastMCP + ): + """Truncating a tool with outputSchema must not cause validation errors. + + Regression test for #3717: the MCP SDK rejects truncated results + from tools with outputSchema because structured_content is dropped. + We verify the server returns a successful (non-error) truncated result. + """ + mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=1_000)) + + @mcp_server.tool() + def big_answer() -> Answer: + return Answer(text="x" * 2_000) + + result = await mcp_server.call_tool("big_answer", {}) + first = result.content[0] + assert isinstance(first, TextContent) + assert "[Response truncated" in first.text + def test_utf8_truncation_preserves_characters(self): """Test that UTF-8 truncation doesn't break multi-byte characters.""" middleware = ResponseLimitingMiddleware(max_size=100) diff --git a/tests/server/mount/test_mount.py b/tests/server/mount/test_mount.py index 1850a3d28..9563378a8 100644 --- a/tests/server/mount/test_mount.py +++ b/tests/server/mount/test_mount.py @@ -300,18 +300,19 @@ class TestMultipleServerMount: prompt_names = [prompt.name for prompt in prompts] assert "working_working_prompt" in prompt_names - # Verify that errors were logged for the unreachable provider (at DEBUG level) - debug_messages = [ - record.message for record in caplog.records if record.levelname == "DEBUG" + # Verify that errors were logged for the unreachable provider (at WARNING level) + warning_messages = [ + record.message for record in caplog.records if record.levelname == "WARNING" ] assert any( - "Error during list_tools from provider" in msg for msg in debug_messages + "Error during list_tools from provider" in msg for msg in warning_messages ) assert any( - "Error during list_resources from provider" in msg for msg in debug_messages + "Error during list_resources from provider" in msg + for msg in warning_messages ) assert any( - "Error during list_prompts from provider" in msg for msg in debug_messages + "Error during list_prompts from provider" in msg for msg in warning_messages ) @@ -540,3 +541,97 @@ class TestPrefixConflictResolution: assert result.messages is not None assert isinstance(result.messages[0].content, TextContent) assert result.messages[0].content.text == "First app prompt" + + +class TestCrossProviderDuplicateDetection: + """Cross-provider collisions always log a warning — diagnostic signal only. + + `on_duplicate` is a registration-time setting for LocalProvider (two + decorators on the same server), not a knob for AggregateProvider + composition. Mounted-provider collisions happen at runtime (sometimes + dynamically), so an error mode would give the author no way to react. + """ + + async def test_cross_provider_duplicate_warns(self, caplog): + """Two mounted providers exposing the same tool identity log a warning.""" + main = FastMCP("Main") + sub1 = FastMCP("Sub1") + sub2 = FastMCP("Sub2") + + @sub1.tool(name="greet") + def greet_w1() -> str: + return "from sub1" + + @sub2.tool(name="greet") + def greet_w2() -> str: + return "from sub2" + + main.mount(sub1, "ns") + main.mount(sub2, "ns") + + caplog.set_level(logging.WARNING, logger="fastmcp") + tools = await main.list_tools() + assert len([t for t in tools if t.name == "ns_greet"]) == 2 + assert any("Duplicate" in r.message for r in caplog.records) + + async def test_no_false_positive_for_different_names(self): + """Different tool names in the same namespace don't trigger a warning.""" + main = FastMCP("Main") + sub1 = FastMCP("Sub1") + sub2 = FastMCP("Sub2") + + @sub1.tool + def tool_a() -> str: + return "a" + + @sub2.tool + def tool_b() -> str: + return "b" + + main.mount(sub1, "ns") + main.mount(sub2, "ns") + + tools = await main.list_tools() + assert len(tools) == 2 + + +class TestMaskErrorDetailsMismatchWarning: + """Test that mismatched mask_error_details is warned about at mount time.""" + + async def test_warns_when_parent_masked_child_not(self, caplog): + """Mounting an unmasked child into a masked parent logs a warning.""" + caplog.set_level(logging.WARNING, logger="fastmcp") + parent = FastMCP("parent", mask_error_details=True) + child = FastMCP("child") + + @child.tool + def my_tool() -> str: + return "ok" + + parent.mount(child, "ns") + assert any("mask_error_details" in r.message for r in caplog.records) + + async def test_no_warning_when_both_masked(self, caplog): + """No warning when both parent and child have masking enabled.""" + caplog.set_level(logging.WARNING, logger="fastmcp") + parent = FastMCP("parent", mask_error_details=True) + child = FastMCP("child", mask_error_details=True) + + @child.tool + def my_tool() -> str: + return "ok" + + parent.mount(child, "ns") + assert not any("mask_error_details" in r.message for r in caplog.records) + + async def test_child_not_mutated_by_mount(self): + """Mounting should not mutate the child server's mask setting.""" + parent = FastMCP("parent", mask_error_details=True) + child = FastMCP("child") + + @child.tool + def my_tool() -> str: + return "ok" + + parent.mount(child, "ns") + assert child._mask_error_details is False diff --git a/tests/server/mount/test_resources.py b/tests/server/mount/test_resources.py index e6c4fb502..06e2ce548 100644 --- a/tests/server/mount/test_resources.py +++ b/tests/server/mount/test_resources.py @@ -54,6 +54,23 @@ class TestResourcesAndTemplates: assert profile["id"] == "123" assert profile["name"] == "User 123" + async def test_mount_with_wildcard_resource_template(self): + """Wildcard `{name*}` params must survive round-trip through a namespaced mount.""" + main_app = FastMCP("MainApp") + sub_app = FastMCP("SubApp") + + @sub_app.resource("resource://multi/{extra*}") + def multi(extra: str) -> str: + return extra + + main_app.mount(sub_app, namespace="sub") + + result = await main_app.read_resource("resource://sub/multi/abc/def") + assert result.contents[0].content == "abc/def" + + result = await main_app.read_resource("resource://sub/multi/abc") + assert result.contents[0].content == "abc" + async def test_adding_resource_after_mounting(self): """Test adding a resource after mounting.""" main_app = FastMCP("MainApp") diff --git a/tests/server/providers/local_provider_tools/test_local_provider_tools.py b/tests/server/providers/local_provider_tools/test_local_provider_tools.py index 9e1803b41..292365bc4 100644 --- a/tests/server/providers/local_provider_tools/test_local_provider_tools.py +++ b/tests/server/providers/local_provider_tools/test_local_provider_tools.py @@ -67,7 +67,9 @@ class TestToolReturnTypes: return b"Hello, world!" result = await mcp.call_tool("bytes_tool", {}) - assert result.structured_content == {"result": "Hello, world!"} + # bytes can't be represented as structured JSON, so no structured_content + assert result.structured_content is None + assert result.content[0].text == "Hello, world!" # ty:ignore[unresolved-attribute] async def test_uuid(self): mcp = FastMCP() diff --git a/tests/server/providers/openapi/test_performance_comparison.py b/tests/server/providers/openapi/test_performance_comparison.py index 8a0d49e53..66f55bd1b 100644 --- a/tests/server/providers/openapi/test_performance_comparison.py +++ b/tests/server/providers/openapi/test_performance_comparison.py @@ -185,14 +185,14 @@ class TestPerformance: times.append(end_time - start_time) avg_time = sum(times) / len(times) - max_acceptable_time = 0.1 # 100ms + max_acceptable_time = 0.2 # 200ms (Windows CI runners regularly clip 100ms) print(f"Average initialization time: {avg_time:.4f}s") print(f"Performance: {'✓' if avg_time < max_acceptable_time else '✗'}") - # Should initialize in under 100ms for serverless requirements + # Should initialize in under 200ms for serverless requirements assert avg_time < max_acceptable_time, ( - f"Provider should initialize in under 100ms, got {avg_time:.4f}s" + f"Provider should initialize in under 200ms, got {avg_time:.4f}s" ) def test_server_initialization_performance(self, comprehensive_spec): @@ -214,12 +214,12 @@ class TestPerformance: times.append(end_time - start_time) avg_time = sum(times) / len(times) - max_acceptable_time = 0.1 # 100ms + max_acceptable_time = 0.2 # 200ms (Windows CI runners regularly clip 100ms) print(f"Average server initialization time: {avg_time:.4f}s") assert avg_time < max_acceptable_time, ( - f"Server should initialize in under 100ms, got {avg_time:.4f}s" + f"Server should initialize in under 200ms, got {avg_time:.4f}s" ) async def test_functionality_after_optimization(self, comprehensive_spec): diff --git a/tests/server/providers/openapi/test_server.py b/tests/server/providers/openapi/test_server.py index 0d7447eab..af3641ca0 100644 --- a/tests/server/providers/openapi/test_server.py +++ b/tests/server/providers/openapi/test_server.py @@ -9,6 +9,58 @@ from fastmcp.server.providers.openapi import OpenAPIProvider from fastmcp.server.providers.openapi.provider import DEFAULT_TIMEOUT +class TestOpenAPIProviderServerVariables: + """Test that OpenAPIProvider resolves OpenAPI 3.x server variables.""" + + def test_server_variables_substituted_with_defaults(self): + spec = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "servers": [ + { + "url": "https://{region}.api.example.com/v1", + "variables": { + "region": { + "default": "us", + "enum": ["us", "eu", "apac"], + } + }, + } + ], + "paths": {}, + } + client = OpenAPIProvider._create_default_client(spec) + assert str(client.base_url) == "https://us.api.example.com/v1/" + + def test_multiple_server_variables_substituted(self): + spec = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "servers": [ + { + "url": "{scheme}://{host}/v1", + "variables": { + "scheme": {"default": "https"}, + "host": {"default": "api.example.com"}, + }, + } + ], + "paths": {}, + } + client = OpenAPIProvider._create_default_client(spec) + assert str(client.base_url) == "https://api.example.com/v1/" + + def test_static_server_url_unaffected(self): + spec = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": {}, + } + client = OpenAPIProvider._create_default_client(spec) + assert str(client.base_url) == "https://api.example.com" + + class TestOpenAPIProviderBasicFunctionality: """Test basic OpenAPIProvider functionality.""" diff --git a/tests/server/providers/test_addressing.py b/tests/server/providers/test_addressing.py new file mode 100644 index 000000000..ed348403d --- /dev/null +++ b/tests/server/providers/test_addressing.py @@ -0,0 +1,66 @@ +"""Tests for the tool hashing primitives.""" + +from __future__ import annotations + +from fastmcp.server.providers.addressing import ( + HASH_LENGTH, + hash_tool, + hashed_backend_name, + hashed_resource_uri, + parse_hashed_backend_name, + parse_hashed_resource_uri, +) + + +class TestHashFunction: + def test_hash_is_fixed_length_hex(self): + h = hash_tool("myapp", "greet") + assert len(h) == HASH_LENGTH + assert all(c in "0123456789abcdef" for c in h) + + def test_same_inputs_same_hash(self): + a = hash_tool("app", "submit_form") + b = hash_tool("app", "submit_form") + assert a == b + + def test_different_app_names_different_hash(self): + a = hash_tool("contacts", "save") + b = hash_tool("billing", "save") + assert a != b + + def test_different_tool_names_different_hash(self): + a = hash_tool("app", "save") + b = hash_tool("app", "delete") + assert a != b + + +class TestBackendNameRoundtrip: + def test_format_and_parse(self): + name = hashed_backend_name("contacts", "submit_form") + parsed = parse_hashed_backend_name(name) + assert parsed is not None + digest, local = parsed + assert digest == hash_tool("contacts", "submit_form") + assert local == "submit_form" + + def test_parse_rejects_short_strings(self): + assert parse_hashed_backend_name("foo") is None + + def test_parse_rejects_non_hex_prefix(self): + assert parse_hashed_backend_name("zzzzzzzzzzzz_save") is None + + def test_parse_rejects_missing_separator(self): + assert parse_hashed_backend_name("abcdef012345save") is None + + +class TestResourceUriRoundtrip: + def test_format_and_parse(self): + uri = hashed_resource_uri("dashboard", "show") + h = parse_hashed_resource_uri(uri) + assert h == hash_tool("dashboard", "show") + + def test_parse_rejects_unrelated_uri(self): + assert parse_hashed_resource_uri("file:///etc/passwd") is None + + def test_parse_rejects_wrong_length_hash(self): + assert parse_hashed_resource_uri("ui://prefab/tool/abc/renderer.html") is None diff --git a/tests/server/providers/test_prefab_roundtrip.py b/tests/server/providers/test_prefab_roundtrip.py new file mode 100644 index 000000000..72946910f --- /dev/null +++ b/tests/server/providers/test_prefab_roundtrip.py @@ -0,0 +1,185 @@ +"""End-to-end round-trip tests for Prefab peer-tool references. + +These simulate what a real host does: call the UI tool, extract the +hashed backend-tool name from structured_content, call back with +that name, and verify the backend tool actually executes. Covers +single-server, namespaced mounts, and cross-server mounts. +""" + +from __future__ import annotations + +import json + +import pytest + +from fastmcp import FastMCP, FastMCPApp +from fastmcp.server.providers.addressing import hashed_backend_name + +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 + + +class TestSingleServerRoundTrip: + async def test_ui_tool_serializes_hashed_peer_reference(self): + """The resolver converts a CallTool string reference to a hashed + name that appears in the tool result's structured_content.""" + 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 + + # 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 + resolves when called back — the full round-trip works.""" + 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) + + # Step 1: call UI tool, get structured_content with hashed ref + await server.call_tool("contact_form", {}) + + # Step 2: call the backend tool by its hashed name + hashed_name = hashed_backend_name("contacts", "save_contact") + backend_result = await server.call_tool(hashed_name, {"name": "Alice"}) + assert backend_result.content[0].text == "saved Alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + +class TestNamespacedMountRoundTrip: + async def test_namespaced_app_backend_tool_round_trip(self): + """A FastMCPApp mounted with a namespace: the UI tool is called + by its namespaced display name, the backend tool is called by + its hashed name — both work.""" + app = FastMCPApp("crm") + + @app.tool() + def save(name: str) -> str: + return f"saved {name}" + + @app.ui() + def form() -> Text: + return Text(content="Enter details") + + server = FastMCP("Platform") + server.add_provider(app, namespace="crm") + + # UI tool visible under namespace + result = await server.call_tool("crm_form", {}) + assert result.structured_content is not None + + # Backend tool reachable via hash + hashed_name = hashed_backend_name("crm", "save") + backend_result = await server.call_tool(hashed_name, {"name": "Bob"}) + assert backend_result.content[0].text == "saved Bob" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + +class TestMountedServerRoundTrip: + async def test_backend_tool_reachable_through_mounted_server(self): + """A FastMCPApp inside a mounted FastMCP server: the outer + server's dispatcher walks through FastMCPProvider to find + the backend tool by hash.""" + app = FastMCPApp("contacts") + + @app.tool() + def save(name: str) -> str: + return f"saved {name}" + + @app.ui() + def form() -> Text: + return Text(content="Form") + + inner = FastMCP("Inner") + inner.add_provider(app) + + outer = FastMCP("Outer") + outer.mount(inner, namespace="inner") + + # Backend tool callable through the mount via hash dispatch + hashed_name = hashed_backend_name("contacts", "save") + result = await outer.call_tool(hashed_name, {"name": "Carol"}) + assert result.content[0].text == "saved Carol" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + +class TestDynamicToolAdd: + async def test_tool_added_after_first_call_is_reachable(self): + """Tools added to an already-mounted app after the first call + are still reachable via their hashed name — get_tool_by_hash + does a live walk, not a cached lookup.""" + app = FastMCPApp("contacts") + + server = FastMCP("Platform") + server.add_provider(app) + + # First call — nothing to call yet, just prime any caches. + tools = await server.list_tools() + assert len(tools) == 0 + + # Now add a backend tool dynamically. + @app.tool() + def save(name: str) -> str: + return f"saved {name}" + + # The dynamically-added tool should be reachable. + hashed_name = hashed_backend_name("contacts", "save") + result = await server.call_tool(hashed_name, {"name": "Dan"}) + assert result.content[0].text == "saved Dan" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + +class TestCollision: + async def test_same_app_name_same_tool_name_first_wins(self): + """Two apps with the same name and same tool name: the hash is + identical, so get_tool_by_hash returns the first match. This is + the same first-match behavior the old get_app_tool had.""" + app_a = FastMCPApp("shared") + app_b = FastMCPApp("shared") + + @app_a.tool() + def save(name: str) -> str: + return f"from A: {name}" + + @app_b.tool() + def save_b(name: str) -> str: + return f"from B: {name}" + + # Register under a different local tool name to avoid + # actual collision at the provider level. The hash collision + # only happens when both app name AND tool name match. + # This test just verifies one app's tool is reachable. + server = FastMCP("Platform") + server.add_provider(app_a) + server.add_provider(app_b) + + hashed_name = hashed_backend_name("shared", "save") + result = await server.call_tool(hashed_name, {"name": "Eve"}) + assert result.content[0].text == "from A: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] diff --git a/tests/server/providers/test_prefab_synthesis.py b/tests/server/providers/test_prefab_synthesis.py new file mode 100644 index 000000000..1a84ac0c4 --- /dev/null +++ b/tests/server/providers/test_prefab_synthesis.py @@ -0,0 +1,198 @@ +"""End-to-end tests for the on-demand Prefab renderer synthesis. + +The whole architecture exists to fix #3735 / PR #3754: a user passing a +``PrefabAppConfig(csp=ResourceCSP(frame_domains=[...]))`` should see +their ``frame_domains`` actually arrive on the renderer resource's CSP, +and CSP should NOT leak into the tool's wire metadata. These tests +exercise the synthesis path directly through the public server API. +""" + +from __future__ import annotations + +import pytest + +from fastmcp import FastMCP, FastMCPApp + +prefab_ui = pytest.importorskip("prefab_ui") +from fastmcp.apps.config import PrefabAppConfig, ResourceCSP # noqa: E402 + + +class TestUserCSPReachesResource: + """The original bug: user CSP must land on the resource, not vanish.""" + + async def test_frame_domains_reach_resource(self): + mcp = FastMCP("test") + + @mcp.tool( + app=PrefabAppConfig( + csp=ResourceCSP(frame_domains=["https://example1234.com"]) + ) + ) + def show_widget() -> str: + return "widget" + + # Find the synthesized prefab resource for this tool. + resources = list(await mcp.list_resources()) + renderer = next((r for r in resources if "prefab/tool" in str(r.uri)), None) + assert renderer is not None, "no prefab resource was synthesized" + assert renderer.meta is not None + + csp = renderer.meta["ui"]["csp"] + assert "https://example1234.com" in csp.get("frameDomains", []), ( + f"frame_domains missing from resource CSP: {csp}" + ) + + async def test_all_four_domain_fields_preserved(self): + """The old singleton silently dropped frame_domains and + base_uri_domains; the synthesizer covers all four fields.""" + mcp = FastMCP("test") + + @mcp.tool( + app=PrefabAppConfig( + csp=ResourceCSP( + connect_domains=["https://api.example.com"], + resource_domains=["https://cdn.example.com"], + frame_domains=["https://embed.example.com"], + base_uri_domains=["https://base.example.com"], + ) + ) + ) + def widget() -> str: + return "x" + + resources = list(await mcp.list_resources()) + renderer = next(r for r in resources if "prefab/tool" in str(r.uri)) + assert renderer.meta is not None + csp = renderer.meta["ui"]["csp"] + + assert "https://api.example.com" in csp.get("connectDomains", []) + assert "https://cdn.example.com" in csp.get("resourceDomains", []) + assert "https://embed.example.com" in csp.get("frameDomains", []) + assert "https://base.example.com" in csp.get("baseUriDomains", []) + + +class TestCSPStrippedFromToolMeta: + """CSP belongs on the resource, not the tool. The wire format that + clients see for tools must not contain it.""" + + async def test_csp_not_in_listed_tool_meta(self): + mcp = FastMCP("test") + + @mcp.tool( + app=PrefabAppConfig(csp=ResourceCSP(frame_domains=["https://example.com"])) + ) + def show_widget() -> str: + return "widget" + + tools = list(await mcp.list_tools()) + tool = next(t for t in tools if t.name == "show_widget") + assert tool.meta is not None + ui = tool.meta["ui"] + assert "csp" not in ui, f"csp leaked into tool meta: {ui}" + assert "permissions" not in ui + + +class TestPerToolURIs: + """Each prefab tool gets its own URI — distinct CSP per tool becomes + possible because no two tools share a renderer resource.""" + + async def test_two_tools_get_distinct_uris(self): + mcp = FastMCP("test") + + @mcp.tool(app=True) + def tool_a() -> str: + return "a" + + @mcp.tool(app=True) + def tool_b() -> str: + return "b" + + tools = list(await mcp.list_tools()) + a = next(t for t in tools if t.name == "tool_a") + b = next(t for t in tools if t.name == "tool_b") + assert a.meta is not None + assert b.meta is not None + uri_a = a.meta["ui"]["resourceUri"] + uri_b = b.meta["ui"]["resourceUri"] + assert uri_a != uri_b + assert uri_a.startswith("ui://prefab/tool/") + assert uri_b.startswith("ui://prefab/tool/") + + +class TestFastMCPAppMounts: + """Tools inside FastMCPApps get URIs derived from the app's mount address.""" + + async def test_app_tool_uri_uses_address(self): + app = FastMCPApp("dashboard") + + @app.ui() + def show() -> str: + return "rendered" + + mcp = FastMCP("Platform") + mcp.add_provider(app) + + resources = list(await mcp.list_resources()) + prefab = [r for r in resources if "prefab/tool" in str(r.uri)] + assert len(prefab) == 1 + + async def test_namespaced_mount_still_synthesizes_resource(self): + app = FastMCPApp("crm") + + @app.ui() + def contact_form() -> str: + return "form" + + mcp = FastMCP("Platform") + mcp.add_provider(app, namespace="customers") + + resources = list(await mcp.list_resources()) + prefab = [r for r in resources if "prefab/tool" in str(r.uri)] + assert len(prefab) == 1 + + +class TestReadResource: + """The synthesized resources are actually fetchable via read_resource.""" + + async def test_read_resource_returns_renderer_html(self): + from fastmcp import Client + + mcp = FastMCP("test") + + @mcp.tool(app=True) + def my_tool() -> str: + return "hi" + + async with Client(mcp) as client: + tools = await client.list_tools() + uri = next(t for t in tools if t.name == "my_tool").meta["ui"][ + "resourceUri" + ] + contents = await client.read_resource(uri) + + assert len(contents) > 0 + text = contents[0].text if hasattr(contents[0], "text") else "" + assert " str: + return name + + tools = list(await mcp.list_tools()) + tool = next(t for t in tools if t.name == "greet") + assert not tool.meta or "ui" not in (tool.meta or {}) + + async def test_plain_server_has_no_synthesized_resources(self): + mcp = FastMCP("test") + + @mcp.tool + def greet(name: str) -> str: + return name + + resources = list(await mcp.list_resources()) + assert not any("prefab/tool" in str(r.uri) for r in resources) diff --git a/tests/server/providers/test_skills_provider.py b/tests/server/providers/test_skills_provider.py index 3caa96cf4..97732f308 100644 --- a/tests/server/providers/test_skills_provider.py +++ b/tests/server/providers/test_skills_provider.py @@ -653,14 +653,8 @@ class TestPathTraversalPrevention: mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir)) async with Client(mcp) as client: - # Path traversal attempts should fail (either normalized away or blocked) - # The important thing is that SECRET DATA is never returned + # Path traversal attempts should fail — the secret must never be returned with pytest.raises(Exception): - result = await client.read_resource( + await client.read_resource( AnyUrl("skill://test-skill/../../../secret.txt") ) - # If we somehow got here, ensure we didn't get the secret - if result: - for content in result: - if hasattr(content, "text"): - assert "SECRET DATA" not in content.text diff --git a/tests/server/tasks/test_concurrent_dependencies.py b/tests/server/tasks/test_concurrent_dependencies.py new file mode 100644 index 000000000..eb5af1bb1 --- /dev/null +++ b/tests/server/tasks/test_concurrent_dependencies.py @@ -0,0 +1,213 @@ +"""Tests for concurrent dependency resolution in foreground and background tasks. + +Regression tests for: +- #3654: ValueError when concurrent Docket tasks share a Dependency instance + that stores a ContextVar token on `self` +- #3656: Progress raises AssertionError when concurrent tasks share `_impl` +""" + +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 ( + get_access_token, + get_http_headers, +) + + +async def test_concurrent_foreground_tools_with_context(): + """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() + async def slow_tool(name: str, ctx: Context) -> str: + await asyncio.sleep(0.05) + results.append(name) + return f"done:{name}" + + 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: + assert outcome.content[0].text.startswith("done:") + + +async def test_concurrent_foreground_tools_with_progress(): + """Multiple concurrent tool calls sharing the same Progress() default + should not raise AssertionError from _impl being None (#3656).""" + mcp = FastMCP("test") + + @mcp.tool() + async def variable_tool( + name: str, delay: float, progress: Progress = Progress() + ) -> str: + await progress.set_total(3) + await progress.increment() + await asyncio.sleep(delay) + await progress.increment() + await progress.set_message(f"finishing {name}") + await progress.increment() + return f"done:{name}" + + 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: + assert outcome.content[0].text.startswith("done:") + + +async def test_concurrent_background_tasks_with_context(): + """Multiple concurrent background tasks sharing _CurrentContext() should + not raise ValueError from ContextVar token resets (#3654).""" + mcp = FastMCP("test") + + @mcp.tool(task=True) + async def bg_tool(name: str, ctx: Context) -> str: + await asyncio.sleep(0.05) + return f"bg:{name}" + + 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) + ] + results = await asyncio.gather(*[t.result() for t in task_handles]) + + 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.tool(task=True) + async def bg_progress_tool( + name: str, delay: float, progress: Progress = Progress() + ) -> str: + await progress.set_total(3) + await progress.increment() + await asyncio.sleep(delay) + await progress.increment() + await progress.set_message(f"bg finishing {name}") + await progress.increment() + return f"bg:{name}" + + 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) + ] + results = await asyncio.gather(*[t.result() for t in task_handles]) + + assert len(results) == 4 + for result in results: + assert result.content[0].text.startswith("bg:") + + +async def test_dependency_aenter_returns_fresh_instances(): + """Verify that Dependency.__aenter__ returns independent per-invocation + objects, not the shared default.""" + mcp = FastMCP("test") + + instances: list[Context] = [] + + @mcp.tool() + async def capture_context(ctx: Context) -> str: + instances.append(ctx) + return "ok" + + 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(): + """Verify that Progress.__aenter__ returns independent per-invocation + objects, not the shared default.""" + progress_instances: list[Progress] = [] + + mcp = FastMCP("test") + + @mcp.tool() + async def capture_progress(progress: Progress = Progress()) -> str: + progress_instances.append(progress) + await progress.set_total(1) + await progress.increment() + return "ok" + + 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] + assert progress_instances[0]._impl is not progress_instances[1]._impl + + +async def test_sync_context_functions_work_in_background_without_deps(): + """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 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.tool(task=True) + async def bare_sync_access() -> dict[str, str]: + headers = get_http_headers() + return {"has_headers": str(bool(headers))} + + 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 functions work via ContextVar when _CurrentContext loads the snapshot.""" + mcp = FastMCP("test") + + @mcp.tool(task=True) + async def context_sync_access(ctx: Context) -> dict[str, str]: + headers = get_http_headers() + token = get_access_token() + return { + "has_headers": str(bool(headers)), + "has_token": str(token is not None), + "is_background": str(ctx.is_background_task), + } + + 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 index 8b2a92889..4b7f66583 100644 --- a/tests/server/tasks/test_context_background_task.py +++ b/tests/server/tasks/test_context_background_task.py @@ -6,10 +6,16 @@ no mocking of Redis, Docket, or session internals. """ import asyncio +import json +from datetime import datetime, timezone from typing import cast +from unittest.mock import 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 pydantic import BaseModel from fastmcp import FastMCP from fastmcp.client import Client @@ -18,8 +24,21 @@ 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, DeclinedElicitation +from fastmcp.server.elicitation import ( + AcceptedElicitation, + CancelledElicitation, + DeclinedElicitation, +) +from fastmcp.server.tasks.context import ( + TaskContextInfo, + TaskContextSnapshot, + _set_cached_snapshot, + get_task_scope, +) 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) @@ -119,10 +138,6 @@ class TestElicitFailFast: This test patches ONLY push_notification — all other components (Docket, Redis, session) are real via the memory:// backend. """ - from unittest.mock import patch - - from fastmcp.server.elicitation import CancelledElicitation - mcp = FastMCP("failfast-test") elicit_started = asyncio.Event() captured: dict[str, object] = {} @@ -250,16 +265,17 @@ class TestBackgroundTaskIntegration: assert isinstance(origin, str) assert origin != "" - key = docket.key( - f"fastmcp:task:{ctx.session_id}:{ctx.task_id}:origin_request_id" - ) + # 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() - assert str(raw) == origin + snapshot = json.loads(raw) + assert snapshot["origin_request_id"] == origin return "ok" async with Client(mcp) as client: @@ -311,7 +327,6 @@ class TestBackgroundTaskIntegration: async def test_elicit_with_pydantic_model(self): """E2E: tool elicits structured Pydantic input via elicitation_handler.""" - from pydantic import BaseModel class UserInfo(BaseModel): name: str @@ -351,7 +366,7 @@ class TestBackgroundTaskIntegration: # Task already completed — no elicitation waiting success = await handle_task_input( task_id=task.task_id, - session_id="nonexistent-session", + task_scope="nonexistent-scope", action="accept", content={"value": "too late"}, fastmcp=mcp, @@ -371,9 +386,6 @@ class TestAccessTokenInBackgroundTasks: async def test_token_round_trips_through_background_task(self): """E2E: token set at submit time is available inside the worker.""" - from mcp.server.auth.middleware.auth_context import auth_context_var - from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser - mcp = FastMCP("token-roundtrip") @mcp.tool(task=True) @@ -412,49 +424,60 @@ class TestAccessTokenInBackgroundTasks: async def test_expired_token_returns_none(self): """get_access_token() returns None when task token has expired.""" - from datetime import datetime, timezone - - from fastmcp.server.dependencies import _task_access_token - expired = AccessToken( token="expired-jwt", client_id="test-client", scopes=["read"], expires_at=int(datetime.now(timezone.utc).timestamp()) - 3600, ) - _task_access_token.set(expired) - assert get_access_token() is None + _set_cached_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.tasks.context.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.""" - from datetime import datetime, timezone - - from fastmcp.server.dependencies import _task_access_token - valid = AccessToken( token="valid-jwt", client_id="test-client", scopes=["read"], expires_at=int(datetime.now(timezone.utc).timestamp()) + 3600, ) - _task_access_token.set(valid) - result = get_access_token() - assert result is not None - assert result.token == "valid-jwt" + _set_cached_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.tasks.context.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.""" - from fastmcp.server.dependencies import _task_access_token - no_expiry = AccessToken( token="eternal-jwt", client_id="test-client", scopes=["read"], ) - _task_access_token.set(no_expiry) - result = get_access_token() - assert result is not None - assert result.token == "eternal-jwt" + _set_cached_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.tasks.context.get_task_context", return_value=fake_ctx + ): + result = get_access_token() + assert result is not None + assert result.token == "eternal-jwt" class TestLifespanContextInBackgroundTasks: diff --git a/tests/server/tasks/test_task_capabilities.py b/tests/server/tasks/test_task_capabilities.py index 146d80d41..b8b432a7f 100644 --- a/tests/server/tasks/test_task_capabilities.py +++ b/tests/server/tasks/test_task_capabilities.py @@ -42,3 +42,28 @@ async def test_client_uses_task_capable_session(): 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_dependencies.py b/tests/server/tasks/test_task_dependencies.py index 7669f150c..129f0908d 100644 --- a/tests/server/tasks/test_task_dependencies.py +++ b/tests/server/tasks/test_task_dependencies.py @@ -13,6 +13,7 @@ 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 @@ -257,8 +258,6 @@ async def test_dependency_errors_propagate_to_task_failure(): ) -> str: return f"Got: {dep}" - from fastmcp.exceptions import ToolError - async with Client(mcp) as client: task = await client.call_tool( "tool_with_failing_dep", {"value": "test"}, task=True diff --git a/tests/server/tasks/test_task_keys.py b/tests/server/tasks/test_task_keys.py new file mode 100644 index 000000000..06a64f8f1 --- /dev/null +++ b/tests/server/tasks/test_task_keys.py @@ -0,0 +1,170 @@ +"""Tests for ``fastmcp.server.tasks.keys`` — the encoding boundary that +separates authenticated and anonymous task keyspaces. + +Cross-scope isolation depends on these encodings being unambiguous and +round-trippable, so the tests cover: tag dispatch (``auth``/``anon``), +the ``None`` ⇄ anonymous round trip, encoding of values that contain the +``:`` delimiter, error paths for malformed keys, and the parity between +the Docket-key prefix and the Redis-key prefix. +""" + +import pytest + +from fastmcp.server.tasks.keys import ( + build_task_key, + get_client_task_id_from_key, + parse_task_key, + task_redis_prefix, +) + +ROUND_TRIP_CASES = [ + ("client-a", "task-1", "tool", "my_tool"), + (None, "task-1", "tool", "my_tool"), + ("client-a", "task-1", "resource", "file://data.txt"), + (None, "task-1", "resource", "file://data.txt"), + ("client-a", "task-1", "template", "users://{id}"), + ("client-a", "task-1", "prompt", "greet@1.0.0"), + # Scope contains the inner separator used by get_task_scope (client_id|sub). + ("client|sub-42", "task-1", "tool", "my_tool"), + # Adversarial: scope is literally the anon tag — must not collide. + ("anon", "task-1", "tool", "my_tool"), + # Adversarial: scope is literally the legacy "_" sentinel. + ("_", "task-1", "tool", "my_tool"), + # Scope contains every delimiter we care about. + ("a:b/c d%e|f", "task-1", "tool", "my_tool"), + # Component identifier with colons, slashes, percent, spaces. + ("client-a", "task-1", "resource", "https://x/y?z=1&q=a b"), + # UUID-shaped task id (the realistic case). + ("client-a", "0c3e9b14-3a3f-4b3a-9b1a-1d8d6e6e0c11", "tool", "t"), +] + + +@pytest.mark.parametrize( + ("scope", "task_id", "task_type", "identifier"), ROUND_TRIP_CASES +) +def test_round_trip_preserves_all_fields( + scope: str | None, task_id: str, task_type: str, identifier: str +): + key = build_task_key(scope, task_id, task_type, identifier) + parsed = parse_task_key(key) + assert parsed == { + "task_scope": scope, + "client_task_id": task_id, + "task_type": task_type, + "component_identifier": identifier, + } + + +@pytest.mark.parametrize( + ("scope", "task_id", "task_type", "identifier"), ROUND_TRIP_CASES +) +def test_get_client_task_id_round_trip( + scope: str | None, task_id: str, task_type: str, identifier: str +): + key = build_task_key(scope, task_id, task_type, identifier) + assert get_client_task_id_from_key(key) == task_id + + +def test_authenticated_key_uses_auth_tag(): + key = build_task_key("client-a", "task-1", "tool", "my_tool") + assert key.startswith("auth:") + assert key == "auth:client-a:task-1:tool:my_tool" + + +def test_anonymous_key_uses_anon_tag(): + key = build_task_key(None, "task-1", "tool", "my_tool") + assert key.startswith("anon:") + assert key == "anon:task-1:tool:my_tool" + + +def test_anonymous_and_literal_anon_scope_have_disjoint_keyspaces(): + """A real anonymous task and a (hostile) authenticated task whose scope + literally equals "anon" must not collide.""" + anon_key = build_task_key(None, "task-1", "tool", "x") + impostor_key = build_task_key("anon", "task-1", "tool", "x") + assert anon_key != impostor_key + assert parse_task_key(anon_key)["task_scope"] is None + assert parse_task_key(impostor_key)["task_scope"] == "anon" + + +def test_legacy_underscore_scope_is_just_a_string_now(): + """Belt-and-suspenders: a client_id of "_" no longer aliases anonymous.""" + underscore_key = build_task_key("_", "task-1", "tool", "x") + anon_key = build_task_key(None, "task-1", "tool", "x") + assert underscore_key != anon_key + assert parse_task_key(underscore_key)["task_scope"] == "_" + + +def test_component_identifier_with_colons_is_recovered(): + key = build_task_key("client-a", "task-1", "resource", "file://data:special.txt") + assert parse_task_key(key)["component_identifier"] == "file://data:special.txt" + + +def test_scope_with_colons_is_recovered(): + key = build_task_key("a:b:c", "task-1", "tool", "t") + parsed = parse_task_key(key) + assert parsed["task_scope"] == "a:b:c" + assert parsed["client_task_id"] == "task-1" + + +def test_scope_pipe_separator_is_preserved(): + """``get_task_scope`` composes ``client_id|sub`` — the ``|`` must survive.""" + key = build_task_key("client-a|user-42", "task-1", "tool", "t") + assert parse_task_key(key)["task_scope"] == "client-a|user-42" + + +@pytest.mark.parametrize( + "bad_key", + [ + "", + "client-a:task-1:tool:my_tool", # legacy untagged format + "weird:client-a:task-1:tool:my_tool", # unknown tag + "auth:client-a:task-1:tool", # missing identifier + "auth:client-a", # truncated + "anon:task-1:tool", # truncated anon + "anon", # tag only + "auth", # tag only + ":task-1:tool:t", # empty tag + ], +) +def test_parse_rejects_malformed_keys(bad_key: str): + with pytest.raises(ValueError): + parse_task_key(bad_key) + + +def test_redis_prefix_authenticated(): + assert task_redis_prefix("client-a") == "fastmcp:task:auth:client-a" + + +def test_redis_prefix_anonymous(): + assert task_redis_prefix(None) == "fastmcp:task:anon" + + +def test_redis_prefix_disjoint_for_anon_vs_literal_anon_scope(): + assert task_redis_prefix(None) != task_redis_prefix("anon") + + +def test_redis_prefix_disjoint_for_anon_vs_literal_underscore_scope(): + assert task_redis_prefix(None) != task_redis_prefix("_") + + +def test_redis_prefix_encodes_special_characters(): + # Colons, slashes, pipes in the scope must not break the prefix shape. + prefix = task_redis_prefix("client:a/b|sub") + assert prefix.startswith("fastmcp:task:auth:") + # Exactly four ":" delimiters: fastmcp / task / auth / encoded-scope. + assert prefix.count(":") == 3 + + +def test_docket_and_redis_prefixes_agree_on_partition(): + """The Docket key tag and the Redis prefix tag must always match — that is + the load-bearing invariant for cross-scope isolation.""" + auth_docket = build_task_key("client-a", "task-1", "tool", "x") + auth_redis = task_redis_prefix("client-a") + assert auth_docket.split(":", 1)[0] == "auth" + assert ":auth:" in auth_redis + + anon_docket = build_task_key(None, "task-1", "tool", "x") + anon_redis = task_redis_prefix(None) + assert anon_docket.split(":", 1)[0] == "anon" + assert anon_redis.endswith(":anon") diff --git a/tests/server/tasks/test_task_mount.py b/tests/server/tasks/test_task_mount.py index 1b1558579..b00653ca3 100644 --- a/tests/server/tasks/test_task_mount.py +++ b/tests/server/tasks/test_task_mount.py @@ -10,6 +10,8 @@ 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 @@ -17,6 +19,7 @@ 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 @@ -571,6 +574,21 @@ class TestMountedTaskMetadata: assert child_mcp_tool.execution.taskSupport == "optional" assert parent_mcp_tool.execution.taskSupport == "optional" + async def test_proxy_tool_preserves_execution_metadata(self): + """ProxyTool.from_mcp_tool should propagate execution.taskSupport (#3569).""" + mcp_tool = MCPTool( + name="remote_task_tool", + description="A remote tool that supports tasks", + inputSchema={"type": "object", "properties": {}}, + execution=ToolExecution(taskSupport="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.taskSupport == "optional" + class TestMountedTaskConfigModes: """Test TaskConfig mode enforcement for mounted tools.""" diff --git a/tests/server/tasks/test_task_return_types.py b/tests/server/tasks/test_task_return_types.py index 3ef352902..967d41ee6 100644 --- a/tests/server/tasks/test_task_return_types.py +++ b/tests/server/tasks/test_task_return_types.py @@ -258,8 +258,10 @@ async def binary_type_server(): [ ( "return_bytes", - str, - lambda r: "Hello bytes!" in r.data or "SGVsbG8gYnl0ZXMh" in r.data, + type(None), + lambda r: ( + r.data is None and any("Hello bytes!" in c.text for c in r.content) + ), ), ( "return_uuid", diff --git a/tests/server/tasks/test_task_security.py b/tests/server/tasks/test_task_security.py index 88af3aa7d..5d3b16ffa 100644 --- a/tests/server/tasks/test_task_security.py +++ b/tests/server/tasks/test_task_security.py @@ -1,22 +1,27 @@ """ -Tests for session-based task ID isolation (CRITICAL SECURITY). +Tests for authorization-based task isolation (CRITICAL SECURITY). -Ensures that tasks are properly scoped to sessions and clients cannot -access each other's tasks. +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 -async def task_server(): +def task_server(): """Create a server with background tasks enabled.""" mcp = FastMCP("security-test-server") - @mcp.tool(task=True) # Enable background execution + @mcp.tool(task=True) async def secret_tool(data: str) -> str: """A tool that processes sensitive data.""" return f"Secret result: {data}" @@ -24,24 +29,121 @@ async def task_server(): return mcp -async def test_same_session_can_access_all_its_tasks(task_server): - """A single session can access all tasks it created.""" +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: - # Submit multiple tasks - 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" + 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) - # Wait for both to complete - await task1.wait(timeout=2.0) - await task2.wait(timeout=2.0) - # Should be able to access both - result1 = await task1.result() - result2 = await task2.result() +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)) - assert "first" in str(result1.data) - assert "second" in str(result2.data) + +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/test_dependencies.py b/tests/server/test_dependencies.py index 9a4af015b..37e5aa537 100644 --- a/tests/server/test_dependencies.py +++ b/tests/server/test_dependencies.py @@ -757,12 +757,109 @@ class TestDependencyInjection: assert is_docket_available() is True + def test_is_docket_available_false_when_pydocket_too_old(self, monkeypatch): + """``is_docket_available()`` must treat pre-0.19.0 pydocket as unavailable. + + Older pydocket versions (e.g. 0.16.x, pulled in transitively by + packages like prefect) import cleanly but lack the APIs fastmcp + uses (``docket.dependencies.current_execution``, etc.). Without a + version floor, the check would report available and then crash at + runtime. Simulate by forcing ``importlib.metadata`` to report an + old version. + """ + 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 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.""" + import importlib.metadata + + from fastmcp.server import dependencies + + original_version = importlib.metadata.version + + def fake_version(name: str) -> str: + if name == "pydocket": + raise importlib.metadata.PackageNotFoundError(name) + return original_version(name) + + monkeypatch.setattr(dependencies, "_DOCKET_AVAILABLE", None) + monkeypatch.setattr(importlib.metadata, "version", fake_version) + + assert dependencies.is_docket_available() is False + + def test_is_docket_available_false_when_import_broken(self, monkeypatch): + """Metadata says installed but ``import docket`` fails — treat as unavailable. + + Catches the broken/partial-install case where ``importlib.metadata`` + still reports a usable version but the package itself isn't actually + importable (corrupted wheel, sys.path weirdness, etc.). Without the + import probe, fastmcp would later crash on its first ``from docket + ...`` instead of falling back gracefully. + """ + import builtins + + from fastmcp.server import dependencies + + original_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "docket" or name.startswith("docket."): + raise ImportError("simulated broken docket install") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(dependencies, "_DOCKET_AVAILABLE", None) + monkeypatch.setattr(builtins, "__import__", fake_import) + + assert dependencies.is_docket_available() is False + def test_require_docket_passes_when_installed(self): """Test require_docket doesn't raise when docket is installed.""" from fastmcp.server.dependencies import require_docket require_docket("test feature") + def test_require_docket_error_mentions_version_when_too_old(self, monkeypatch): + """``require_docket()`` distinguishes "missing" from "too old". + + When pydocket is installed but pinned below the floor, the install + instructions in the error must point at upgrading pydocket — not at + installing the ``tasks`` extra (which the resolver will treat as a + no-op as long as the lower pin is held by another package). + """ + 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) + + with pytest.raises(ImportError, match="pydocket 0.16.6 is installed"): + dependencies.require_docket("CurrentDocket()") + def test_dependency_class_exists(self): """Test Dependency and Depends are importable from fastmcp.""" from fastmcp.dependencies import Dependency, Depends diff --git a/tests/server/test_fastapi_testclient_compat.py b/tests/server/test_fastapi_testclient_compat.py new file mode 100644 index 000000000..b2f55cc76 --- /dev/null +++ b/tests/server/test_fastapi_testclient_compat.py @@ -0,0 +1,57 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fastmcp import FastMCP + + +def test_fastapi_testclient_multiple_runs(): + """Test that TestClient can be used multiple times with a mounted FastMCP app. + + This verifies that the StreamableHTTPSessionManager is correctly recreated + for each lifespan cycle. + """ + mcp = FastMCP("test") + mcp_app = mcp.http_app(path="/mcp") + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + @asynccontextmanager + async def combined_lifespan(app: FastAPI): + # Trigger the sub-app's lifespan + async with mcp_app.router.lifespan_context(mcp_app): + yield + + app = FastAPI(lifespan=combined_lifespan) + app.mount("/analytics", mcp_app) + + # First test run + with TestClient(app) as client: + # We use analytics prefix since it's mounted there + client.get("/analytics/mcp") # Would raise RuntimeError before fix + + # Second test run - this would fail before the fix + with TestClient(app) as client: + client.get("/analytics/mcp") + + +def test_fastapi_testclient_nested_lifespan(): + """Test that TestClient works with custom combined lifespans and multiple iterations.""" + mcp = FastMCP("test") + mcp_app = mcp.http_app(path="/mcp") + + @asynccontextmanager + async def combined_lifespan(app: FastAPI): + async with mcp_app.router.lifespan_context(mcp_app): + yield + + app = FastAPI(lifespan=combined_lifespan) + app.mount("/analytics", mcp_app) + + # Multiple runs with custom lifespan + for _ in range(3): + with TestClient(app) as client: + client.get("/analytics/mcp") diff --git a/tests/server/test_input_validation.py b/tests/server/test_input_validation.py index f986f3769..60456d0c3 100644 --- a/tests/server/test_input_validation.py +++ b/tests/server/test_input_validation.py @@ -13,6 +13,7 @@ from mcp.types import TextContent from pydantic import BaseModel from fastmcp import Client, FastMCP +from fastmcp.exceptions import ToolError class UserProfile(BaseModel): @@ -130,8 +131,13 @@ class TestPydanticModelArguments: assert "Alice" in result.content[0].text assert "30" in result.content[0].text - async def test_pydantic_model_with_stringified_json_no_strict(self): - """Test if stringified JSON is accepted for Pydantic models without strict validation.""" + async def test_stringified_json_not_auto_parsed_for_pydantic_models(self): + """Stringified JSON is rejected for Pydantic model parameters. + + Some LLM clients send stringified JSON (a JSON string containing a + JSON object) instead of a proper JSON object. FastMCP does not + auto-parse these; callers get a validation error. + """ mcp = FastMCP("TestServer", strict_input_validation=False) @mcp.tool @@ -140,32 +146,12 @@ class TestPydanticModelArguments: return f"Created user {profile.name}, age {profile.age}" async with Client(mcp) as client: - # Some LLM clients send stringified JSON instead of actual JSON stringified = json.dumps( {"name": "Bob", "age": 25, "email": "bob@example.com"} ) - # This test verifies whether we handle stringified JSON - try: - result = await client.call_tool("create_user", {"profile": stringified}) - # If this succeeds, we're handling stringified JSON - assert isinstance(result.content[0], TextContent) - assert "Bob" in result.content[0].text - stringified_json_works = True - except Exception as e: - # If this fails, we're not handling stringified JSON - stringified_json_works = False - error_msg = str(e) - - # Document the behavior - we want to know if this works or not - if stringified_json_works: - # This is the desired behavior - pass - else: - # This means stringified JSON doesn't work - document it - assert ( - "validation" in error_msg.lower() or "invalid" in error_msg.lower() - ) + with pytest.raises(ToolError, match="validation"): + await client.call_tool("create_user", {"profile": stringified}) async def test_pydantic_model_with_coercion(self): """Pydantic models should benefit from coercion without strict validation.""" diff --git a/tests/server/test_providers.py b/tests/server/test_providers.py index b62df314a..6f53d4bde 100644 --- a/tests/server/test_providers.py +++ b/tests/server/test_providers.py @@ -211,16 +211,19 @@ class TestProvider: async def test_call_tool_uses_get_tool_for_efficient_lookup( self, base_server: FastMCP, dynamic_tools: list[Tool] ): - """Test that call_tool uses get_tool() for efficient single-tool lookup.""" + """Test that call_tool uses get_tool() (not list_tools) for lookup.""" provider = SimpleToolProvider(tools=dynamic_tools) base_server.add_provider(provider) await base_server.call_tool(name="dynamic_multiply", arguments={"a": 2, "b": 3}) - # get_tool is called once for efficient lookup: - # call_tool() calls provider.get_tool() to get the tool and execute it - # Key point: list_tools is NOT called during tool execution (efficient lookup) - assert provider.get_tool_call_count == 1 + # get_tool may be called more than once — once for the initial + # resolution and once again by the dispatcher's reverse-lookup to + # find the tool's owning provider for Context.mount_path. Both + # are bounded by provider count, much cheaper than list_tools. + # The key invariant: list_tools is NOT called during dispatch. + assert provider.get_tool_call_count >= 1 + assert provider.list_tools_call_count == 0 async def test_default_get_tool_falls_back_to_list(self, base_server: FastMCP): """Test that BaseToolProvider's default get_tool calls list_tools.""" diff --git a/tests/server/test_run_server.py b/tests/server/test_run_server.py deleted file mode 100644 index 65e3112ac..000000000 --- a/tests/server/test_run_server.py +++ /dev/null @@ -1,98 +0,0 @@ -# from pathlib import Path -# from typing import TYPE_CHECKING, Any - -# import pytest - -# import fastmcp -# from fastmcp import FastMCP - -# if TYPE_CHECKING: -# pass - -# USERS = [ -# {"id": "1", "name": "Alice", "active": True}, -# {"id": "2", "name": "Bob", "active": True}, -# {"id": "3", "name": "Charlie", "active": False}, -# ] - - -# @pytest.fixture -# def fastmcp_server(): -# server = FastMCP("TestServer") - -# # --- Tools --- - -# @server.tool -# def greet(name: str) -> str: -# """Greet someone by name.""" -# return f"Hello, {name}!" - -# @server.tool -# def add(a: int, b: int) -> int: -# """Add two numbers together.""" -# return a + b - -# @server.tool -# def error_tool(): -# """This tool always raises an error.""" -# raise ValueError("This is a test error") - -# # --- Resources --- - -# @server.resource(uri="resource://wave") -# def wave() -> str: -# return "👋" - -# @server.resource(uri="data://users") -# async def get_users() -> list[dict[str, Any]]: -# return USERS - -# @server.resource(uri="data://user/{user_id}") -# async def get_user(user_id: str) -> dict[str, Any] | None: -# return next((user for user in USERS if user["id"] == user_id), None) - -# # --- Prompts --- - -# @server.prompt -# def welcome(name: str) -> str: -# return f"Welcome to FastMCP, {name}!" - -# return server - - -# @pytest.fixture -# async def stdio_client(): -# # Find the stdio.py script path -# base_dir = Path(__file__).parent -# stdio_script = base_dir / "test_servers" / "stdio.py" - -# if not stdio_script.exists(): -# raise FileNotFoundError(f"Could not find stdio.py script at {stdio_script}") - -# client = fastmcp.Client( -# transport=fastmcp.client.transports.StdioTransport( -# command="python", -# args=[str(stdio_script)], -# ) -# ) - -# async with client: -# print("READY") -# yield client -# print("DONE") - - -# class TestRunServerStdio: -# async def test_run_server_stdio( -# self, fastmcp_server: FastMCP, stdio_client: fastmcp.Client -# ): -# print("TEST") -# tools = await stdio_client.list_tools() -# print("TEST 2") -# assert tools == 1 - - -# class TestRunServerSSE: -# -# async def test_run_server_sse(self, fastmcp_server: FastMCP): -# pass diff --git a/tests/test_apps.py b/tests/test_apps.py index 125772897..ba2765805 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -585,7 +585,8 @@ class TestPrefabAppConfig: assert config.csp is not None assert config.csp.frame_domains == ["https://example.com"] - async def test_auto_registers_renderer_resource(self): + async def test_auto_synthesizes_renderer_resource(self): + """Each prefab tool gets a per-tool renderer resource on demand.""" from fastmcp.apps import PrefabAppConfig server = FastMCP("test") @@ -595,11 +596,11 @@ class TestPrefabAppConfig: return "hello" resources = list(await server.list_resources()) - uris = [str(r.uri) for r in resources] - assert any("ui://prefab/renderer.html" in u for u in uris) + prefab = [r for r in resources if "prefab/tool" in str(r.uri)] + assert len(prefab) == 1 async def test_equivalent_to_app_true(self): - """PrefabAppConfig() should produce the same tool metadata as app=True.""" + """PrefabAppConfig() and app=True both synthesize a per-tool renderer.""" from fastmcp.apps import PrefabAppConfig server1 = FastMCP("test1") @@ -621,4 +622,6 @@ class TestPrefabAppConfig: assert tools2[0].meta is not None ui2 = tools2[0].meta.get("ui", {}) - assert ui1.get("resourceUri") == ui2.get("resourceUri") + # Both produce per-tool URIs in the prefab/tool// form. + assert ui1.get("resourceUri", "").startswith("ui://prefab/tool/") + assert ui2.get("resourceUri", "").startswith("ui://prefab/tool/") diff --git a/tests/test_apps_prefab.py b/tests/test_apps_prefab.py index 8db9a2a97..5f19e1982 100644 --- a/tests/test_apps_prefab.py +++ b/tests/test_apps_prefab.py @@ -119,42 +119,49 @@ class TestAppTrue: assert "ui" in tool.meta assert tool.meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI - def test_app_true_registers_renderer_resource(self): + async def test_app_true_synthesizes_renderer_resource(self): + """Each prefab tool gets a per-tool renderer resource synthesized + on demand at list_resources time. Resources don't live on any + provider's storage — they're computed from the registry walk.""" mcp = FastMCP("test") @mcp.tool(app=True) def my_tool() -> str: return "hello" - renderer_key = f"resource:{PREFAB_RENDERER_URI}@" - assert renderer_key in mcp._local_provider._components + resources = list(await mcp.list_resources()) + prefab_resources = [r for r in resources if "prefab/tool" in str(r.uri)] + assert len(prefab_resources) == 1 + assert "renderer.html" in str(prefab_resources[0].uri) - def test_renderer_resource_has_correct_mime_type(self): + async def test_renderer_resource_has_correct_mime_type(self): mcp = FastMCP("test") @mcp.tool(app=True) def my_tool() -> str: return "hello" - renderer_key = f"resource:{PREFAB_RENDERER_URI}@" - resource = mcp._local_provider._components[renderer_key] - assert isinstance(resource, TextResource) - assert resource.mime_type == UI_MIME_TYPE + resources = list(await mcp.list_resources()) + renderer = next(r for r in resources if "prefab/tool" in str(r.uri)) + assert isinstance(renderer, TextResource) + assert renderer.mime_type == UI_MIME_TYPE - def test_renderer_resource_has_csp(self): + async def test_renderer_resource_has_csp(self): mcp = FastMCP("test") @mcp.tool(app=True) def my_tool() -> str: return "hello" - renderer_key = f"resource:{PREFAB_RENDERER_URI}@" - resource = mcp._local_provider._components[renderer_key] - assert resource.meta is not None - assert "ui" in resource.meta - assert "csp" in resource.meta["ui"] + resources = list(await mcp.list_resources()) + renderer = next(r for r in resources if "prefab/tool" in str(r.uri)) + assert renderer.meta is not None + assert "ui" in renderer.meta + assert "csp" in renderer.meta["ui"] - def test_multiple_tools_share_renderer(self): + async def test_multiple_tools_get_dedicated_resources(self): + """Each prefab tool gets its own resource at a distinct hashed + URI — no shared singleton, so per-tool CSP becomes possible.""" mcp = FastMCP("test") @mcp.tool(app=True) @@ -165,10 +172,9 @@ class TestAppTrue: def tool_b() -> str: return "b" - renderer_keys = [ - k for k in mcp._local_provider._components if k.startswith("resource:ui://") - ] - assert len(renderer_keys) == 1 + resources = list(await mcp.list_resources()) + prefab_uris = {str(r.uri) for r in resources if "prefab/tool" in str(r.uri)} + assert len(prefab_uris) == 2 def test_explicit_app_config_not_overridden(self): mcp = FastMCP("test") @@ -421,7 +427,9 @@ class TestIntegration: tool = next(t for t in tools if t.name == "my_tool") meta = tool.meta or {} assert "ui" in meta - assert meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI + # URI is the per-tool hashed form, not the singleton. + assert meta["ui"]["resourceUri"].startswith("ui://prefab/tool/") + assert meta["ui"]["resourceUri"].endswith("/renderer.html") async def test_renderer_resource_readable(self): mcp = FastMCP("test") @@ -431,7 +439,13 @@ class TestIntegration: return "hello" async with Client(mcp) as client: - contents = await client.read_resource(PREFAB_RENDERER_URI) + # Look up the URI by listing first — the hash isn't + # computable from outside without the address registry. + tools = await client.list_tools() + uri = next(t for t in tools if t.name == "my_tool").meta["ui"][ + "resourceUri" + ] + contents = await client.read_resource(uri) assert len(contents) > 0 text = contents[0].text if hasattr(contents[0], "text") else "" diff --git a/tests/test_fastmcp_app.py b/tests/test_fastmcp_app.py index bc02cf8ae..72d693c7e 100644 --- a/tests/test_fastmcp_app.py +++ b/tests/test_fastmcp_app.py @@ -20,7 +20,7 @@ from prefab_ui.components import Text from fastmcp import Client, FastMCP from fastmcp.apps.app import ( FastMCPApp, - _resolve_tool_ref, + _make_resolver, ) from fastmcp.tools.base import Tool @@ -29,6 +29,16 @@ from fastmcp.tools.base import Tool # --------------------------------------------------------------------------- +class TestFastMCPAppInit: + def test_app_name_with_underscores_ok(self): + # The old `___` separator is gone — backend tool routing now uses + # a hashed positional address rather than a name-based prefix, so + # any character is fine inside an app name. + FastMCPApp("my_app") + FastMCPApp("my__app") + FastMCPApp("my___app") + + class TestAppTool: def test_tool_bare_decorator(self): app = FastMCPApp("test") @@ -236,7 +246,8 @@ class TestAppUI: assert meta is not None assert meta["ui"]["resourceUri"] == "ui://prefab/renderer.html" - async def test_ui_has_csp(self): + async def test_ui_tool_has_no_csp(self): + """CSP belongs on the UI resource, not the tool (per MCP Apps spec).""" app = FastMCPApp("test") @app.ui() @@ -246,8 +257,7 @@ class TestAppUI: tools = await app._list_tools() meta = tools[0].meta assert meta is not None - csp = meta["ui"].get("csp") - assert csp is not None + assert "csp" not in meta["ui"] async def test_ui_with_title_and_description(self): app = FastMCPApp("test") @@ -277,35 +287,33 @@ class TestAppUI: class TestResolveToolRef: - def test_resolve_string_passes_through(self): - """Strings pass through as-is — server resolves at call time.""" - result = _resolve_tool_ref("save_contact") + def test_resolve_string_no_context(self): + """Without a running Context the resolver returns bare names.""" + result = _make_resolver()("save_contact") assert isinstance(result, ResolvedTool) assert result.name == "save_contact" - def test_resolve_callable_uses_name(self): + def test_resolve_string_with_app_name(self): + """With an app name the resolver produces a hashed backend name.""" + from fastmcp.server.providers.addressing import hashed_backend_name + + result = _make_resolver("contacts")("save_contact") + assert isinstance(result, ResolvedTool) + assert result.name == hashed_backend_name("contacts", "save_contact") + + def test_resolve_callable_no_context(self): + """Without context, callables resolve to their bare __name__.""" + def my_tool(): pass - result = _resolve_tool_ref(my_tool) + result = _make_resolver()(my_tool) assert isinstance(result, ResolvedTool) assert result.name == "my_tool" - def test_resolve_fastmcp_metadata(self): - from fastmcp.tools.function_tool import ToolMeta - - def my_tool(): - pass - - my_tool.__fastmcp__ = ToolMeta(name="custom_name") # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - - result = _resolve_tool_ref(my_tool) - assert isinstance(result, ResolvedTool) - assert result.name == "custom_name" - def test_resolve_unresolvable_raises(self): with pytest.raises(ValueError): - _resolve_tool_ref(42) + _make_resolver()(42) # --------------------------------------------------------------------------- @@ -447,8 +455,12 @@ class TestProviderInterface: class TestCallToolAppRouting: - async def test_call_tool_with_app_name(self): - """Server.call_tool routes via get_app_tool when app_name is set.""" + async def test_call_tool_with_hashed_name(self): + """A backend tool with visibility=['app'] is callable via its + hashed-name address — the same form a Prefab UI's resolver would + produce when serializing a peer reference.""" + from fastmcp.server.providers.addressing import hashed_backend_name + app = FastMCPApp("contacts") @app.tool() @@ -458,11 +470,13 @@ class TestCallToolAppRouting: server = FastMCP("Platform") server.add_provider(app) - result = await server.call_tool("save", {"name": "alice"}, app_name="contacts") + 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_call_tool_without_app_name_model_visible(self): - """Regular name-based resolution works for model-visible tools.""" + async def test_call_tool_model_visible_uses_display_name(self): + """Tools with visibility=['app','model'] are callable by display name.""" app = FastMCPApp("test") @app.tool(model=True) @@ -475,8 +489,12 @@ class TestCallToolAppRouting: result = await server.call_tool("save", {"name": "bob"}) assert result.content[0].text == "saved bob" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - async def test_app_name_survives_namespace(self): - """app_name routing bypasses namespace transforms.""" + async def test_hashed_name_survives_namespace_mount(self): + """The hashed-name path bypasses display-layer transforms entirely. + A FastMCPApp mounted under a Namespace transform still has its + backend tools reachable via the same hash.""" + from fastmcp.server.providers.addressing import hashed_backend_name + app = FastMCPApp("crm") @app.tool() @@ -487,12 +505,12 @@ class TestCallToolAppRouting: server.add_provider(app, namespace="crm") result = await server.call_tool( - "save_contact", {"name": "alice"}, app_name="crm" + hashed_backend_name("crm", "save_contact"), {"name": "alice"} ) assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - async def test_namespaced_name_also_works(self): - """Namespaced tool name works through normal resolution.""" + async def test_namespaced_display_name_also_works(self): + """Model-visible tools still resolve through Namespace as before.""" app = FastMCPApp("crm") @app.tool(model=True) @@ -505,10 +523,11 @@ class TestCallToolAppRouting: result = await server.call_tool("crm_save_contact", {"name": "bob"}) assert result.content[0].text == "saved bob" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - async def test_app_name_auth_blocks_unauthorized(self): - """Auth checks run even when routing via app_name.""" + async def test_hashed_name_auth_blocks_unauthorized(self): + """Auth checks run on the hashed-name dispatch path too.""" from fastmcp.exceptions import NotFoundError from fastmcp.server.context import _current_transport + from fastmcp.server.providers.addressing import hashed_backend_name app = FastMCPApp("test") deny_all = AsyncMock(return_value=False) @@ -523,12 +542,16 @@ class TestCallToolAppRouting: token = _current_transport.set("streamable-http") try: with pytest.raises(NotFoundError): - await server.call_tool("secret", {}, app_name="test") + await server.call_tool(hashed_backend_name("test", "secret"), {}) finally: _current_transport.reset(token) - async def test_two_apps_same_tool_name_routed_correctly(self): - """Two apps with same tool name disambiguated by app_name.""" + async def test_two_apps_same_tool_name_routed_by_address(self): + """Two FastMCPApps each with a `save` tool live at distinct + addresses, so they hash differently and the dispatcher routes + each call to the right app without name collisions.""" + from fastmcp.server.providers.addressing import hashed_backend_name + contacts = FastMCPApp("contacts") billing = FastMCPApp("billing") @@ -541,34 +564,19 @@ class TestCallToolAppRouting: return f"invoice: {amount}" server = FastMCP("Platform") - server.add_provider(contacts) - server.add_provider(billing) + server.add_provider(contacts) # → address (0,) + server.add_provider(billing) # → address (1,) - r1 = await server.call_tool("save", {"name": "alice"}, app_name="contacts") - r2 = await server.call_tool("save", {"amount": "100"}, app_name="billing") + r1 = await server.call_tool( + hashed_backend_name("contacts", "save"), {"name": "alice"} + ) + r2 = await server.call_tool( + hashed_backend_name("billing", "save"), {"amount": "100"} + ) assert r1.content[0].text == "contact: alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] assert r2.content[0].text == "invoice: 100" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - async def test_deeply_nested_app(self): - """App tool is found even through multiple levels of nesting.""" - app = FastMCPApp("deep") - - @app.tool() - def hidden(x: str) -> str: - return x - - inner = FastMCP("Inner") - inner.add_provider(app, namespace="app") - - outer = FastMCP("Outer") - outer.mount(inner, namespace="inner") - - # Normal resolution: would need "inner_app_hidden" - # App routing: bypasses all transforms - result = await outer.call_tool("hidden", {"x": "found"}, app_name="deep") - assert result.content[0].text == "found" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - # --------------------------------------------------------------------------- # App-only tool filtering from server list_tools / get_tool @@ -637,8 +645,12 @@ class TestAppOnlyToolFiltering: names = [t.name for t in tools] assert "save" not in names - # But still callable via app_name routing - result = await server.call_tool("save", {"name": "alice"}, app_name="contacts") + # But still callable via the hashed-address routing path. + from fastmcp.server.providers.addressing import hashed_backend_name + + result = await server.call_tool( + hashed_backend_name("contacts", "save"), {"name": "alice"} + ) assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] async def test_app_only_tool_hidden_from_get_tool(self): @@ -789,9 +801,14 @@ class TestComposition: server.add_provider(crm, namespace="crm") server.add_provider(billing, namespace="billing") - r1 = await server.call_tool("save_contact", {"name": "alice"}, app_name="CRM") + from fastmcp.server.providers.addressing import hashed_backend_name + + # CRM is at address (0,), billing at (1,) — registration order. + r1 = await server.call_tool( + hashed_backend_name("CRM", "save_contact"), {"name": "alice"} + ) r2 = await server.call_tool( - "create_invoice", {"amount": 100}, app_name="Billing" + hashed_backend_name("Billing", "create_invoice"), {"amount": 100} ) assert r1.content[0].text == "alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] @@ -813,16 +830,21 @@ class TestComposition: names = {t.name for t in tools} assert names == {"dashboard", "save"} - async def test_ui_registers_prefab_renderer_resource(self): + async def test_ui_synthesizes_per_tool_renderer_resource(self): + """Each @app.ui() tool gets its own renderer resource synthesized + on demand from the server's address registry.""" app = FastMCPApp("test") @app.ui() def dashboard() -> str: return "ui" - resources = await app._list_resources() - uris = [str(r.uri) for r in resources] - assert any("ui://prefab/renderer.html" in uri for uri in uris) + server = FastMCP("Platform") + server.add_provider(app) + + resources = list(await server.list_resources()) + prefab = [r for r in resources if "prefab/tool" in str(r.uri)] + assert len(prefab) == 1 # --------------------------------------------------------------------------- @@ -833,8 +855,10 @@ class TestComposition: class TestAppIntegration: async def test_full_app_lifecycle_through_client(self): """End-to-end: mount an app on a namespaced server, call UI tool - through a client (verifying structured_content contains _meta.fastmcp.app), - then call the backend tool via server.call_tool with app_name.""" + through a client (verifying structured_content is returned), then + call the backend tool via its hashed-address name.""" + from fastmcp.server.providers.addressing import hashed_backend_name + app = FastMCPApp("contacts") @app.ui() @@ -860,15 +884,12 @@ class TestAppIntegration: result = await client.call_tool_mcp("crm_contact_form", {}) sc = result.structuredContent assert sc is not None - assert "_meta" in sc - assert sc["_meta"]["fastmcp"]["app"] == "contacts" - # Call the backend tool via server.call_tool with app_name - # (bypasses namespace transforms and visibility filtering) + # 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( - "save_contact", + hashed_backend_name("contacts", "save_contact"), {"name": "Alice", "email": "alice@example.com"}, - app_name="contacts", ) result_text = backend_result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] assert "Alice" in result_text diff --git a/tests/tools/tool/test_tool.py b/tests/tools/tool/test_tool.py index b9c14a9f3..00bd5d9e4 100644 --- a/tests/tools/tool/test_tool.py +++ b/tests/tools/tool/test_tool.py @@ -279,7 +279,7 @@ class TestToolFromFunction: "tags": set(), "parameters": { "additionalProperties": False, - "properties": {"x": {"title": "X"}}, + "properties": {"x": {}}, "required": ["x"], "type": "object", }, diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py index 810225121..ba5d46555 100644 --- a/tests/tools/tool_transform/test_tool_transform.py +++ b/tests/tools/tool_transform/test_tool_transform.py @@ -618,3 +618,41 @@ class TestProxy: result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2}) assert isinstance(result.content[0], TextContent) assert result.content[0].text == "3" + + +async def test_sync_transform_fn(): + """Sync transform_fn should not crash when called (was unconditionally awaited).""" + + @Tool.from_function + def parent(x: int, y: int = 10) -> int: + return x + y + + def sync_transform(x: int, **kwargs) -> str: + return f"transformed: {x}" + + transformed = Tool.from_tool(parent, transform_fn=sync_transform) + result = await transformed.run(arguments={"x": 7}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "transformed: 7" + + +async def test_transform_args_do_not_mutate_parent_schema(): + """Mutating a transformed tool's schema must not corrupt the parent's schema.""" + + @Tool.from_function + def parent(x: int, y: int = 10) -> int: + return x + y + + parent_props_before = { + k: dict(v) for k, v in parent.parameters["properties"].items() + } + + transformed = Tool.from_tool( + parent, + transform_args={"x": ArgTransform(name="a")}, + ) + + transformed.parameters["properties"]["a"]["description"] = "INJECTED" + + parent_props_after = parent.parameters["properties"] + assert parent_props_after == parent_props_before diff --git a/tests/utilities/json_schema_type/conftest.py b/tests/utilities/json_schema_type/conftest.py new file mode 100644 index 000000000..04e201fac --- /dev/null +++ b/tests/utilities/json_schema_type/conftest.py @@ -0,0 +1,105 @@ +"""Session hooks for the real-world schema crash test. + +Per-provider tests in `test_real_world_schemas.py` run under `pytest-xdist` — +each worker is a separate process, so module-level accumulators don't survive +across workers. Each test persists its counts to `SCHEMA_CRASH_RESULTS_DIR` +and the session-finish hook below reads them back on the xdist master after +all workers finish. + +These hooks must live in conftest.py (not the test module) because pytest +only picks up `pytest_configure` / `pytest_sessionfinish` from conftest files. +""" + +from __future__ import annotations + +import json +import os +import shutil +from pathlib import Path + +import pytest + +SCHEMA_CRASH_RESULTS_DIR = Path( + os.environ.get("SCHEMA_CRASH_RESULTS_DIR", "/tmp/schema_crash_results") +) + + +def pytest_configure(config: pytest.Config) -> None: + """Clear prior per-provider results at session start (xdist master only).""" + if hasattr(config, "workerinput"): + return + shutil.rmtree(SCHEMA_CRASH_RESULTS_DIR, ignore_errors=True) + SCHEMA_CRASH_RESULTS_DIR.mkdir(parents=True, exist_ok=True) + + +def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: + """Aggregate per-provider results once all xdist workers finish.""" + if hasattr(session.config, "workerinput"): + return + if not SCHEMA_CRASH_RESULTS_DIR.exists(): + return + files = list(SCHEMA_CRASH_RESULTS_DIR.glob("*.json")) + if not files: + return + + keys = ("schemas", "type_errors", "schema_errors", "timeouts", "other_errors") + totals = dict.fromkeys(keys, 0) + for f in files: + data = json.loads(f.read_text()) + for k in keys: + totals[k] += data.get(k, 0) + + crashes = ( + totals["type_errors"] + + totals["schema_errors"] + + totals["timeouts"] + + totals["other_errors"] + ) + + print(f"\n{'=' * 60}") + print("Real-world schema crash test — aggregate results") + print(f"{'=' * 60}") + print(f"Providers tested: {len(files):,}") + print(f"Schemas tested: {totals['schemas']:,}") + print(f"TypeErrors: {totals['type_errors']:,}") + print(f"SchemaErrors: {totals['schema_errors']:,}") + print(f"Timeouts: {totals['timeouts']:,}") + print(f"Other errors: {totals['other_errors']:,}") + print( + f"Total crashes: {crashes:,} ({crashes / max(totals['schemas'], 1) * 100:.2f}%)" + ) + + # Snapshot baselines (captured 2026-04-10, openapi-directory@f7207cf0, + # origin/main, with JSON round-trip to strip YAML artifacts). + MAX_TYPE_ERRORS = 420 # was 388 — real json_schema_to_type bugs + MAX_SCHEMA_ERRORS = 300 # was 277 — Pydantic regex rejections (not our code) + MAX_TIMEOUTS = 5 # was 0 + MAX_OTHER_ERRORS = 50 # was 0 + + failures: list[str] = [] + if totals["schemas"] <= 200_000: + failures.append( + f"Expected >200k schemas but only found {totals['schemas']}. " + f"Is the openapi-directory checkout correct?" + ) + if totals["type_errors"] > MAX_TYPE_ERRORS: + failures.append( + f"TypeErrors regressed: {totals['type_errors']} > {MAX_TYPE_ERRORS}" + ) + if totals["schema_errors"] > MAX_SCHEMA_ERRORS: + failures.append( + f"SchemaErrors regressed: {totals['schema_errors']} > {MAX_SCHEMA_ERRORS}" + ) + if totals["timeouts"] > MAX_TIMEOUTS: + failures.append(f"Timeouts regressed: {totals['timeouts']} > {MAX_TIMEOUTS}") + if totals["other_errors"] > MAX_OTHER_ERRORS: + failures.append( + f"Other errors regressed: {totals['other_errors']} > {MAX_OTHER_ERRORS}" + ) + + if failures: + print("\nBASELINE VIOLATIONS:") + for msg in failures: + print(f" - {msg}") + # Force a non-zero exit even though all individual tests passed. + session.exitstatus = pytest.ExitCode.TESTS_FAILED diff --git a/tests/utilities/json_schema_type/test_containers.py b/tests/utilities/json_schema_type/test_containers.py index 9b10b5c37..139124533 100644 --- a/tests/utilities/json_schema_type/test_containers.py +++ b/tests/utilities/json_schema_type/test_containers.py @@ -140,6 +140,35 @@ class TestObjectTypes: generated_type = json_schema_to_type(schema) assert generated_type == expected_type + @pytest.mark.parametrize( + "input_type, expected_type", + [ + # list[dict] roundtrips correctly (not list[Root()]) + (list[dict], list[dict[str, Any]]), + # list[dict[str, Any]] stays the same + (list[dict[str, Any]], list[dict[str, Any]]), + # list[dict[str, str]] preserves value type + (list[dict[str, str]], list[dict[str, str]]), + # list[dict[str, int]] preserves value type + (list[dict[str, int]], list[dict[str, int]]), + ], + ) + def test_list_of_dict_types_roundtrip(self, input_type, expected_type): + """Ensure list[dict] schemas produce dict types, not dataclasses (issue #3867).""" + schema = TypeAdapter(input_type).json_schema() + generated_type = json_schema_to_type(schema) + assert generated_type == expected_type + + def test_list_dict_validates_data(self): + """list[dict] schema should validate actual dict data, not produce Root() (issue #3867).""" + schema = TypeAdapter(list[dict]).json_schema() + generated_type = json_schema_to_type(schema) + validator = TypeAdapter(generated_type) + result = validator.validate_python( + [{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}] + ) + assert result == [{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}] + def test_object_accepts_valid(self, simple_object): validator = TypeAdapter(simple_object) result = validator.validate_python({"name": "test", "age": 30}) 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 fc2226bdc..07c9351e9 100644 --- a/tests/utilities/json_schema_type/test_json_schema_type.py +++ b/tests/utilities/json_schema_type/test_json_schema_type.py @@ -1,8 +1,9 @@ """Core JSON schema type conversion tests.""" +import dataclasses from dataclasses import Field from enum import Enum -from typing import Literal +from typing import Any, Literal import pytest from pydantic import TypeAdapter, ValidationError @@ -111,6 +112,77 @@ class TestSimpleTypes: validator.validate_python(False) +class TestBooleanSchemas: + """JSON Schema draft-06+ allows true/false as property schemas.""" + + def test_true_property_schema_accepts_any_value(self): + """A property with schema `true` should accept any value.""" + schema = { + "type": "object", + "properties": {"name": {"type": "string"}, "anything": True}, + "required": ["name", "anything"], + } + result = json_schema_to_type(schema) + validator = TypeAdapter(result) + obj = validator.validate_python({"name": "test", "anything": 42}) + assert obj.name == "test" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + assert obj.anything == 42 # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + + def test_false_property_schema_rejects_values(self): + """A property with schema `false` should reject any provided value.""" + schema = { + "type": "object", + "properties": {"name": {"type": "string"}, "never": False}, + "required": ["name"], + } + result = json_schema_to_type(schema) + validator = TypeAdapter(result) + obj = validator.validate_python({"name": "test"}) + assert obj.name == "test" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + + with pytest.raises(ValidationError): + validator.validate_python({"name": "test", "never": "anything"}) + + def test_boolean_schema_in_object_with_additional_properties(self): + """Boolean property schemas work alongside additionalProperties=True.""" + schema = { + "type": "object", + "properties": { + "known": {"type": "string"}, + "flexible": True, + }, + "required": ["known"], + "additionalProperties": True, + } + result = json_schema_to_type(schema) + validator = TypeAdapter(result) + obj = validator.validate_python( + {"known": "hello", "flexible": [1, 2, 3], "extra": "field"} + ) + assert obj.known == "hello" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + assert obj.flexible == [1, 2, 3] # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + + def test_issue_3783_boolean_property_schemas(self): + """Regression test for GitHub issue #3783.""" + schema = { + "type": "object", + "properties": { + "ts": {"type": "integer"}, + "level": True, + "app": True, + "tag": {"type": ["array", "null"], "items": {"type": "string"}}, + }, + "required": ["ts"], + "additionalProperties": True, + } + result = json_schema_to_type(schema) + validator = TypeAdapter(result) + obj = validator.validate_python({"ts": 123, "level": "info", "app": "myapp"}) + assert obj.ts == 123 # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + assert obj.level == "info" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + assert obj.app == "myapp" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + + class TestConstrainedTypes: def test_constant(self): validator = TypeAdapter(Literal["x"]) @@ -168,3 +240,73 @@ class TestConstrainedTypes: assert TypeAdapter(type_).validate_python("y") == "y" with pytest.raises(ValidationError): TypeAdapter(type_).validate_python("z") + + +class TestCrashPrevention: + """Schemas that previously caused crashes should now be handled gracefully.""" + + def test_boolean_schema_true(self): + """Boolean schema True should return Any (JSON Schema draft-06+).""" + assert json_schema_to_type(True) is Any + + def test_boolean_schema_false(self): + """Boolean schema False should return an unsatisfiable type.""" + result = json_schema_to_type(False) + with pytest.raises(ValidationError): + TypeAdapter(result).validate_python("anything") + + def test_python_keyword_property_names(self): + """Properties named after Python keywords should not crash.""" + schema = { + "type": "object", + "properties": { + "class": {"type": "string"}, + "return": {"type": "integer"}, + "import": {"type": "boolean"}, + }, + "required": ["class"], + } + T = json_schema_to_type(schema) + ta = TypeAdapter(T) + result = ta.validate_python({"class": "A", "return": 1, "import": True}) + assert result.class_ == "A" # ty:ignore[unresolved-attribute] + + def test_empty_enum(self): + """Empty enum means no value is valid — should reject like a false schema.""" + schema = { + "type": "object", + "properties": {"status": {"enum": []}}, + "required": ["status"], + } + T = json_schema_to_type(schema) + ta = TypeAdapter(T) + with pytest.raises(ValidationError): + ta.validate_python({"status": "anything"}) + + def test_sanitized_name_collision(self): + """Properties that collide after sanitization get deduplicated.""" + schema = { + "type": "object", + "properties": { + "foo-bar": {"type": "string"}, + "foo_bar": {"type": "string"}, + }, + } + T = json_schema_to_type(schema) + field_names = [f.name for f in dataclasses.fields(T)] + assert len(field_names) == 2 + assert len(set(field_names)) == 2 + + def test_empty_property_name(self): + """Empty and whitespace-only property names should not crash.""" + schema = { + "type": "object", + "properties": { + "": {"type": "string"}, + " ": {"type": "integer"}, + }, + } + T = json_schema_to_type(schema) + 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/json_schema_type/test_real_world_schemas.py b/tests/utilities/json_schema_type/test_real_world_schemas.py new file mode 100644 index 000000000..31364c177 --- /dev/null +++ b/tests/utilities/json_schema_type/test_real_world_schemas.py @@ -0,0 +1,340 @@ +"""Crash-test json_schema_to_type against real-world OpenAPI schemas. + +Uses the APIs.guru openapi-directory (https://github.com/APIs-guru/openapi-directory) +pinned to a specific commit for reproducibility. + +Parametrized by API provider (~700 providers, one test each) so pytest +shows progress and can identify which provider caused a hang. + +Marked as an integration test — skipped by default, run with: + uv run pytest tests/utilities/json_schema_type/test_real_world_schemas.py -m integration -v +""" + +from __future__ import annotations + +import json +import os +import shutil +import signal +import subprocess +from dataclasses import asdict, dataclass +from pathlib import Path + +import pytest +import yaml +from pydantic import TypeAdapter +from yaml import CSafeLoader # ty: ignore[possibly-missing-import] + +from fastmcp.utilities.json_schema_type import json_schema_to_type + +# Pin to a specific commit for reproducibility +OPENAPI_DIRECTORY_REPO = "https://github.com/APIs-guru/openapi-directory.git" +OPENAPI_DIRECTORY_COMMIT = "f7207cf0a5c56081d275ebae4cf615249323385d" +CLONE_DIR = Path(os.environ.get("OPENAPI_DIRECTORY_PATH", "/tmp/openapi-directory")) + +# Per-schema timeout (seconds) to catch infinite loops +SCHEMA_TIMEOUT = 5 + +# In CI (RUN_REAL_WORLD_SCHEMA_TEST=1), _ensure_repo clones automatically. +# Locally, skip unless the repo is already cloned to avoid a surprise 200MB download. +_run_in_ci = os.environ.get("RUN_REAL_WORLD_SCHEMA_TEST") == "1" +_skip_locally = not _run_in_ci and not CLONE_DIR.exists() + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + _skip_locally, + reason=( + f"openapi-directory not found at {CLONE_DIR}. " + f"Set RUN_REAL_WORLD_SCHEMA_TEST=1 to auto-clone, or: " + f"git clone --depth 1 {OPENAPI_DIRECTORY_REPO} {CLONE_DIR}" + ), + ), +] + + +class _SchemaTimeout(Exception): + pass + + +def _alarm_handler(signum: object, frame: object) -> None: + raise _SchemaTimeout() + + +# ── Helpers ────────────────────────────────────────────────────────── + + +def _is_openapi_directory_clone(path: Path) -> bool: + """Check whether *path* looks like a clone of the openapi-directory repo.""" + if not (path / ".git").is_dir(): + return False + result = subprocess.run( + ["git", "-C", str(path), "remote", "get-url", "origin"], + capture_output=True, + text=True, + ) + return "openapi-directory" in result.stdout + + +def _ensure_repo() -> Path: + """Clone the openapi-directory repo if not already present at the pinned commit. + + Safe under pytest-xdist: a file lock serializes the rmtree/reclone path so + concurrent workers don't race on the shared CLONE_DIR. + """ + # Fast-path check without a lock — if HEAD already matches, skip locking entirely. + if _head_matches_pinned(): + return CLONE_DIR + + # fcntl is POSIX-only; imported here (not at module level) so this file + # collects cleanly on Windows where the integration test is skipped. + import fcntl + + lock_path = CLONE_DIR.with_suffix(".lock") + lock_path.parent.mkdir(parents=True, exist_ok=True) + with open(lock_path, "w") as lf: + fcntl.flock(lf.fileno(), fcntl.LOCK_EX) + try: + # Re-check under the lock in case another worker already fixed it. + if _head_matches_pinned(): + return CLONE_DIR + + if CLONE_DIR.exists(): + if not _is_openapi_directory_clone(CLONE_DIR): + raise RuntimeError( + f"{CLONE_DIR} exists but is not an openapi-directory clone. " + f"Remove it manually or set OPENAPI_DIRECTORY_PATH to a different path." + ) + shutil.rmtree(CLONE_DIR) + + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + OPENAPI_DIRECTORY_REPO, + str(CLONE_DIR), + ], + check=True, + capture_output=True, + ) + subprocess.run( + [ + "git", + "-C", + str(CLONE_DIR), + "fetch", + "--depth", + "1", + "origin", + OPENAPI_DIRECTORY_COMMIT, + ], + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "-C", str(CLONE_DIR), "checkout", OPENAPI_DIRECTORY_COMMIT], + check=True, + capture_output=True, + ) + return CLONE_DIR + finally: + fcntl.flock(lf.fileno(), fcntl.LOCK_UN) + + +def _head_matches_pinned() -> bool: + """Return True when CLONE_DIR is already checked out at the pinned commit.""" + if not (CLONE_DIR.exists() and (CLONE_DIR / ".git").is_dir()): + return False + result = subprocess.run( + ["git", "-C", str(CLONE_DIR), "rev-parse", "HEAD"], + capture_output=True, + text=True, + ) + return result.stdout.strip() == OPENAPI_DIRECTORY_COMMIT + + +def _load_spec(spec_file: Path) -> dict | None: + """Load and return a spec dict, or None on failure.""" + try: + if spec_file.suffix == ".yaml": + spec = yaml.load(spec_file.read_text(), Loader=CSafeLoader) + else: + spec = json.loads(spec_file.read_text()) + return spec if isinstance(spec, dict) else None + except Exception: + return None + + +def _extract_schemas(spec: dict) -> dict: + """Pull all schema definitions out of an OpenAPI spec.""" + schemas: dict = {} + if "definitions" in spec: + schemas.update(spec["definitions"]) + components = spec.get("components") + if isinstance(components, dict): + schemas.update(components.get("schemas", {})) + return {k: v for k, v in schemas.items() if isinstance(v, dict)} + + +# ── Per-provider collection ────────────────────────────────────────── + + +def _collect_providers() -> list[str]: + """List API provider directories (e.g. 'github.com', 'amazonaws.com').""" + apis_dir = CLONE_DIR / "APIs" + if not apis_dir.is_dir(): + return [] + return sorted(d.name for d in apis_dir.iterdir() if d.is_dir()) + + +def _spec_files_for_provider(provider: str) -> list[Path]: + """Find all spec files for a given provider.""" + provider_dir = CLONE_DIR / "APIs" / provider + files: list[Path] = [] + for name in ("openapi.yaml", "swagger.yaml", "openapi.json", "swagger.json"): + files.extend(provider_dir.rglob(name)) + return sorted(files) + + +# ── Test logic ─────────────────────────────────────────────────────── + + +@dataclass +class ProviderResult: + """Crash counts for one API provider.""" + + schemas: int = 0 + type_errors: int = 0 + schema_errors: int = 0 + timeouts: int = 0 + other_errors: int = 0 + + +def _test_provider(provider: str) -> ProviderResult: + """Run json_schema_to_type on every schema for one provider.""" + # Clear the module-level type cache between providers to avoid + # unbounded memory growth across 232K schemas. + from fastmcp.utilities.json_schema_type import _classes + + _classes.clear() + + result = ProviderResult() + use_alarm = hasattr(signal, "SIGALRM") + + for spec_file in _spec_files_for_provider(provider): + spec = _load_spec(spec_file) + if spec is None: + continue + for _name, schema in _extract_schemas(spec).items(): + # JSON-round-trip to simulate production: schemas arrive over + # MCP as JSON, so YAML-specific types (datetime, date) should + # not be present. This avoids counting YAML-parser artifacts + # as json_schema_to_type bugs. + try: + schema = json.loads(json.dumps(schema, default=str)) + except (TypeError, ValueError): + continue + + result.schemas += 1 + + old_handler = signal.SIG_DFL + if use_alarm: + old_handler = signal.signal(signal.SIGALRM, _alarm_handler) + signal.alarm(SCHEMA_TIMEOUT) + try: + T = json_schema_to_type(schema) + TypeAdapter(T) + except _SchemaTimeout: + result.timeouts += 1 + except TypeError: + result.type_errors += 1 + except Exception as e: + err_type = type(e).__name__ + if "SchemaError" in err_type or "schema" in str(e).lower()[:50]: + result.schema_errors += 1 + else: + result.other_errors += 1 + finally: + if use_alarm: + signal.alarm(0) + signal.signal(signal.SIGALRM, old_handler) + + return result + + +# ── Per-provider test (parametrized) ───────────────────────────────── +# +# ~700 test items — one per API provider. +# Profiled on a fast MacBook (p50=0.06s, p99=8s); CI is ~3x slower. +# Tiered timeouts so small providers fail fast while large ones get room. +# +# Local times → CI estimate → timeout bucket: +# azure/aws/github/msft/google 80-137s → 240-410s → 600s +# adyen 21s → 63s → 120s +# loket/mailchimp/apisetu/k8s 6-10s → 18-30s → 120s +# everything else (p99) <8s → <24s → 60s + +_TIER1_PROVIDERS = frozenset( + { + "azure.com", + "amazonaws.com", + "googleapis.com", + "github.com", + "microsoft.com", + } +) + +_TIER2_PROVIDERS = frozenset( + { + "adyen.com", + "loket.nl", + "mailchimp.com", + "apisetu.gov.in", + "kubernetes.io", + "twilio.com", + "sportsdata.io", + "vtex.local", + "amadeus.com", + } +) + + +def _providers_with_timeouts() -> list: # list of pytest.param + """Build parametrize list with per-provider timeouts.""" + params = [] + for p in _collect_providers(): + if p in _TIER1_PROVIDERS: + t = 600 + elif p in _TIER2_PROVIDERS: + t = 120 + else: + t = 60 + params.append( + pytest.param(p, id=p, marks=pytest.mark.timeout(t, method="thread")) + ) + return params + + +# Per-provider results are persisted to disk so they survive across xdist +# workers (each worker runs in its own process). Aggregation and baseline +# assertions happen in the pytest_sessionfinish hook in conftest.py. +# This path is duplicated in conftest.py by design — tests shouldn't import +# from conftest, which isn't a reliably importable module. +_RESULTS_DIR = Path( + os.environ.get("SCHEMA_CRASH_RESULTS_DIR", "/tmp/schema_crash_results") +) + + +@pytest.mark.integration +@pytest.mark.parametrize("provider", _providers_with_timeouts()) +def test_provider_schemas(provider: str): + """json_schema_to_type should not infinite-loop on schemas from this provider.""" + _ensure_repo() + _RESULTS_DIR.mkdir(parents=True, exist_ok=True) + result = _test_provider(provider) + (_RESULTS_DIR / f"{provider}.json").write_text(json.dumps(asdict(result))) + assert result.timeouts == 0, ( + f"{provider}: {result.timeouts} schema(s) timed out (possible infinite loop)" + ) diff --git a/tests/utilities/openapi/test_director.py b/tests/utilities/openapi/test_director.py index bcde4f69d..56008b241 100644 --- a/tests/utilities/openapi/test_director.py +++ b/tests/utilities/openapi/test_director.py @@ -832,6 +832,98 @@ class TestQueryParameterSerialization: # Should contain alternating key,value pairs assert "100" in url and "200" in url and "150" in url + def test_explode_true_dict_expands_to_separate_params(self, director): + """style=form, explode=true on objects expands each property as a query param.""" + route = HTTPRoute( + path="/test", + method="GET", + operation_id="test_endpoint", + parameters=[ + ParameterInfo( + name="data", + location="query", + required=True, + schema={ + "type": "object", + "properties": { + "myAttribute": {"type": "boolean"}, + }, + }, + explode=True, + ) + ], + parameter_map={ + "data": {"location": "query", "openapi_name": "data"}, + }, + ) + + request = director.build( + route, {"data": {"myAttribute": True}}, "https://example.com" + ) + url = str(request.url) + # Should expand to myAttribute=true (not data={'myAttribute': True}) + assert "myAttribute=true" in url + assert "data=" not in url + + def test_explode_default_dict_expands_to_separate_params(self, director): + """Default explode (None → true) on objects expands properties.""" + route = HTTPRoute( + path="/test", + method="GET", + operation_id="test_endpoint", + parameters=[ + ParameterInfo( + name="filter", + location="query", + required=True, + schema={ + "type": "object", + "properties": { + "category": {"type": "string"}, + "active": {"type": "boolean"}, + }, + }, + # explode defaults to None → treated as true + ) + ], + parameter_map={ + "filter": {"location": "query", "openapi_name": "filter"}, + }, + ) + + request = director.build( + route, + {"filter": {"category": "electronics", "active": False}}, + "https://example.com", + ) + url = str(request.url) + assert "category=electronics" in url + assert "active=false" in url + assert "filter=" not in url + + def test_explode_true_empty_dict_omitted(self, director): + """Empty dict with explode=true omits the parameter.""" + route = HTTPRoute( + path="/items", + method="GET", + operation_id="list_items", + parameters=[ + ParameterInfo( + name="filter", + location="query", + required=False, + schema={"type": "object"}, + explode=True, + ) + ], + parameter_map={ + "filter": {"location": "query", "openapi_name": "filter"}, + }, + ) + + request = director.build(route, {"filter": {}}, "https://example.com") + assert "filter" not in str(request.url) + def test_explode_false_empty_dict_omitted(self, director): """Empty dict with explode=false omits the parameter.""" route = HTTPRoute( diff --git a/tests/utilities/openapi/test_nullable_fields.py b/tests/utilities/openapi/test_nullable_fields.py index 9cc163f00..2331fc3ed 100644 --- a/tests/utilities/openapi/test_nullable_fields.py +++ b/tests/utilities/openapi/test_nullable_fields.py @@ -1,8 +1,12 @@ """Tests for nullable field handling in OpenAPI schemas.""" +import httpx import pytest from jsonschema import ValidationError, validate +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.server.providers.openapi import OpenAPIProvider from fastmcp.utilities.openapi.json_schema_converter import ( convert_openapi_schema_to_json_schema, ) @@ -350,6 +354,70 @@ class TestHandleNullableFields: result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0") assert result == expected + def test_nullable_in_definitions(self): + """Test nullable field inside $defs.""" + input_schema = { + "type": "object", + "properties": {"user": {"$ref": "#/$defs/User"}}, + "$defs": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "bio": {"type": "string", "nullable": True}, + }, + } + }, + } + expected = { + "type": "object", + "properties": {"user": {"$ref": "#/$defs/User"}}, + "$defs": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "bio": {"type": ["string", "null"]}, + }, + } + }, + } + result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0") + assert result == expected + + def test_nullable_in_nested_properties(self): + """Test nullable field in deeply nested properties.""" + input_schema = { + "type": "object", + "properties": { + "a": { + "type": "object", + "properties": { + "b": { + "type": "object", + "properties": {"c": {"type": "string", "nullable": True}}, + } + }, + } + }, + } + expected = { + "type": "object", + "properties": { + "a": { + "type": "object", + "properties": { + "b": { + "type": "object", + "properties": {"c": {"type": ["string", "null"]}}, + } + }, + } + }, + } + result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0") + assert result == expected + class TestNullableFieldValidation: """Test that converted schemas validate correctly with jsonschema.""" @@ -383,3 +451,108 @@ class TestNullableFieldValidation: # Invalid values should fail with pytest.raises(ValidationError): validate(instance="INVALID", schema=json_schema) + + +class TestNullableInputSchemaIntegration: + """Test that nullable fields are converted in tool input schemas end-to-end. + + These tests exercise the full pipeline: OpenAPI spec -> OpenAPIProvider -> + tool.inputSchema, verifying that `nullable: true` doesn't leak through. + """ + + async def test_nullable_query_param_converted_in_tool_input_schema(self): + """Nullable query parameter should produce type union in tool input schema.""" + spec = { + "openapi": "3.0.0", + "info": {"title": "Test", "version": "1.0.0"}, + "paths": { + "/search": { + "get": { + "operationId": "search", + "parameters": [ + { + "name": "query", + "in": "query", + "required": True, + "schema": {"type": "string"}, + }, + { + "name": "category", + "in": "query", + "schema": {"type": "string", "nullable": True}, + }, + ], + "responses": {"200": {"description": "OK"}}, + } + } + }, + } + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider(openapi_spec=spec, client=client) + mcp = FastMCP("test") + mcp.add_provider(provider) + + async with Client(mcp) as mcp_client: + tools = await mcp_client.list_tools() + assert len(tools) == 1 + schema = tools[0].inputSchema + category_prop = schema["properties"]["category"] + assert "nullable" not in category_prop + assert category_prop["type"] == ["string", "null"] + + async def test_nullable_in_request_body_defs_converted(self): + """Nullable field inside $defs referenced by request body should be converted.""" + spec = { + "openapi": "3.0.0", + "info": {"title": "Test", "version": "1.0.0"}, + "paths": { + "/users": { + "post": { + "operationId": "create_user", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUser" + } + } + }, + }, + "responses": {"201": {"description": "Created"}}, + } + } + }, + "components": { + "schemas": { + "CreateUser": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "bio": {"type": "string", "nullable": True}, + }, + } + } + }, + } + async with httpx.AsyncClient(base_url="https://api.example.com") as client: + provider = OpenAPIProvider(openapi_spec=spec, client=client) + mcp = FastMCP("test") + mcp.add_provider(provider) + + async with Client(mcp) as mcp_client: + tools = await mcp_client.list_tools() + assert len(tools) == 1 + schema = tools[0].inputSchema + + # Find the bio property — it may be inline or in $defs + if "$defs" in schema: + # Resolve through $defs + user_schema = next(iter(schema["$defs"].values())) + bio_prop = user_schema["properties"]["bio"] + else: + bio_prop = schema["properties"]["bio"] + + assert "nullable" not in bio_prop + assert bio_prop["type"] == ["string", "null"] diff --git a/tests/utilities/test_docstring_parsing.py b/tests/utilities/test_docstring_parsing.py new file mode 100644 index 000000000..8fab08ede --- /dev/null +++ b/tests/utilities/test_docstring_parsing.py @@ -0,0 +1,562 @@ +"""Tests for docstring-to-schema parameter description extraction.""" + +from typing import Annotated + +from pydantic import Field + +from fastmcp.tools.function_parsing import ParsedFunction +from fastmcp.utilities.docstring_parsing import parse_docstring + + +class TestGoogleStyle: + """Google-style docstrings (Args:/Arguments:).""" + + def test_basic(self): + def fn(a: float, b: float) -> float: + """Add two numbers. + + Args: + a: The first number. + b: The second number. + """ + return a + b + + parsed = parse_docstring(fn) + assert parsed.description == "Add two numbers." + assert parsed.parameters == { + "a": "The first number.", + "b": "The second number.", + } + + def test_with_inline_types(self): + def fn(a: float, b: str) -> float: + """Do something. + + Args: + a (float): The number. + b (str, optional): The string. + """ + return a + + parsed = parse_docstring(fn) + assert parsed.description == "Do something." + assert parsed.parameters == {"a": "The number.", "b": "The string."} + + def test_returns_section_excluded(self): + def fn(a: float) -> float: + """Summary. + + Args: + a: The input. + + Returns: + The output. + """ + return a + + parsed = parse_docstring(fn) + assert parsed.description == "Summary." + assert parsed.parameters == {"a": "The input."} + + def test_raises_section_excluded(self): + def fn(a: float) -> float: + """Summary. + + Args: + a: The input. + + Raises: + ValueError: If negative. + """ + return a + + parsed = parse_docstring(fn) + assert parsed.description == "Summary." + assert parsed.parameters == {"a": "The input."} + + def test_example_section_excluded(self): + def fn(a: str) -> str: + """Run some code. + + Example: + >>> fn("hello") + 'hello' + + Args: + a: The input. + """ + return a + + parsed = parse_docstring(fn) + assert parsed.description == "Run some code." + assert parsed.parameters == {"a": "The input."} + + def test_multiline_param_description(self): + def fn(a: float) -> float: + """Summary. + + Args: + a: A description that + spans multiple lines. + """ + return a + + parsed = parse_docstring(fn) + assert parsed.description == "Summary." + assert "spans multiple lines" in parsed.parameters["a"] + + +class TestNumpyStyle: + """NumPy-style docstrings (Parameters\\n----------).""" + + def test_basic(self): + def fn(x: int, y: int) -> int: + """Multiply. + + Parameters + ---------- + x + The first integer. + y + The second integer. + """ + return x * y + + parsed = parse_docstring(fn) + assert parsed.description == "Multiply." + assert parsed.parameters == { + "x": "The first integer.", + "y": "The second integer.", + } + + def test_with_types(self): + def fn(a: float, b: str) -> float: + """Do something. + + Parameters + ---------- + a : float + The number. + b : str + The string. + """ + return a + + parsed = parse_docstring(fn) + assert parsed.description == "Do something." + assert parsed.parameters == {"a": "The number.", "b": "The string."} + + +class TestSphinxStyle: + """Sphinx-style docstrings (:param name:).""" + + def test_basic(self): + def fn(name: str, age: int) -> str: + """Format a greeting. + + :param name: The person's name. + :param age: The person's age. + """ + return f"{name} is {age}" + + parsed = parse_docstring(fn) + assert parsed.description == "Format a greeting." + assert parsed.parameters == { + "name": "The person's name.", + "age": "The person's age.", + } + + def test_with_type_directive(self): + def fn(a: float, b: str) -> float: + """Summary. + + :param a: The number. + :type a: float + :param b: The string. + :type b: str + """ + return a + + parsed = parse_docstring(fn) + assert parsed.description == "Summary." + assert parsed.parameters == {"a": "The number.", "b": "The string."} + + +class TestEdgeCases: + """Unusual, malformed, or partially-correct docstrings.""" + + def test_no_docstring(self): + def fn(a: int) -> int: + return a + + parsed = parse_docstring(fn) + assert parsed.description is None + assert parsed.parameters == {} + + def test_summary_only(self): + def fn(a: int) -> int: + """Just a summary.""" + return a + + parsed = parse_docstring(fn) + assert parsed.description == "Just a summary." + assert parsed.parameters == {} + + def test_multi_paragraph_description(self): + def fn(a: float) -> float: + """Summary line. + + More detailed explanation here + spanning multiple lines. + + Another paragraph. + + Args: + a: The number. + """ + return a + + parsed = parse_docstring(fn) + # Full description (summary + body) should be preserved + assert parsed.description is not None + assert "Summary line." in parsed.description + assert "More detailed explanation" in parsed.description + assert "Another paragraph." in parsed.description + # Args section should not bleed into description + assert "The number" not in parsed.description + assert parsed.parameters == {"a": "The number."} + + def test_multiline_summary(self): + def fn(a: float) -> float: + """Multi-line summary + continues on next line. + + Args: + a: The number. + """ + return a + + parsed = parse_docstring(fn) + assert parsed.description is not None + assert "Multi-line summary" in parsed.description + assert "continues on next line" in parsed.description + assert parsed.parameters == {"a": "The number."} + + def test_missing_colon_after_args_keyword(self): + """Malformed: 'Args' without colon is not a valid section.""" + + def fn(a: float) -> float: + """Summary. + + Args + a: Maybe the number? + """ + return a + + parsed = parse_docstring(fn) + # Parser shouldn't pick this up as an Args section + assert parsed.parameters == {} + + def test_empty_args_section(self): + def fn(a: float) -> float: + """Summary. + + Args: + """ + return a + + parsed = parse_docstring(fn) + assert parsed.parameters == {} + + def test_param_name_not_in_function_signature(self): + """Docstring documents a param that doesn't exist on the function.""" + + def fn(a: float) -> float: + """Summary. + + Args: + nonexistent: Wrong param name. + """ + return a + + parsed = parse_docstring(fn) + # parse_docstring returns whatever the docstring says — + # filtering happens at the schema injection level + assert parsed.parameters == {"nonexistent": "Wrong param name."} + + def test_async_function(self): + async def fn(a: float) -> float: + """Async summary. + + Args: + a: The number. + """ + return a + + parsed = parse_docstring(fn) + assert parsed.description == "Async summary." + assert parsed.parameters == {"a": "The number."} + + +class TestParsedFunctionIntegration: + """Tests for docstring flowing through ParsedFunction.from_function.""" + + def test_description_is_summary_only(self): + def fn(a: float) -> float: + """The summary line. + + Args: + a: Some param. + + Returns: + Something. + """ + return a + + p = ParsedFunction.from_function(fn) + assert p.description == "The summary line." + + def test_param_descriptions_in_schema(self): + def fn(a: float, b: str) -> str: + """Do something. + + Args: + a: The number. + b: The string. + """ + return str(a) + b + + p = ParsedFunction.from_function(fn) + assert p.input_schema["properties"]["a"]["description"] == "The number." + assert p.input_schema["properties"]["b"]["description"] == "The string." + + def test_numpy_style_integration(self): + def fn(a: float, b: str) -> str: + """Summary. + + Parameters + ---------- + a : float + The number. + b : str + The string. + """ + return str(a) + b + + p = ParsedFunction.from_function(fn) + assert p.description == "Summary." + assert p.input_schema["properties"]["a"]["description"] == "The number." + assert p.input_schema["properties"]["b"]["description"] == "The string." + + def test_sphinx_style_integration(self): + def fn(a: float, b: str) -> str: + """Summary. + + :param a: The number. + :param b: The string. + """ + return str(a) + b + + p = ParsedFunction.from_function(fn) + assert p.description == "Summary." + assert p.input_schema["properties"]["a"]["description"] == "The number." + assert p.input_schema["properties"]["b"]["description"] == "The string." + + def test_field_description_takes_precedence(self): + def fn( + a: Annotated[float, Field(description="From Field")], + b: float, + ) -> float: + """Add. + + Args: + a: From docstring. + b: Also from docstring. + """ + return a + b + + p = ParsedFunction.from_function(fn) + assert p.input_schema["properties"]["a"]["description"] == "From Field" + assert ( + p.input_schema["properties"]["b"]["description"] == "Also from docstring." + ) + + def test_annotated_string_takes_precedence(self): + def fn( + a: Annotated[float, "From annotation"], + b: float, + ) -> float: + """Add. + + Args: + a: From docstring. + b: Also from docstring. + """ + return a + b + + p = ParsedFunction.from_function(fn) + assert p.input_schema["properties"]["a"]["description"] == "From annotation" + assert ( + p.input_schema["properties"]["b"]["description"] == "Also from docstring." + ) + + def test_no_docstring_no_descriptions(self): + def fn(a: float) -> float: + return a + + p = ParsedFunction.from_function(fn) + assert p.description is None + assert "description" not in p.input_schema["properties"]["a"] + + def test_docstring_without_args_section(self): + def fn(a: float) -> float: + """Just a summary.""" + return a + + p = ParsedFunction.from_function(fn) + assert p.description == "Just a summary." + assert "description" not in p.input_schema["properties"]["a"] + + def test_partial_params_documented(self): + """Only some params documented — others remain undescribed.""" + + def fn(a: float, b: float, c: float) -> float: + """Add numbers. + + Args: + a: Documented. + """ + return a + b + c + + p = ParsedFunction.from_function(fn) + assert p.input_schema["properties"]["a"]["description"] == "Documented." + assert "description" not in p.input_schema["properties"]["b"] + assert "description" not in p.input_schema["properties"]["c"] + + def test_nonexistent_param_in_docstring_ignored(self): + """Docstring mentions a param that doesn't exist — silently skipped.""" + + def fn(a: float) -> float: + """Summary. + + Args: + a: The real one. + ghost: Doesn't exist. + """ + return a + + p = ParsedFunction.from_function(fn) + assert p.input_schema["properties"]["a"]["description"] == "The real one." + # No crash, no ghost in properties + assert "ghost" not in p.input_schema["properties"] + + def test_types_in_docstring_dont_override_schema_types(self): + """A '(str)' in the docstring must not change the schema's type.""" + + def fn(a: float) -> float: + """Summary. + + Args: + a (str): A description, but the type is wrong. + """ + return a + + p = ParsedFunction.from_function(fn) + # Schema type comes from the annotation, not the docstring + assert p.input_schema["properties"]["a"]["type"] == "number" + assert ( + p.input_schema["properties"]["a"]["description"] + == "A description, but the type is wrong." + ) + + def test_multi_paragraph_description_preserved(self): + def fn(a: float) -> float: + """Short summary. + + A longer explanation that provides + additional context. + + Args: + a: The number. + """ + return a + + p = ParsedFunction.from_function(fn) + assert p.description is not None + assert "Short summary" in p.description + assert "longer explanation" in p.description + assert "The number" not in p.description + + def test_async_function_integration(self): + async def fn(a: float) -> float: + """Async summary. + + Args: + a: The number. + """ + return a + + p = ParsedFunction.from_function(fn) + assert p.description == "Async summary." + assert p.input_schema["properties"]["a"]["description"] == "The number." + + def test_callable_class_sources_description_from_class(self): + """Class docstring drives the tool description (it describes what the + tool IS), while __call__'s Args section drives parameter descriptions + (its params are what the schema actually exposes).""" + + class MyTool: + """Class-level description.""" + + def __call__(self, x: int) -> int: + """Internal call doc. + + Args: + x: From call. + """ + return x + + p = ParsedFunction.from_function(MyTool()) + # Class docstring wins for the description + assert p.description == "Class-level description." + # __call__'s Args wins for the parameter description + assert p.input_schema["properties"]["x"]["description"] == "From call." + + def test_callable_class_does_not_inherit_class_param_descriptions(self): + """The class docstring's Args section typically describes __init__. + Even when param names overlap with __call__, those descriptions must + not leak into __call__'s parameter schema.""" + + class MyTool: + """Describes what the tool does. + + Args: + x: Constructor argument (should NOT appear on __call__'s x). + """ + + def __init__(self, x: str) -> None: + self.x = x + + def __call__(self, x: int) -> int: + return x + + p = ParsedFunction.from_function(MyTool("config")) + assert p.description == "Describes what the tool does." + # x's description does NOT come from the class's constructor-focused Args + assert "description" not in p.input_schema["properties"]["x"] + + def test_callable_class_falls_back_to_call_description(self): + """If the class has no docstring, fall back to __call__'s description.""" + + class MyTool: + def __call__(self, x: int) -> int: + """Call-level description. + + Args: + x: From call. + """ + return x + + p = ParsedFunction.from_function(MyTool()) + assert p.description == "Call-level description." + assert p.input_schema["properties"]["x"]["description"] == "From call." diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index 9c208f5d8..644366057 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -197,6 +197,67 @@ class TestDereferenceRefs: assert country["default"] == "US" assert "$defs" not in result + def test_strips_discriminator_mapping_after_inlining(self): + """Discriminator.mapping refs dangle after $defs are inlined (#3679).""" + schema = { + "$defs": { + "IdentifyPerson": { + "type": "object", + "properties": { + "action": {"const": "identify", "type": "string"}, + "name": {"type": "string"}, + }, + "required": ["action", "name"], + }, + "PersonDelete": { + "type": "object", + "properties": { + "action": {"const": "delete", "type": "string"}, + }, + "required": ["action"], + }, + }, + "anyOf": [ + {"$ref": "#/$defs/IdentifyPerson"}, + {"$ref": "#/$defs/PersonDelete"}, + ], + "discriminator": { + "mapping": { + "identify": "#/$defs/IdentifyPerson", + "delete": "#/$defs/PersonDelete", + }, + "propertyName": "action", + }, + } + result = dereference_refs(schema) + + assert "$defs" not in result + assert "discriminator" not in result + # The anyOf variants should be inlined with their const values intact + assert len(result["anyOf"]) == 2 + actions = {v["properties"]["action"]["const"] for v in result["anyOf"]} + assert actions == {"identify", "delete"} + + def test_preserves_property_named_discriminator(self): + """A field *named* 'discriminator' inside properties must survive.""" + schema = { + "$defs": { + "Inner": { + "type": "object", + "properties": { + "discriminator": {"type": "string"}, + }, + }, + }, + "properties": { + "item": {"$ref": "#/$defs/Inner"}, + }, + } + result = dereference_refs(schema) + + assert "$defs" not in result + assert "discriminator" in result["properties"]["item"]["properties"] + class TestCompressSchema: """Tests for the compress_schema function.""" @@ -389,6 +450,107 @@ class TestCompressSchema: assert "title" not in compressed["properties"]["title"] assert "title" not in compressed["properties"]["type"] + def test_prune_titles_on_bare_metadata_node(self): + """Pydantic emits `{"title": "X"}` for Any-typed fields with no sibling + `type`/`properties`. Gemini 2.5 Flash rejects these with + MALFORMED_FUNCTION_CALL, so we need to strip the title even without a + schema keyword — as long as every remaining key is metadata.""" + schema = { + "type": "object", + "properties": { + "anyfield": {"title": "Anyfield"}, + "described_any": {"title": "Described", "description": "anything"}, + }, + } + + compressed = compress_schema(schema, prune_titles=True) + + assert "title" not in compressed["properties"]["anyfield"] + assert "title" not in compressed["properties"]["described_any"] + # description survives — it's also metadata but prune_titles only + # targets "title" specifically + assert compressed["properties"]["described_any"]["description"] == "anything" + + def test_prune_titles_on_draft07_and_legacy_keywords(self): + """Sub-schemas under draft-07 and 2019-09+ keywords that previous + drafts still use must be treated as schemas, not opaque payload — + `dependencies`, `additionalItems`, `contentSchema` all hold + sub-schemas in at least some JSON Schema drafts.""" + schema = { + "type": "object", + "properties": { + "payload": { + "type": "string", + "contentMediaType": "application/json", + "contentSchema": { + "type": "object", + "title": "Payload", + }, + }, + "items_field": { + "type": "array", + "items": [{"type": "string", "title": "First"}], + "additionalItems": {"type": "number", "title": "Extra"}, + }, + }, + "dependencies": { + "credit_card": { + "type": "object", + "title": "HasBillingAddress", + "required": ["billing_address"], + } + }, + } + + compressed = compress_schema(schema, prune_titles=True) + + # title metadata stripped from sub-schemas reachable through + # draft-07 / 2019-09+ keywords + assert "title" not in compressed["properties"]["payload"]["contentSchema"] + assert "title" not in compressed["properties"]["items_field"]["items"][0] + assert "title" not in compressed["properties"]["items_field"]["additionalItems"] + assert "title" not in compressed["dependencies"]["credit_card"] + + def test_prune_titles_preserves_user_extension_payloads(self): + """User extensions (json_schema_extra, x-* vendor keys) carry opaque + payloads that may look metadata-shaped. They must not be touched.""" + schema = { + "type": "object", + "x-ui": {"title": "Dashboard", "description": "sidebar label"}, + "properties": { + "config": { + "type": "object", + "x-widget": {"title": "Dropdown"}, + } + }, + } + + compressed = compress_schema(schema, prune_titles=True) + + assert compressed["x-ui"] == { + "title": "Dashboard", + "description": "sidebar label", + } + assert compressed["properties"]["config"]["x-widget"] == {"title": "Dropdown"} + + def test_prune_titles_does_not_recurse_into_default_values(self): + """A user default that happens to be a dict shaped like schema metadata + must not be corrupted — `default` holds literal values, not sub-schemas.""" + schema = { + "type": "object", + "properties": { + "config": { + "type": "object", + "default": {"title": "My Dashboard", "type": "vis"}, + } + }, + } + + compressed = compress_schema(schema, prune_titles=True) + + default = compressed["properties"]["config"]["default"] + assert default == {"title": "My Dashboard", "type": "vis"} + def test_title_pruning_with_nested_properties(self): """Test that nested property structures are handled correctly.""" schema = { diff --git a/uv.lock b/uv.lock index 94fc36233..3bddc574e 100644 --- a/uv.lock +++ b/uv.lock @@ -41,7 +41,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.86.0" +version = "0.87.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -53,9 +53,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5", size = 583820, upload-time = "2026-03-18T18:43:08.017Z" } +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/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400, upload-time = "2026-03-18T18:43:06.526Z" }, + { 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]] @@ -167,6 +167,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" }, + { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" }, + { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, + { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, + { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +] + [[package]] name = "cachetools" version = "7.0.5" @@ -559,62 +603,62 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, - { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, - { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, - { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] [[package]] @@ -733,16 +777,16 @@ wheels = [ [[package]] name = "fakeredis" -version = "2.34.1" +version = "2.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "redis" }, { name = "sortedcontainers" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/40/fd09efa66205eb32253d2b2ebc63537281384d2040f0a88bcd2289e120e4/fakeredis-2.34.1.tar.gz", hash = "sha256:4ff55606982972eecce3ab410e03d746c11fe5deda6381d913641fbd8865ea9b", size = 177315, upload-time = "2026-02-25T13:17:51.315Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/b9/c40b92cd49155a8ebbdc983cb50c02fc1c87d3a53f19aa420aefb96b00a3/fakeredis-2.35.0.tar.gz", hash = "sha256:5d1a0192c2c559e55b2d05328d86282ddd2079c1712a91e6d1b3010e0dd45ca6", size = 189000, upload-time = "2026-04-09T18:02:14.746Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/b5/82f89307d0d769cd9bf46a54fb9136be08e4e57c5570ae421db4c9a2ba62/fakeredis-2.34.1-py3-none-any.whl", hash = "sha256:0107ec99d48913e7eec2a5e3e2403d1bd5f8aa6489d1a634571b975289c48f12", size = 122160, upload-time = "2026-02-25T13:17:49.701Z" }, + { url = "https://files.pythonhosted.org/packages/0d/43/83508ccf8177a840aec118bf4d20b0c25ddca6ccecd13f1f89caabcb1a45/fakeredis-2.35.0-py3-none-any.whl", hash = "sha256:565d337a5492e8c19be33a89e7acc078374741c65cb6d4413bd8818346b8c252", size = 129578, upload-time = "2026-04-09T18:02:13.264Z" }, ] [package.optional-dependencies] @@ -786,6 +830,7 @@ dependencies = [ { name = "authlib" }, { name = "cyclopts" }, { name = "exceptiongroup" }, + { name = "griffelib" }, { name = "httpx" }, { name = "jsonref" }, { name = "jsonschema-path" }, @@ -836,9 +881,9 @@ dev = [ { name = "fastapi" }, { name = "fastmcp", extra = ["anthropic", "apps", "azure", "code-mode", "gemini", "openai", "tasks"] }, { name = "inline-snapshot", extra = ["dirty-equals"] }, - { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "9.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "loq" }, { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-sdk" }, @@ -851,6 +896,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-env" }, + { name = "pytest-examples" }, { name = "pytest-flakefinder" }, { name = "pytest-httpx" }, { name = "pytest-report" }, @@ -869,6 +915,7 @@ requires-dist = [ { name = "cyclopts", specifier = ">=4.0.0" }, { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "google-genai", marker = "extra == 'gemini'", specifier = ">=1.18.0" }, + { name = "griffelib", specifier = ">=2.0.0" }, { name = "httpx", specifier = ">=0.28.1,<1.0" }, { name = "jsonref", specifier = ">=1.1.0" }, { name = "jsonschema-path", specifier = ">=0.3.4" }, @@ -878,11 +925,11 @@ requires-dist = [ { name = "opentelemetry-api", specifier = ">=1.20.0" }, { name = "packaging", specifier = ">=24.0" }, { name = "platformdirs", specifier = ">=4.0.0" }, - { name = "prefab-ui", marker = "extra == 'apps'", specifier = ">=0.14.0" }, + { name = "prefab-ui", marker = "extra == 'apps'", specifier = ">=0.18.0" }, { name = "py-key-value-aio", extras = ["filetree", "keyring", "memory"], specifier = ">=0.4.4,<0.5.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, - { name = "pydantic-monty", marker = "extra == 'code-mode'", specifier = "==0.0.8" }, - { name = "pydocket", marker = "extra == 'tasks'", specifier = ">=0.18.0" }, + { name = "pydantic-monty", marker = "extra == 'code-mode'", specifier = "==0.0.11" }, + { name = "pydocket", marker = "extra == 'tasks'", specifier = ">=0.19.0" }, { name = "pyjwt", marker = "extra == 'azure'", specifier = ">=2.12.0" }, { name = "pyperclip", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, @@ -914,6 +961,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-cov", specifier = ">=6.1.1" }, { name = "pytest-env", specifier = ">=1.1.5" }, + { name = "pytest-examples", specifier = ">=0.0.18" }, { name = "pytest-flakefinder", specifier = ">=1.1.0" }, { name = "pytest-httpx", specifier = ">=0.35.0" }, { name = "pytest-report", specifier = ">=0.2.1" }, @@ -921,7 +969,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.25" }, + { name = "ty", specifier = ">=0.0.29" }, ] [[package]] @@ -944,7 +992,7 @@ requests = [ [[package]] name = "google-genai" -version = "1.68.0" +version = "1.69.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -958,21 +1006,30 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96", size = 511285, upload-time = "2026-03-18T01:03:18.243Z" } +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/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15", size = 750912, upload-time = "2026-03-18T01:03:15.983Z" }, + { 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.73.0" +version = "1.73.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } +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/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, + { 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.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] [[package]] @@ -1136,7 +1193,7 @@ dirty-equals = [ [[package]] name = "ipython" -version = "8.38.0" +version = "8.39.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11'", @@ -1154,14 +1211,14 @@ dependencies = [ { name = "traitlets", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/61/1810830e8b93c72dcd3c0f150c80a00c3deb229562d9423807ec92c3a539/ipython-8.38.0.tar.gz", hash = "sha256:9cfea8c903ce0867cc2f23199ed8545eb741f3a69420bfcf3743ad1cec856d39", size = 5513996, upload-time = "2026-01-05T10:59:06.901Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/df/db59624f4c71b39717c423409950ac3f2c8b2ce4b0aac843112c7fb3f721/ipython-8.38.0-py3-none-any.whl", hash = "sha256:750162629d800ac65bb3b543a14e7a74b0e88063eac9b92124d4b2aa3f6d8e86", size = 831813, upload-time = "2026-01-05T10:59:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, ] [[package]] name = "ipython" -version = "9.10.0" +version = "9.10.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.11.*'", @@ -1179,14 +1236,14 @@ dependencies = [ { name = "traitlets", marker = "python_full_version == '3.11.*'" }, { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/60/2111715ea11f39b1535bed6024b7dec7918b71e5e5d30855a5b503056b50/ipython-9.10.0.tar.gz", hash = "sha256:cd9e656be97618a0676d058134cd44e6dc7012c0e5cb36a9ce96a8c904adaf77", size = 4426526, upload-time = "2026-02-02T10:00:33.594Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/25/daae0e764047b0a2480c7bbb25d48f4f509b5818636562eeac145d06dfee/ipython-9.10.1.tar.gz", hash = "sha256:e170e9b2a44312484415bdb750492699bf329233b03f2557a9692cce6466ada4", size = 4426663, upload-time = "2026-03-27T09:53:26.244Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/aa/898dec789a05731cd5a9f50605b7b44a72bd198fd0d4528e11fc610177cc/ipython-9.10.0-py3-none-any.whl", hash = "sha256:c6ab68cc23bba8c7e18e9b932797014cc61ea7fd6f19de180ab9ba73e65ee58d", size = 622774, upload-time = "2026-02-02T10:00:31.503Z" }, + { url = "https://files.pythonhosted.org/packages/01/09/ba70f8d662d5671687da55ad2cc0064cf795b15e1eea70907532202e7c97/ipython-9.10.1-py3-none-any.whl", hash = "sha256:82d18ae9fb9164ded080c71ef92a182ee35ee7db2395f67616034bebb020a232", size = 622827, upload-time = "2026-03-27T09:53:24.566Z" }, ] [[package]] name = "ipython" -version = "9.11.0" +version = "9.12.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", @@ -1205,9 +1262,9 @@ dependencies = [ { name = "stack-data", marker = "python_full_version >= '3.12'" }, { name = "traitlets", marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354, upload-time = "2026-03-05T08:57:30.549Z" } +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/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19", size = 624222, upload-time = "2026-03-05T08:57:28.94Z" }, + { 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]] @@ -1626,9 +1683,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, ] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "openai" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1640,9 +1706,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/15/203d537e58986b5673e7f232453a2a2f110f22757b15921cbdeea392e520/openai-2.29.0.tar.gz", hash = "sha256:32d09eb2f661b38d3edd7d7e1a2943d1633f572596febe64c0cd370c86d52bec", size = 671128, upload-time = "2026-03-17T17:53:49.599Z" } +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/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl", hash = "sha256:b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a", size = 1141533, upload-time = "2026-03-17T17:53:47.348Z" }, + { 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]] @@ -1766,6 +1832,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" }, ] +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + [[package]] name = "pdbpp" version = "0.12.1" @@ -1811,15 +1886,16 @@ wheels = [ [[package]] name = "prefab-ui" -version = "0.14.0" +version = "0.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cyclopts" }, { name = "pydantic" }, { name = "rich" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/2f/a3/25fe72b9887d9c2daa0ec5e79a7971a67aad31a6f71d634e23da662343ad/prefab_ui-0.18.0.tar.gz", hash = "sha256:f72e241f52f4720baac670f8527c773e1c1f4b558bce4f77097441eecbb51b9e", size = 3998186, upload-time = "2026-03-30T01:13:33.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/75/a6534b16b7a6a7fbf7afd1901e0c25ead55f33d30c37d494c4e9ea9f2f24/prefab_ui-0.14.0-py3-none-any.whl", hash = "sha256:034a0df692a617a9361b7f88fcfff667bc56eb8f03ace46c46402269c1a8f018", size = 1741233, upload-time = "2026-03-27T02:36:03.86Z" }, + { 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]] @@ -2126,70 +2202,73 @@ wheels = [ [[package]] name = "pydantic-monty" -version = "0.0.8" +version = "0.0.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/bf/e9794b562c207406d8fda0cf4fea810943a5e8a85fe69e5505046179df16/pydantic_monty-0.0.8.tar.gz", hash = "sha256:8135e781a184f971825c1d2eb6d621598103e900f6e0d34291ff0bf35df6142f", size = 802646, upload-time = "2026-03-10T14:46:51.353Z" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/d3/6c0001ca033c0ac49be967c297c373d385c0d37ca099b6541ef7883250dc/pydantic_monty-0.0.11.tar.gz", hash = "sha256:1121b4d636f9245de358eabc42de52f2e8005ad988243e311f39dabddedadfb7", size = 957093, upload-time = "2026-04-10T08:43:14.07Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/5a/1d80f5b27717cb5842d9140b8f4033c61340a0f24588cdd06e8c1d71ec57/pydantic_monty-0.0.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1a15033d8479adf4566fa52f9067640a83e3478340757f480c9924032386a9a3", size = 6700740, upload-time = "2026-03-10T14:46:17.612Z" }, - { url = "https://files.pythonhosted.org/packages/92/62/171af2737950fabbf25cdcf7dd8b05dbe1bc6fc30fb4d40e1b4da013cc06/pydantic_monty-0.0.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6785dd86af61e0898b6dd6162db57b1fa5d971572ec30b0b60eb79f9034f0e1d", size = 6768818, upload-time = "2026-03-10T14:46:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/8d/7c/5ee596f92977dae7f9d677432da7883483be512efa0432b416d67f42155e/pydantic_monty-0.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e036bcf26fa7ac0bc8d370b7786faef9e89e4dd9e690d2e02abb96cb534f1382", size = 6504276, upload-time = "2026-03-10T14:45:45.166Z" }, - { url = "https://files.pythonhosted.org/packages/d3/a8/2edc58fe3453941f75a2933e7bffa08f30a448d3e28f81679950fafec681/pydantic_monty-0.0.8-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a7341af27a08112e78ad7879549b1c2684b6405d14891bf972a0c51b845b2a4", size = 6764775, upload-time = "2026-03-10T14:45:09.916Z" }, - { url = "https://files.pythonhosted.org/packages/b5/80/55bf6c7b76786142c14cd6dc8aa350e8cfd0e04ee256bd7b267caf105060/pydantic_monty-0.0.8-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2e1a3b4150756e6e16ea6f57c2da18839d4754b1ef564acf8750156783b8631", size = 7324970, upload-time = "2026-03-10T14:45:32.139Z" }, - { url = "https://files.pythonhosted.org/packages/21/dc/1f578581e1a09dff04df303327aac2a23160da7906d9b7898ed462980f43/pydantic_monty-0.0.8-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0133ee9335f18e049bc0c9d40e81b995b30c6ac2da0dde8549d883599f0b3791", size = 7530638, upload-time = "2026-03-10T14:46:06.424Z" }, - { url = "https://files.pythonhosted.org/packages/39/21/c783a54695d76673e1fe7285edd99b241f0743e00dd99f3645d7ddea6585/pydantic_monty-0.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d515ff3a284757016ee741bb337072a228c9ac8ebe63ff31263f337651eaf3c", size = 7298036, upload-time = "2026-03-10T14:46:46.346Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/36838e08baac3a675c382f4f27d651e4aa79a77d08b5f5efb45bc95a43a2/pydantic_monty-0.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:687bce9965b2be994cc72c6e3d6f9debf578beba7b33ce26160db209d41aed3f", size = 7186272, upload-time = "2026-03-10T14:46:26.088Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/eaad2d0c35451ce1697b8afc3d3bcab263a5bbaf71d1bee91a35a48a9a5d/pydantic_monty-0.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:aae593b5fd001f7ba026b6fe780028a0fc951c54f39e3dd1c232c225243be17d", size = 6678215, upload-time = "2026-03-10T14:46:44.914Z" }, - { url = "https://files.pythonhosted.org/packages/60/f3/031c7277082f60e43dd14490f9e50262ba6298df2793872df5f55da6ec97/pydantic_monty-0.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:199c8135a1ac4ce4eee477d529aaff1986558542109cf207bf2071cf400006da", size = 7136708, upload-time = "2026-03-10T14:45:41.191Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d7/b7d94ab8e74f2431ed315c554d00f919674c650a0d03c2f22f91f3f62d53/pydantic_monty-0.0.8-cp310-cp310-win32.whl", hash = "sha256:643b6285fc921b5a73e23dcd71315a38eebf33bd75ba10deb3736c8bf8171d2d", size = 6585407, upload-time = "2026-03-10T14:46:11.335Z" }, - { url = "https://files.pythonhosted.org/packages/21/59/53af269e465828a48b544a5d42bb3278a4ba1bf657169358ba4575713d54/pydantic_monty-0.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:85a0378ae97dbc56aecfbe9e097c5bb8b3d402f44ecf4de63016711751f9b9bb", size = 7363913, upload-time = "2026-03-10T14:46:37.859Z" }, - { url = "https://files.pythonhosted.org/packages/31/32/ac657c9e517665cc74bf47c326a2b9585b1ed0a9ebba5d624c8ad3cf3892/pydantic_monty-0.0.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b36cd5b8e380d9624a6929203a35e8e0d6e11b2bdb840553f0079000c1acf84e", size = 6700260, upload-time = "2026-03-10T14:45:19.034Z" }, - { url = "https://files.pythonhosted.org/packages/a7/0b/042f23c8211cc74b508eb7a648f7dd3ce6b39ef6fb72ebd8f1f6c060f29b/pydantic_monty-0.0.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:82adee95051eab2b04d11e4a11e852eafa2d00d8adacf62176ec25bd0d3f6a7b", size = 6767968, upload-time = "2026-03-10T14:46:14.422Z" }, - { url = "https://files.pythonhosted.org/packages/77/9a/c0d387b8b3ad6255645044afc8ac3f1cece7f8d91e819aaefeb738251634/pydantic_monty-0.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f7840f0e0d3933dc3c2f64402275d9176877ecbe565008dee657305f5c6e40f", size = 6502971, upload-time = "2026-03-10T14:46:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/d8/58/ebda094d5ac8fbdd74dfc2041d7a04289fd13a9ad19accdf22841ef00d79/pydantic_monty-0.0.8-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5a904d8d489bb6494458364874af31a8a933f7b316234fd69b25edf6b0567b9", size = 6764397, upload-time = "2026-03-10T14:46:12.921Z" }, - { url = "https://files.pythonhosted.org/packages/52/58/9a89b514b2953f85cc089149bcbc1ed4a79d9ee90cd6448ce85d71d04ece/pydantic_monty-0.0.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c44c7464c9286a8c39ee58bf45d0a477e2f281c49ce99796210874f58bbe52f7", size = 7325434, upload-time = "2026-03-10T14:46:01.563Z" }, - { url = "https://files.pythonhosted.org/packages/b1/fe/cf2c8556589d11f85ab82ff1cc2ead3f1e70a1bdbc03e6b0648bf66186c7/pydantic_monty-0.0.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94644e34771e4255772d41e31c30c9ab1f9df06860507cb5f47152760a15767d", size = 7530790, upload-time = "2026-03-10T14:45:11.566Z" }, - { url = "https://files.pythonhosted.org/packages/f8/56/b981e8e29e316d3f327142fc206c4d727ecaa1b875057110b3e754c1fc52/pydantic_monty-0.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70fa4aba236678a1d0b04e07300d404f2504351922b6aecec7aa4b4d84785d3e", size = 7297432, upload-time = "2026-03-10T14:46:36.305Z" }, - { url = "https://files.pythonhosted.org/packages/f3/4e/8b41a6e1461e52478d8ca3ef19db6e271c4b7f27e0073337b3f98e35d3a4/pydantic_monty-0.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8df1d1fa0deed81de60e43e3ab8c4a82f63998b6e04e535e681633b4eaff98b6", size = 7185726, upload-time = "2026-03-10T14:45:05.965Z" }, - { url = "https://files.pythonhosted.org/packages/eb/be/28f86c4c7679359c9a2fcd17392d7716550e87b8a4d381cf865bc1014a31/pydantic_monty-0.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f0344a975c59f5f35ca6c0c2b332d7d8d7c681449c5ce8849181ed05025b18c4", size = 6677665, upload-time = "2026-03-10T14:46:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/31/68/82374a2e8a832f72c65a4a26d20fdb756c089cbce0984d7153f70fbb05fb/pydantic_monty-0.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8aad6b5eee8d8c1dda9b63b6ea3b7c524b64b8c06ad49ba9cc015740c4f86c78", size = 7136490, upload-time = "2026-03-10T14:45:17.199Z" }, - { url = "https://files.pythonhosted.org/packages/63/46/4b487728ed5a97341a21fd54a32ec11552757e843bb69983f72eae0caa41/pydantic_monty-0.0.8-cp311-cp311-win32.whl", hash = "sha256:d7e16a25b0066e34a2694dac157fc93971f80c67c4f1b47ad6e5049423141e6a", size = 6585220, upload-time = "2026-03-10T14:45:47.124Z" }, - { url = "https://files.pythonhosted.org/packages/87/d6/b2665d7590849969da9dbde08fdc1810f8ded7891d8fff3488ceb557eda2/pydantic_monty-0.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:85cdec430afb4bbe1860aadf910c1b00832037ce08a2fb64e6012927a58ea14f", size = 7364044, upload-time = "2026-03-10T14:45:59.698Z" }, - { url = "https://files.pythonhosted.org/packages/f4/4f/7d7c7531be850469bccfbbf3cfede9e95d92b1d8e7b245d9dc77a599d6e4/pydantic_monty-0.0.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:025b380c0ed728bdf88e3ed60d5977498d4bb9da61cd81d9cabdaecce16f5755", size = 6699454, upload-time = "2026-03-10T14:46:39.422Z" }, - { url = "https://files.pythonhosted.org/packages/c0/35/074daa5fd92e4a5c1e49c8fae06036e194139feef9a51b4db82d0fee7e54/pydantic_monty-0.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76846d77ebda3414beb8c9e5da9eaef722e06606dc0307613186d888e775dc51", size = 6743273, upload-time = "2026-03-10T14:46:16.184Z" }, - { url = "https://files.pythonhosted.org/packages/55/0d/e6cb1e9e1c2e51501d8f7848c18803780164948e338350ab94769690f207/pydantic_monty-0.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b58149d6a8998ffed80f626c10bf0de7e65964aae1dfad80db8ecb00968fb1a", size = 6503552, upload-time = "2026-03-10T14:45:26.083Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a9/286f7eb6c95d7877f6f8b9bcbe26e2d988fa142cd77509e48e67302478bf/pydantic_monty-0.0.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b99bb030ff8e95b160c11702b9a6823705b7dcc1a49a2c1dccc43c2538bfe27", size = 6765377, upload-time = "2026-03-10T14:45:14.472Z" }, - { url = "https://files.pythonhosted.org/packages/74/76/85268f6305bc5b153be7c0860e69ce3b9ba916daa4a419f53d8a777e9a39/pydantic_monty-0.0.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b05b17fc2b8875e0efea047416fc0cd28a8018c040b25387454996b98540ccfc", size = 7324571, upload-time = "2026-03-10T14:45:48.786Z" }, - { url = "https://files.pythonhosted.org/packages/11/6a/2855c6149f6ba3138c7bfb009c07d528e2df12802e3216928b1289bbf233/pydantic_monty-0.0.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97fa5deef4d8cdcf60a4d8a4e7a34099deae1ad4b33acc1c33ac2e1cc348a1c2", size = 7533828, upload-time = "2026-03-10T14:46:55.386Z" }, - { url = "https://files.pythonhosted.org/packages/31/63/44b5bb5798323f7f735f5855a0d3f478d6852d9d1774427d77e31b5dbffb/pydantic_monty-0.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1aabd56af5db51c4af512142fe7ffc866b6a20dcd039a4aad5245ef42768f21", size = 7271978, upload-time = "2026-03-10T14:45:33.903Z" }, - { url = "https://files.pythonhosted.org/packages/4b/68/ce82eb571e45afbe3c0b9544fe3ebf93f841ec895fea0d39c9604a0a421f/pydantic_monty-0.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9782e58da29176a3fb0ce8ec808571d0be99c1e2ad167637225246e594e85f13", size = 7187140, upload-time = "2026-03-10T14:46:41.784Z" }, - { url = "https://files.pythonhosted.org/packages/5b/45/778bc260195ddb892f284c3cb8ab8cbcb0542e6a18f11b7e50a592b507ba/pydantic_monty-0.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ff7fb7ff3f4e830ef9b28a76e634219195b552c0dfed3471fb4910708db56221", size = 6677996, upload-time = "2026-03-10T14:46:03.163Z" }, - { url = "https://files.pythonhosted.org/packages/1e/02/8e8396b83d19ec70a09c24b0245177a595c2b7d6d092c6f6c7d3310b191c/pydantic_monty-0.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce7222d923676900827e3951e65f854d65f50b65ed8c7a84011d3472773d51bf", size = 7136513, upload-time = "2026-03-10T14:45:52.179Z" }, - { url = "https://files.pythonhosted.org/packages/fd/24/53d06af74b82043be9c4960c38bbc255eeb3de9f2a4d450f942912630bb3/pydantic_monty-0.0.8-cp312-cp312-win32.whl", hash = "sha256:4cb59e1e7b1a3d573247a871a87c88506ce9fb68bbd7648598e171cf2f04747e", size = 6582168, upload-time = "2026-03-10T14:45:58.197Z" }, - { url = "https://files.pythonhosted.org/packages/25/0f/55a16faf379139263ad852421734cb096390777d299ef7f185ec10404656/pydantic_monty-0.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:45bf11e3b795cc470a91cbd7cfeb9d96f7a60387e8da146a11bf952b4371aba7", size = 7335422, upload-time = "2026-03-10T14:45:28.301Z" }, - { url = "https://files.pythonhosted.org/packages/27/46/3268001a639052515d5a55ea2f1e087ae1e5f7aa9d7bc62c4808d731fff1/pydantic_monty-0.0.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f303f1d979213dcb365c69de3912daa70a32af019bb5ee86e086f584638974f2", size = 6698529, upload-time = "2026-03-10T14:45:39.712Z" }, - { url = "https://files.pythonhosted.org/packages/f4/22/28d6b7f8f7a1e0881c449310a6f2c8ee0cf85dc4434ae0f7e633dfcf5bcf/pydantic_monty-0.0.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8cc698da6f7f11743df7c48b959eea000e8e30511c7e1318b67543e25985062", size = 6743849, upload-time = "2026-03-10T14:46:48.023Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ab/e3dfe057af472e4065297d69aea0cf30616d01366a29e02d0a2739f22b93/pydantic_monty-0.0.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90f6da093164e99012b49b39ba1e64c166ccd126128d114f9e2c66df6fb695c4", size = 6503266, upload-time = "2026-03-10T14:46:19.594Z" }, - { url = "https://files.pythonhosted.org/packages/0a/82/d3e9aa9bd9ac69b3584216166a189e11370913ffcbd2a57a5cac6ce2d4ba/pydantic_monty-0.0.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2432ef62140554f5970991b7df41e064824741a807515658f91283c75669086", size = 6765032, upload-time = "2026-03-10T14:45:20.571Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9d/7ab11be8eff998bf9283c7bf444254cff5212f87fe3f2a9a2f7436cce6d7/pydantic_monty-0.0.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99951afb4d722212c2ce85c303b5e87d50756adaff46817ad0e60e95eb1e5141", size = 7324673, upload-time = "2026-03-10T14:45:37.957Z" }, - { url = "https://files.pythonhosted.org/packages/39/a0/7f026ece228cc990e58aa27f2ce5af26d042baaa0186cb451f3e04ff0abe/pydantic_monty-0.0.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdef70921b408378f9bc38bce4952d3c176b3f7833aa782068b310390901c516", size = 7533774, upload-time = "2026-03-10T14:46:24.265Z" }, - { url = "https://files.pythonhosted.org/packages/07/e7/72f250ffd005520ad8cdffb387241a9dd92fc70bcbdd769720918bd34495/pydantic_monty-0.0.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b5dee7bef619e7661f77bef765f483605bcd2a79c6b8bf5c910afa1c93fc40", size = 7272179, upload-time = "2026-03-10T14:45:08.085Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7c/97c87c2a315ba4376bfb83197586365003005e01d6547df7f6d77b80d13f/pydantic_monty-0.0.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5f4c6f6fb2ebae0bc80520af71e1057055413ca96fd33f13f3c612a085e50fb8", size = 7186684, upload-time = "2026-03-10T14:46:49.833Z" }, - { url = "https://files.pythonhosted.org/packages/09/42/2eea55906fee8bef7c1024bf2d35e2c301b15b562934731bdae25db448cb/pydantic_monty-0.0.8-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3ab3c558a648942d4d31e7f668d60d2a2e129751a3e8e9dc27b1e6d635e9e627", size = 6677272, upload-time = "2026-03-10T14:45:54.037Z" }, - { url = "https://files.pythonhosted.org/packages/40/0a/066e53d4693b680e39080d3af4f234c1ff9976f7621b8a49dd2a41710431/pydantic_monty-0.0.8-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:782d686d217b537e6fa9047aa3444d67b3b7cc69bf6faf34078c992ceaeb1e9e", size = 7136493, upload-time = "2026-03-10T14:45:30.014Z" }, - { url = "https://files.pythonhosted.org/packages/81/c1/46dc300f87314aef883a57ae5a35ba45a463c09f64d3d9c9f0b620672734/pydantic_monty-0.0.8-cp313-cp313-win32.whl", hash = "sha256:ef0db454757cb92974890b11c7b0969d8c0d95944c821f6fc6cd23846d1d2aa4", size = 6581014, upload-time = "2026-03-10T14:46:07.886Z" }, - { url = "https://files.pythonhosted.org/packages/b0/2a/cf156df19a1612ca5ddcbd982645e54c5485e83215d9532cc9aff791f854/pydantic_monty-0.0.8-cp313-cp313-win_amd64.whl", hash = "sha256:dbf8c7cfaff2b345f8c1bfba98fdc282e790fef5c601f37c8d5355ccd45073de", size = 7335041, upload-time = "2026-03-10T14:46:21.371Z" }, - { url = "https://files.pythonhosted.org/packages/a5/69/5bedc7ad67fdd9e4f04007477f5c414b54c51d47c5dfdd7abad4c78663aa/pydantic_monty-0.0.8-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:47990770ed74af1e8e2160a7dd925aa1e4fd1bf4ae9658ffd1cdc32eb5c8c6e8", size = 6700454, upload-time = "2026-03-10T14:45:56.247Z" }, - { url = "https://files.pythonhosted.org/packages/bf/35/06aaa9c766a83e7e95219bfb6cb88bd0a87803f85eb3f596b82da0cb3009/pydantic_monty-0.0.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9534f6236b3ebdd09f0f7d71e508e68736733d24cdda2a6c0a758914261c75e5", size = 6760180, upload-time = "2026-03-10T14:45:22.982Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b8/eaa4a4f0b3a1c343773317027bb5d1e11a1b0c1ad01d3f0921a7f547d346/pydantic_monty-0.0.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76c4ffd8275d520628732d82e7410e5f1f69ef5af274d676f1f2f9221fd85a34", size = 6504414, upload-time = "2026-03-10T14:45:12.984Z" }, - { url = "https://files.pythonhosted.org/packages/9d/79/dbb0875ad2b565d7ef1981feda4438ab743130f4031107aaadc9212488dd/pydantic_monty-0.0.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:badaa22749fa7a22ee2cb88f6deb48fc191cb2f41237dd630593259ed9f30b67", size = 6766768, upload-time = "2026-03-10T14:46:29.12Z" }, - { url = "https://files.pythonhosted.org/packages/25/cb/bd6bef8fa2cfe807dd5ef36ab8ce6d094ecdad0050cf57dec4c8a438d413/pydantic_monty-0.0.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e39104684eda1ae3c290e3a4369bef9d00f9b637be8515802bc1523fecb5160d", size = 7326073, upload-time = "2026-03-10T14:45:50.679Z" }, - { url = "https://files.pythonhosted.org/packages/aa/44/b64b6e2857519d5fdc66f74998019e585e5c3bf2cf8deb7b77419d29db2a/pydantic_monty-0.0.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2e5b3b11b794f82504bb6929f4101f0db84933986aee9b46ce7561add152e9", size = 7535251, upload-time = "2026-03-10T14:46:04.645Z" }, - { url = "https://files.pythonhosted.org/packages/da/11/4e1524d94e33427990cffd74eaf94a023724c872f11d42ac90eebd74ccc0/pydantic_monty-0.0.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a94fc87b2a9b1b66a09f35b819b1c7e7da7702b21c923cdca21fa13129486b45", size = 7288739, upload-time = "2026-03-10T14:45:35.609Z" }, - { url = "https://files.pythonhosted.org/packages/2c/7d/7e667c72c9742a6725b04d21faf50e8f552a0e23da9ca1cbeba891961c9a/pydantic_monty-0.0.8-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:70b7479e7bd47d274fbd6278a625289e71b1a4aad400f40c7a4a89fe9a50952c", size = 7189195, upload-time = "2026-03-10T14:46:09.399Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3f/3243277e2c6bf4c3e4684fbc78c316a8965dbbb012b573b38978d8628bfc/pydantic_monty-0.0.8-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:48b8c9cb9a96a5f28b03669771368a444c8b50c758b6a5bd0d148fa02db3f65b", size = 6679333, upload-time = "2026-03-10T14:45:24.494Z" }, - { url = "https://files.pythonhosted.org/packages/0c/51/88152b89f5288e9d3cd28e71f79a6567421e20d8f00b0514b7819a29a8bb/pydantic_monty-0.0.8-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b005f10798a1b58a12b5643f69a7b54208bd4965d16ca2bd21d13fca4d1a4f67", size = 7137771, upload-time = "2026-03-10T14:46:33.043Z" }, - { url = "https://files.pythonhosted.org/packages/d8/38/baf5a66ed72b931de7ce92251822d6ffb752582389ff4f4c853742cee029/pydantic_monty-0.0.8-cp314-cp314-win32.whl", hash = "sha256:b143bba29c274e15424a097af6ae85a1e81e6c2fb7184ee7a93fbf10997dbbd3", size = 6584038, upload-time = "2026-03-10T14:46:34.53Z" }, - { url = "https://files.pythonhosted.org/packages/71/80/bfda690914fa15c68f8be9e824e62c4054118a9a72304735221446a89014/pydantic_monty-0.0.8-cp314-cp314-win_amd64.whl", hash = "sha256:f4bc9185bb5f37f3220a978889b4bc6f822a41ee4d5523c17eef4d869aca66e8", size = 7351170, upload-time = "2026-03-10T14:46:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/e7/b2/9a025227019997e73fa9c2461c0f4f95598afa5703c38e108350aab48228/pydantic_monty-0.0.11-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:4f50370b54d5567a825fa6603f7b924c605b7a550c46c9e59b2fcfb7fdbfdfbb", size = 7236324, upload-time = "2026-04-10T08:42:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/1b3c0e18fec3231bfbacc0b7328f9e2f6617599bb1009602ebb0e7c80112/pydantic_monty-0.0.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66d54d8dcab015375ffc133456c60b4af1f6c8d976d2321185d58702abe8b28a", size = 7238432, upload-time = "2026-04-10T08:42:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/018999571d8a25177d864d6f864fe7b1d4ad3411fe82a93849abf87ea87e/pydantic_monty-0.0.11-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0bc23e99d8f9f43dc5f82092b263a75fbd8a8548d609e4218d9e2b877e71797f", size = 7775876, upload-time = "2026-04-10T08:42:01.409Z" }, + { url = "https://files.pythonhosted.org/packages/ae/44/ccd034c18394016a26e0aa90cf49b991a69250827fa75084cc0f3f281366/pydantic_monty-0.0.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcf2f5b3c8e932054749b6b7855fc0fc26e45d20927d312b4d1f28bba6d7b411", size = 7024307, upload-time = "2026-04-10T08:42:06.131Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/6da8f73d643607d0220f1b4894b0bb0e683823af66f8c88a46f713912251/pydantic_monty-0.0.11-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f5c7b9d96775d39fc1f9d802f8bfae4afc9535faa90b1ad612a54038a93d62a", size = 7320798, upload-time = "2026-04-10T08:43:33.603Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/e518ffbed301a60be3953d10de9d3a35b1b36aa25ad2ea76cacfccf296b2/pydantic_monty-0.0.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fbd2b15ae5e7f008c4f0b0e73ce590fa682a68021962427689b6ffc6fe01612f", size = 7863101, upload-time = "2026-04-10T08:43:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/ea/bc/8b456b98993832432a7aaf77a044a073edf44eab7d604d3769bf20d99cde/pydantic_monty-0.0.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ba0a6b1cb5663c2d3b187eab78fce064a83e0635e8c43310dacf51e69cb3cb3", size = 8084738, upload-time = "2026-04-10T08:42:31.494Z" }, + { url = "https://files.pythonhosted.org/packages/84/61/8a6f901764e0153e41db865f4f2271a1da9a68a0c18e3ba4439492c94818/pydantic_monty-0.0.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3642f710ec67cf1f76703bdfdaf57db567f80d48f24d49846908ae27c6d7737d", size = 7777165, upload-time = "2026-04-10T08:42:36.587Z" }, + { url = "https://files.pythonhosted.org/packages/57/27/f3439c3c45a8e6f9f2588b48cdb54c6860fd42a80f40f27c94ba5a67f389/pydantic_monty-0.0.11-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b32e058611593b5e5848f3aa6f9e9ef4eab627bbb174b1621103a7a70844951b", size = 7203026, upload-time = "2026-04-10T08:42:46.102Z" }, + { url = "https://files.pythonhosted.org/packages/59/82/cf705a97356059c59b2709e0cf3834e077c61af2219f7cd5f1b89cee550d/pydantic_monty-0.0.11-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:98c421cdd8b1e86861a1cfdbefb1f6a5fc423164b2556b7dd05caed351f0a561", size = 7658538, upload-time = "2026-04-10T08:42:47.851Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e1/de383194dee3d941c3e986a42bf46dda35283a18e925bccb6f3b64a57e17/pydantic_monty-0.0.11-cp310-cp310-win32.whl", hash = "sha256:016ce1d3952d7533a4ffdbe792819c1907c0fcc541115eebd8772dc9bde00184", size = 7161196, upload-time = "2026-04-10T08:43:15.62Z" }, + { url = "https://files.pythonhosted.org/packages/60/25/67ffb83b8207a7cc7f8b15a5377e04ae927df19ed94fc9dd1f69f7960b75/pydantic_monty-0.0.11-cp310-cp310-win_amd64.whl", hash = "sha256:7e21a7a07388a88f20b9dbfdfc69ff203f70d8403c4badfb659a5827d314dec8", size = 7948770, upload-time = "2026-04-10T08:43:23.523Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/a11e80602e1be5c6fe28cc07cfae4b5a3a04d6e64b9c0f958a414b0f17ce/pydantic_monty-0.0.11-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e88c63151680806e02133ca701fe1b3e0510488f2cd7068a98e8372f4f61ce86", size = 7235713, upload-time = "2026-04-10T08:42:35.042Z" }, + { url = "https://files.pythonhosted.org/packages/dd/eb/272699c60bf22079888b34e3e7709a937e6cad7c177f2dfcbcac35b0d17d/pydantic_monty-0.0.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c435e1bef5847ae31e9eab56880d426e058fcd5ca5d4320098acb0802f19d3d2", size = 7237749, upload-time = "2026-04-10T08:42:00.03Z" }, + { url = "https://files.pythonhosted.org/packages/39/6d/21504def7be88147e72784d047171b9018f30bc58c3a4bb85d476a808659/pydantic_monty-0.0.11-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:651f4983e9e1caaf0bf5b5079bbc4bc9c158f118e526980ef9f40353b5bb3548", size = 7775637, upload-time = "2026-04-10T08:42:49.309Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9d/013e96547f380b969ba5729131227750c5e3c5cac519c2d096f48397b44d/pydantic_monty-0.0.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:843ca8ccad4727d587a236b447bd04c262a1f2251f9e7f7b02f9544247489aba", size = 7022082, upload-time = "2026-04-10T08:42:50.987Z" }, + { url = "https://files.pythonhosted.org/packages/18/e4/d5ad31149f623474ac5dee305799af93657edb9214f35c13f62bb37325ad/pydantic_monty-0.0.11-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:27bc453f12bac132a56fee6ccb050d6f5c95557b211891d7bf3c92c4bb5fd9a8", size = 7320346, upload-time = "2026-04-10T08:41:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/59/48/02137358f9dccb7484cf4b49a12de8eebdeff77746761bdb2feab14faade/pydantic_monty-0.0.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992a9d815dbec113750b637c017422da7970fd90c84268497926942a2536d1fd", size = 7863177, upload-time = "2026-04-10T08:42:10.888Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/1b5c609a9c36e84195215e28368f469c590eefa3ed777b15ce8e74f9ab02/pydantic_monty-0.0.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4d0f5885b530bd5e696b9e1ca836093e87b04b185084b02f3da65eaf7598d42", size = 8084041, upload-time = "2026-04-10T08:41:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/2b/68/e8a763e86bc27cad654760a3a655e9021e29f460903cd740b31cfa95a66d/pydantic_monty-0.0.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dbcee8b42beb1bbdee3db5700be42c8ceb6ffe218f44959c885d116c7d69a6a", size = 7776281, upload-time = "2026-04-10T08:41:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/1c/41/eafcaf17f31c3265bd68fbcc948f75072bd7253dab675dc89d7415324d86/pydantic_monty-0.0.11-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:77bbee4b4662f7e5672bdf61b1e455922ad62ef5aba1541e345894faa75da2e6", size = 7202304, upload-time = "2026-04-10T08:43:20.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/c9/2837a1d0942a618734d1a7e48f9d945651558c24b6c493a91900b3259477/pydantic_monty-0.0.11-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cf2faf360930cd605308338d3cd7e3a7bc99e61850529bb50e46432f99e89e1c", size = 7657780, upload-time = "2026-04-10T08:43:30.251Z" }, + { url = "https://files.pythonhosted.org/packages/6a/f9/6658ff92784bffe5b0bdf87716de08693036cac857776934b8c9d293a8fb/pydantic_monty-0.0.11-cp311-cp311-win32.whl", hash = "sha256:efa316bd83decff35ccc0a3df5c50f2ce28b8dfa4400e89801f410f9bb6c9ad1", size = 7159655, upload-time = "2026-04-10T08:41:54.693Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/7024c7e6f575c955a3cc5947981acc783e991e1acbc583a8f3c03cbd3452/pydantic_monty-0.0.11-cp311-cp311-win_amd64.whl", hash = "sha256:a0e435bf73c67ac0bbcbc22b68194444efdbe8243a7adc1cb6b2a7e014844ac2", size = 7948212, upload-time = "2026-04-10T08:42:23.654Z" }, + { url = "https://files.pythonhosted.org/packages/30/40/330facc0aaf25e8a1de5583ac5579f0545081592dd84453afd8ca5e1dec5/pydantic_monty-0.0.11-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:952ade018d3ddc8e12f29bc8f1c67f1f74fecb21c5a63f03d3999cbdcbb63c2d", size = 7238003, upload-time = "2026-04-10T08:42:43.133Z" }, + { url = "https://files.pythonhosted.org/packages/2d/00/fe732e8cf982f4381d675590164ca658ede6177984e390d398a5df1b4f30/pydantic_monty-0.0.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:91b3a86d4f8f163309c549182901d35afef75bd2b9e7669e07a66e6810d97cc9", size = 7206536, upload-time = "2026-04-10T08:42:33.289Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f8/6bc6005043a062f69317b22b2f70f4f7784726612c1897c788ba87546854/pydantic_monty-0.0.11-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a910461566ad6189afd281336f6de1bad3320a7bc98fee8564764ac747ebdba", size = 7784031, upload-time = "2026-04-10T08:43:11.223Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a0/c19cf84a64729adced84b75a8f7915d70d81e9b03e2a4d4e6dfb4cd10dba/pydantic_monty-0.0.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33e1efa2ab38e6f18a5c31b3a8ad813668447024afe13fb742120888c986e4a8", size = 7027865, upload-time = "2026-04-10T08:42:29.702Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d6/0435097f39f10f7740d70bb72d8169487327631efbd0f629605dc6e8058b/pydantic_monty-0.0.11-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f78d7f99bea6ebd88ad04d7a70b6133a3d948f4322763c87b565d53bf3c9ec87", size = 7325144, upload-time = "2026-04-10T08:42:28.006Z" }, + { url = "https://files.pythonhosted.org/packages/20/77/55fe7a4ea476ef7a75273885cee5fa4bd8c7c5f4ab83ba4c9f089b5b2f30/pydantic_monty-0.0.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c33438d19ef04b75dc785e4f5be274c3a736b7cabd22b6ad454a24f017af9d2c", size = 7867559, upload-time = "2026-04-10T08:43:09.799Z" }, + { url = "https://files.pythonhosted.org/packages/09/6b/2614f9b195429bb8dba5103c661edbc8ca7f41f1da2982b5dd479eda469a/pydantic_monty-0.0.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a18a2fd7fe9f6c887011908366c7dbbd02f4203f5ffb14e92f7d42d33287c18", size = 8092050, upload-time = "2026-04-10T08:42:12.776Z" }, + { url = "https://files.pythonhosted.org/packages/54/0c/278ebeb147c158b626da0f9ff765c0b1557abf08b7f17df2b3669f08888d/pydantic_monty-0.0.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0e7a6690d982ed026ab7f61b87bfed9a4529c87828e6a86e572090165739050", size = 7739476, upload-time = "2026-04-10T08:42:39.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/db/739d71a949e5bc3131f232b7c91b8e0c5d2491b33a6e7c37cb37dffc541b/pydantic_monty-0.0.11-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6e0c956cbab0fa723edf500e7b08855695477e5ef845169362f3a8ae690d170c", size = 7205251, upload-time = "2026-04-10T08:42:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a5/94edffcc43163afc58339a3d07dfa1725eff0ad782d5ad0581df459d3e33/pydantic_monty-0.0.11-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0fa61f9b76704f25cc48b244026b710546129b30faf2ee4da1c36b40c2ea3273", size = 7664487, upload-time = "2026-04-10T08:42:07.529Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d0/4af51b5c89fcb14cb710a8ed3790233f4002260e1244b1ba4b4e7f403df9/pydantic_monty-0.0.11-cp312-cp312-win32.whl", hash = "sha256:c5d9fe5938f63010e10fcacaabf32587d1781a3bf1689eb3bcc0af073b2afb1b", size = 7157728, upload-time = "2026-04-10T08:42:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9e/1cac5569d57ef367171c1cd30282842cdf14db660ab06d4aec6ed3eed26a/pydantic_monty-0.0.11-cp312-cp312-win_amd64.whl", hash = "sha256:8ef95ad81bb0de344f1e34849b36dee31bf35a36bc40a74b3df8bf7f3c00fa6e", size = 7909877, upload-time = "2026-04-10T08:43:04.89Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b8/49c7af21ec758fb47ffe5c5f0611e741e2a8a51101dd4a103c0cc4a702a0/pydantic_monty-0.0.11-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a625856444b0203fd3ec32e4e94e133439ac3e021b97d2ca5ab5ffd15bc99914", size = 7237031, upload-time = "2026-04-10T08:43:32.191Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/d3b81826ad47702d289571dee41c38070885c0a77c9f2887007f014a6c78/pydantic_monty-0.0.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:363cae0d0c125029f75904f37cbfaf5a516db8ba83567a993f71f7d21ec31a45", size = 7207011, upload-time = "2026-04-10T08:42:25.837Z" }, + { url = "https://files.pythonhosted.org/packages/11/a7/ce75fe5c2176d468daf0b1f025915c79f00dfaccd68993827befdf5bcb31/pydantic_monty-0.0.11-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5e69b49d018f3d13c63293fc22c47d2f60092be8182b19e674f1b528927fa102", size = 7782400, upload-time = "2026-04-10T08:43:06.675Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/323cad3114e9c32e80b1b6e4c2ff7966c931137d6ec99272229bb1c323d6/pydantic_monty-0.0.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9486990f4d41c65fd7e48bb6a45cc6816e19f8b40c4e2b01c1920a1b2adf46b8", size = 7026512, upload-time = "2026-04-10T08:42:22.226Z" }, + { url = "https://files.pythonhosted.org/packages/0a/10/95f0874dc458afd9fea55f6a9eaaeb0f645ed2082b7dc0af6bf82724c28f/pydantic_monty-0.0.11-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ea47c27326279462044665de77820489538cc497a7abd6412aba2d378d717420", size = 7324470, upload-time = "2026-04-10T08:42:37.98Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/750fd08b5d2141e6345e67304dca9347bf9efdee711aceffb8a4a4d701f4/pydantic_monty-0.0.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3c0bee427bfe312a1732886370fc6d35e240323dec71b761fcee1c12ffc2d15", size = 7865637, upload-time = "2026-04-10T08:43:18.732Z" }, + { url = "https://files.pythonhosted.org/packages/e5/40/682c099b6a839ddcc999e2e0afef29699b64446f7f007f77479806bb3b3e/pydantic_monty-0.0.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6fad668bcf6a7e01137032c9fbd6b775b004898db55ec2f9616ce7a50daa1aa", size = 8090877, upload-time = "2026-04-10T08:43:03.478Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d2abdb9577d9fb293c292bde268fe772c77a8ee2c77064cc1b6941f75a95/pydantic_monty-0.0.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e294e53388c624aaf48f120d92f00c87f84ba7ad6df0f29a9f2ec04ad60a4e68", size = 7739267, upload-time = "2026-04-10T08:42:17.749Z" }, + { url = "https://files.pythonhosted.org/packages/1e/63/e29a7de18600a87f53ac1086e30f417d1bdeab0a678a32c980f87c57723a/pydantic_monty-0.0.11-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f41a586119077ef57eb01b5f5aabe35ef5c5b73b8c79252c2f04560638ac68a8", size = 7204973, upload-time = "2026-04-10T08:41:58.266Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/6d62ce6a18d2793799cf04a1be857476ec5bde1077cd008a8a01412a955d/pydantic_monty-0.0.11-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:76748f20089e5ba095a2052a6e2cb1d17c0cded9e6fc26c773c84707ddfd5bcf", size = 7664511, upload-time = "2026-04-10T08:42:52.556Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ae/a475cd1c08953c67d15337efb482cafedc834bc86d663fd88e5d02f2b30b/pydantic_monty-0.0.11-cp313-cp313-win32.whl", hash = "sha256:d116f1b2a43b565d9b8288023b74f34e90c8e4fca89341101df483f062318c26", size = 7156329, upload-time = "2026-04-10T08:42:14.473Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/4965c779a39715a889d9089281c8a5b0db2fe3ae89e280580c92b37f5e86/pydantic_monty-0.0.11-cp313-cp313-win_amd64.whl", hash = "sha256:21d041c0e8219ee482d8ee1cae087ce2ee22709471fe8ac2daf2bcd7a3cb82a8", size = 7909809, upload-time = "2026-04-10T08:42:54.088Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b2/6fb79ff02f7e91d7eab5ec3f00a7a4412424090d892bedc589e6a506cb2e/pydantic_monty-0.0.11-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1ebab5a44d5cd0542d366d0b0c50ab88edfe9f72f333d3431e9ba621c1ee1b24", size = 7238995, upload-time = "2026-04-10T08:43:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/99/a0/a47b30d2f691ae83ba0f7becdfd2f64f6826e335897a5226c8eadad82d57/pydantic_monty-0.0.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:341af3417cc6bf8679e11eb6f3cb6078eb333a5b89a4a63cb11468c69569ca1a", size = 7229322, upload-time = "2026-04-10T08:42:09.324Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4c/27ed83fcdb957e2fb7cad88e89c9b3c9037fc12704dbd26a644ea51b9c83/pydantic_monty-0.0.11-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c5b2d21d336a3e78e615596e081b242f2a3950932bdde7ebabc39c4f4f3d81b5", size = 7784706, upload-time = "2026-04-10T08:43:26.698Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d0/919ce241fbcb1f67b315d7f6545358cc3a0d1af472f8e5009eafc9732013/pydantic_monty-0.0.11-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e69143561fb8642b63c2e6c018d640fcdf69fef845eb67b532c2f9f19dc4df", size = 7027325, upload-time = "2026-04-10T08:43:21.996Z" }, + { url = "https://files.pythonhosted.org/packages/ca/23/171a70d51cf4b4bd508b6d55fcd61f2f6ccfc8289802b8ebcb947019c43d/pydantic_monty-0.0.11-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:97bcf9555723734a84fa1e63c895ff816f6dab2f9ccd5a75331e230e5a6f9de4", size = 7326348, upload-time = "2026-04-10T08:43:12.776Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/d984f0febbad36b046d638aa10593a338b8ff015d1c3dabe33376b54aaaa/pydantic_monty-0.0.11-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adf58d73b843fb1b56d88d84b282b2f41ad5cbeaeef7ad5e0cd71223ba374026", size = 7869575, upload-time = "2026-04-10T08:42:41.533Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/cd3c19a73f4d44b0a87b77485b34d67c63c194b530dc7ce712bef39ee7cf/pydantic_monty-0.0.11-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:138732192362a7642e9583ca05d00a13cea5f8b8a484e09ac772d9c310a0361a", size = 8093301, upload-time = "2026-04-10T08:43:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/4b6fb1ea95eb1e7684e4bb5f47513d39b458285665960626ce922419a489/pydantic_monty-0.0.11-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abca7f71bc0911b9657e9705e8d475bd71ceee1e8a2f783413da33e7f732aaf9", size = 7764168, upload-time = "2026-04-10T08:43:28.257Z" }, + { url = "https://files.pythonhosted.org/packages/11/34/5a9f61490cf14d503015a28db2de21005bdd8f30d3d9f46735ef9e7077b3/pydantic_monty-0.0.11-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:f706d0c3f9d18794b2d424fa2a027613287a45b6a6f2f83af381efb31713b284", size = 7205370, upload-time = "2026-04-10T08:42:55.852Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4a/50353df9b111e1eee5e4cd35fb9b4b4f185580c9d1da83107b6335277e11/pydantic_monty-0.0.11-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:d062ee58e068e916ed7391406e7adbafa9c75f954c69b1d5090eb473338c47c9", size = 7665711, upload-time = "2026-04-10T08:42:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7b/c3fcf57f2211232a3827f0847c0d6491c975b53480345c9b09a8ceccc74a/pydantic_monty-0.0.11-cp314-cp314-win32.whl", hash = "sha256:c9cd1aece23203a39b5d092f90acfa7d379e230b7e10277b0e43eeef0c882cd7", size = 7159314, upload-time = "2026-04-10T08:42:57.692Z" }, + { url = "https://files.pythonhosted.org/packages/88/02/d781f48a0f0c8871b6b01dc20762a47e6a56e25fddbc6326d92883f35c17/pydantic_monty-0.0.11-cp314-cp314-win_amd64.whl", hash = "sha256:d0dd58a271ffb6d91a49f26521c18d4ff5d21e47bbb562b82d2b94fb5f2e4a4c", size = 7933387, upload-time = "2026-04-10T08:42:19.243Z" }, ] [[package]] @@ -2208,7 +2287,7 @@ wheels = [ [[package]] name = "pydocket" -version = "0.18.2" +version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cloudpickle" }, @@ -2227,18 +2306,18 @@ dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "uncalled-for" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/5f/82dde9fb6099b960a4203596d3b755d1bd2c0d0210fea104d015d6515d7f/pydocket-0.18.2.tar.gz", hash = "sha256:cc2051d15557f83bb164a83b0743fa9c12c2bfe9a9145cff3a5922b4935ce4f5", size = 354762, upload-time = "2026-03-10T13:09:22.52Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/6e/0db603ce4d82072b1a61798340e408ec04b3a77647f537881ff5b93c31f6/pydocket-0.19.0.tar.gz", hash = "sha256:00bff620d80cd2fad34ccbbe526dce24a9de8cdc1d2b94d305739668a98e308a", size = 355531, upload-time = "2026-04-10T17:25:38.112Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/cf/8c1b6340baf81d7f6c97fe0181bda7cfd500d5e33bf469fbffbdae07b3c9/pydocket-0.18.2-py3-none-any.whl", hash = "sha256:19e48de15e83370f750e362610b777533ff9c0fa48bf36766ed581f91d266556", size = 99041, upload-time = "2026-03-10T13:09:20.598Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/7bed93ecff9015c4a8dcabfaab3d490b45ec8e5847b30ac9671b9c01def8/pydocket-0.19.0-py3-none-any.whl", hash = "sha256:8531e64b989673a17d055ee4498ca8c3505310c5af4e7fd09c7b00fb2f29aa19", size = 99271, upload-time = "2026-04-10T17:25:36.657Z" }, ] [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -2401,6 +2480,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/16/ad52f56b96d851a2bcfdc1e754c3531341885bd7177a128c13ff2ca72ab4/pytest_env-1.6.0-py3-none-any.whl", hash = "sha256:1e7f8a62215e5885835daaed694de8657c908505b964ec8097a7ce77b403d9a3", size = 10400, upload-time = "2026-03-12T22:39:41.887Z" }, ] +[[package]] +name = "pytest-examples" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "black" }, + { name = "pytest" }, + { name = "ruff" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/71/4ae972fd95f474454aa450108ee1037830e7ba11840363e981b8d48fd16a/pytest_examples-0.0.18.tar.gz", hash = "sha256:9a464f007f805b113677a15e2f8942ebb92d7d3eb5312e9a405d018478ec9801", size = 21237, upload-time = "2025-05-06T07:46:10.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/52/7bbfb6e987d9a8a945f22941a8da63e3529465f1b106ef0e26f5df7c780d/pytest_examples-0.0.18-py3-none-any.whl", hash = "sha256:86c195b98c4e55049a0df3a0a990ca89123b7280473ab57608eecc6c47bcfe9c", size = 18169, upload-time = "2025-05-06T07:46:09.349Z" }, +] + [[package]] name = "pytest-flakefinder" version = "1.1.0" @@ -2483,11 +2576,11 @@ wheels = [ [[package]] name = "python-json-logger" -version = "4.0.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, ] [[package]] @@ -2499,6 +2592,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -2785,27 +2917,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.7" +version = "0.15.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +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/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, - { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, - { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, - { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, - { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, - { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, - { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, + { 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]] @@ -2850,15 +2982,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.3.3" +version = "3.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" } +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/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" }, + { 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]] @@ -2912,56 +3044,56 @@ wheels = [ [[package]] name = "tomli" -version = "2.4.0" +version = "2.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] @@ -2987,26 +3119,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.25" +version = "0.0.29" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/12/bf/3c3147c7237277b0e8a911ff89de7183408be96b31fb42b38edb666d287f/ty-0.0.25.tar.gz", hash = "sha256:8ae3891be17dfb6acab51a2df3a8f8f6c551eb60ea674c10946dc92aae8d4401", size = 5375500, upload-time = "2026-03-24T22:32:34.608Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d5/853561de49fae38c519e905b2d8da9c531219608f1fccc47a0fc2c896980/ty-0.0.29.tar.gz", hash = "sha256:e7936cca2f691eeda631876c92809688dbbab68687c3473f526cd83b6a9228d8", size = 5469221, upload-time = "2026-04-05T15:01:21.328Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/a4/6c289cbd1474285223124a4ffb55c078dbe9ae1d925d0b6a948643c7f115/ty-0.0.25-py3-none-linux_armv6l.whl", hash = "sha256:26d6d5aede5d54fb055779460f896d9c1473c6fb996716bd11cb90f027d8fee7", size = 10452747, upload-time = "2026-03-24T22:32:32.662Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/74cb9de356b9ceb3f281ab048f8c4ac2207122161b0ac0066886ce129abe/ty-0.0.25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aedcfbc7b6b96dbc55b0da78fa02bd049373ff3d8a827f613dadd8bd17d10758", size = 10271349, upload-time = "2026-03-24T22:32:13.041Z" }, - { url = "https://files.pythonhosted.org/packages/0e/93/ffc5a20cc9e14fa9b32b0c54884864bede30d144ce2ae013805bce0c86d0/ty-0.0.25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0a8fb3c1e28f73618941811e2568dca195178a1a6314651d4ee97086a4497253", size = 9730308, upload-time = "2026-03-24T22:32:19.24Z" }, - { url = "https://files.pythonhosted.org/packages/6d/78/52e05ef32a5f172fce70633a4e19d8e04364271a4322ae12382c7344b0de/ty-0.0.25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814870b7f347b5d0276304cddb98a0958f08de183bf159abc920ebe321247ad4", size = 10247664, upload-time = "2026-03-24T22:32:08.669Z" }, - { url = "https://files.pythonhosted.org/packages/c2/64/0d0a47ed0aa1d634c666c2cc15d3b0af4b95d0fd3dbb796032bd493f3433/ty-0.0.25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:781150e23825dc110cd5e1f50ca3d61664f7a5db5b4a55d5dbf7d3b1e246b917", size = 10261961, upload-time = "2026-03-24T22:32:43.935Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ba/4666b96f0499465efb97c244554107c541d74a1add393e62276b3de9b54f/ty-0.0.25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc81ff2a0143911321251dc81d1c259fa5cdc56d043019a733c845d55409e2a", size = 10746076, upload-time = "2026-03-24T22:32:26.37Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ed/aa958ccbcd85cc206600e48fbf0a1c27aef54b4b90112d9a73f69ed0c739/ty-0.0.25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f03c5c5b5c10355ea030cbe3cd93b2e759b9492c66688288ea03a68086069f2e", size = 11287331, upload-time = "2026-03-24T22:32:21.607Z" }, - { url = "https://files.pythonhosted.org/packages/26/e4/f4a004e1952e6042f5bfeeb7d09cffb379270ef009d9f8568471863e86e6/ty-0.0.25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fc1ef49cd6262eb9223ccf6e258ac899aaa53e7dc2151ba65a2c9fa248dfa75", size = 11028804, upload-time = "2026-03-24T22:32:39.088Z" }, - { url = "https://files.pythonhosted.org/packages/56/32/5c15bb8ea20ed54d43c734f253a2a5da95d41474caecf4ef3682df9f68f5/ty-0.0.25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad98da1393161096235a387cc36abecd31861060c68416761eccdb7c1bc326b", size = 10845246, upload-time = "2026-03-24T22:32:41.33Z" }, - { url = "https://files.pythonhosted.org/packages/6f/fe/4ddd83e810c8682fcfada0d1c9d38936a34a024d32d7736075c1e53a038e/ty-0.0.25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2d4336aa5381eb4eab107c3dec75fe22943a648ef6646f5a8431ef1c8cdabb66", size = 10233515, upload-time = "2026-03-24T22:32:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/ad/db/9fe54f6fb952e5b218f2e661e64ed656512edf2046cfbb9c159558e255db/ty-0.0.25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e10ed39564227de2b7bd89398250b65daaedbef15a25cef8eee70078f5d9e0b2", size = 10275289, upload-time = "2026-03-24T22:32:28.21Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e0/090d7b33791b42bc7ec29463ac6a634738e16b289e027608ebe542682773/ty-0.0.25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aca04e9ed9b61c706064a1c0b71a247c3f92f373d0222103f3bc54b649421796", size = 10461195, upload-time = "2026-03-24T22:32:24.252Z" }, - { url = "https://files.pythonhosted.org/packages/42/31/5bf12bce01b80b72a7a4e627380779b41510e730f6000862a1d078e423f7/ty-0.0.25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:18a5443e4ef339c1bd8c57fc13112c22080617ea582bfc22b497d82d65361325", size = 10931471, upload-time = "2026-03-24T22:32:14.985Z" }, - { url = "https://files.pythonhosted.org/packages/6a/5e/ab60c11f8a6dd2a0ae96daac83458ef2e9be1ae70481d1ad9c59d3eaf20f/ty-0.0.25-py3-none-win32.whl", hash = "sha256:a685b9a611b69195b5a557e05dbb7ebcd12815f6c32fb27fdf15edeb1fa33d8f", size = 9835974, upload-time = "2026-03-24T22:32:36.86Z" }, - { url = "https://files.pythonhosted.org/packages/41/55/625acc2ef34646268bc2baa8fdd6e22fb47cd5965e2acd3be92c687fb6b0/ty-0.0.25-py3-none-win_amd64.whl", hash = "sha256:0d4d37a1f1ab7f2669c941c38c65144ff223eb51ececd7ccfc0d623afbc0f729", size = 10815449, upload-time = "2026-03-24T22:32:11.031Z" }, - { url = "https://files.pythonhosted.org/packages/82/c7/0147bfb543df97740b45b222c54ff79ef20fa57f14b9d2c1dab3cd7d3faa/ty-0.0.25-py3-none-win_arm64.whl", hash = "sha256:d80b8cd965cbacbfd887ac2d985f5b6da09b7aa3569371e2894e0b30b26b89cd", size = 10225494, upload-time = "2026-03-24T22:32:30.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/b7/911f9962115acfa24e3b2ec9d4992dd994c38e8769e1b1d7680bb4d28a51/ty-0.0.29-py3-none-linux_armv6l.whl", hash = "sha256:b8a40955f7660d3eaceb0d964affc81b790c0765e7052921a5f861ff8a471c30", size = 10568206, upload-time = "2026-04-05T15:01:19.165Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/fcae2167d4c77a97269f92f11d1b43b03617f81de1283d5d05b43432110c/ty-0.0.29-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6b6849adae15b00bbe2d3c5b078967dcb62eba37d38936b8eeb4c81a82d2e3b8", size = 10442530, upload-time = "2026-04-05T15:01:28.471Z" }, + { url = "https://files.pythonhosted.org/packages/97/33/5a6bfa240cfcb9c36046ae2459fa9ea23238d20130d8656ff5ac4d6c012a/ty-0.0.29-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dcdd9b17209788152f7b7ea815eda07989152325052fe690013537cc7904ce49", size = 9915735, upload-time = "2026-04-05T15:01:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/318f45fae232118e81a6306c30f50de42c509c412128d5bd231eab699ffb/ty-0.0.29-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d8ed4789bae78ffaf94462c0d25589a734cab0366b86f2bbcb1bb90e1a7a169", size = 10419748, upload-time = "2026-04-05T15:01:32.375Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/5687872e2ab5a0f7dd4fd8456eac31e9381ad4dc74961f6f29965ad4dd91/ty-0.0.29-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91ec374b8565e0ad0900011c24641ebbef2da51adbd4fb69ff3280c8a7eceb02", size = 10394738, upload-time = "2026-04-05T15:01:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/015d118097eeb95e6a44c4abce4c0a28b7b9dfb3085b7f0ee48e4f099633/ty-0.0.29-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:298a8d5faa2502d3810bbbb47a030b9455495b9921594206043c785dd61548cf", size = 10910613, upload-time = "2026-04-05T15:01:17.17Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/47ce3c6c53e0670eadbe80756b167bf80ed6681d1ba57cfde2e8065a13d1/ty-0.0.29-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c8fba1a3524c6109d1e020d92301c79d41bf442fa8d335b9fa366239339cb70", size = 11475750, upload-time = "2026-04-05T15:01:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cf/e361845b1081c9264ad5b7c963231bab03f2666865a9f2a115c4233f2137/ty-0.0.29-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c48adf88a70d264128c39ee922ed14a947817fced1e93c08c1a89c9244edcde", size = 11190055, upload-time = "2026-04-05T15:01:12.369Z" }, + { url = "https://files.pythonhosted.org/packages/79/12/0fb0857e9a62cb11586e9a712103877bbf717f5fb570d16634408cfdefee/ty-0.0.29-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ce0a7a0e96bc7b42518cd3a1a6a6298ef64ff40ca4614355c1aa807059b5c6f", size = 11020539, upload-time = "2026-04-05T15:01:37.022Z" }, + { url = "https://files.pythonhosted.org/packages/20/36/5a26753802083f80cd125db6c4348ad42b3c982ec36e718e0bf4c18f75e5/ty-0.0.29-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6ac86a05b4a3731d45365ab97780acc7b8146fa62fccb3cbe94fe6546c67a97", size = 10396399, upload-time = "2026-04-05T15:01:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/00/e6/b4e75b5752239ab3ab400f19faef4dbef81d05aab5d3419fda0c062a3765/ty-0.0.29-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6bbbf53141af0f3150bf288d716263f1a3550054e4b3551ca866d38192ba9891", size = 10421461, upload-time = "2026-04-05T15:01:08.367Z" }, + { url = "https://files.pythonhosted.org/packages/c0/21/1084b5b609f9abed62070ec0b31c283a403832a6310c8bbc208bd45ee1e6/ty-0.0.29-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1c9e06b770c1d0ff5efc51e34312390db31d53fcf3088163f413030b42b74f84", size = 10599187, upload-time = "2026-04-05T15:01:23.52Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a1/ce19a2ca717bbcc1ee11378aba52ef70b6ce5b87245162a729d9fdc2360f/ty-0.0.29-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0307fe37e3f000ef1a4ae230bbaf511508a78d24a5e51b40902a21b09d5e6037", size = 11121198, upload-time = "2026-04-05T15:01:15.22Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6b/f1430b279af704321566ce7ec2725d3d8258c2f815ebd93e474c64cd4543/ty-0.0.29-py3-none-win32.whl", hash = "sha256:7a2a898217960a825f8bc0087e1fdbaf379606175e98f9807187221d53a4a8ed", size = 9995331, upload-time = "2026-04-05T15:01:01.32Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ef/3ef01c17785ff9a69378465c7d0faccd48a07b163554db0995e5d65a5a23/ty-0.0.29-py3-none-win_amd64.whl", hash = "sha256:fc1294200226b91615acbf34e0a9ad81caf98c081e9c6a912a31b0a7b603bc3f", size = 11023644, upload-time = "2026-04-05T15:01:04.432Z" }, + { url = "https://files.pythonhosted.org/packages/2c/55/87280a994d6a2d2647c65e12abbc997ed49835794366153c04c4d9304d76/ty-0.0.29-py3-none-win_arm64.whl", hash = "sha256:f9794bbd1bb3ce13f78c191d0c89ae4c63f52c12b6daa0c6fe220b90d019d12c", size = 10428165, upload-time = "2026-04-05T15:01:34.665Z" }, ] [[package]]