diff --git a/.github/workflows/run-upgrade-checks.yml b/.github/workflows/run-upgrade-checks.yml
index 180c25bc0..c94720ba7 100644
--- a/.github/workflows/run-upgrade-checks.yml
+++ b/.github/workflows/run-upgrade-checks.yml
@@ -121,7 +121,7 @@ jobs:
- **ty (type checker)**: New ty releases frequently add stricter checks that flag previously-accepted code. Run `uv run ty check` locally with the latest ty to reproduce. Fix the type errors or bump the ty version floor in `pyproject.toml`.
- **ruff**: New lint rules or stricter defaults in a ruff upgrade.
- - **mcp SDK**: Breaking changes in the `mcp` package (new method signatures, renamed types).
+ - **MCP SDK**: Breaking changes in the `mcp` package (new method signatures, renamed types).
### What to do
diff --git a/docs/apps/architecture.mdx b/docs/apps/architecture.mdx
index 26588c2f8..7ecab2aa3 100644
--- a/docs/apps/architecture.mdx
+++ b/docs/apps/architecture.mdx
@@ -1,119 +1,118 @@
---
-title: App Architecture
+title: Architecture
sidebarTitle: Architecture
description: How FastMCP apps work under the hood — from Python to pixels.
icon: sitemap
-tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
-This page explains how Prefab apps work under the hood — how your Python code becomes an interactive UI inside a host client's conversation. You don't need any of this to build apps, but the mental model is useful when something isn't rendering the way you expect, when tool calls from the UI aren't reaching your server, or when you're building [custom HTML apps](/apps/low-level) and need to understand the protocol directly.
+You don't need this page to build apps. It's for when something isn't rendering the way you expect, when UI tool calls aren't reaching your server, or when you're writing [custom HTML apps](/apps/low-level) and need to understand the protocol directly.
-## The Pipeline
+## The pipeline
-An MCP App moves through five stages from Python to pixels:
+An MCP app moves through five stages from Python to pixels:
```
Python components → JSON tree → structuredContent → Renderer iframe → Host UI
```
-You write Prefab components in Python. FastMCP serializes them to a JSON component tree and delivers it as `structuredContent` on the tool result. The host loads the Prefab renderer in a sandboxed iframe, pushes the JSON into it, and the renderer paints the UI. If the UI needs to call server tools, it talks back through the same `postMessage` channel.
+You write Prefab components. FastMCP serializes them to a JSON component tree and delivers it as `structuredContent` on the tool result. The host loads the Prefab renderer in a sandboxed iframe, pushes the JSON in, and the renderer paints the UI. If the UI calls server tools, it talks back through the same `postMessage` channel.
-The following sections walk through each stage.
+The sections below walk each stage.
-## Tool Registration
+## Tool registration
When you mark a tool with `app=True` or `@app.ui()`, FastMCP wires up the metadata and renderer resource that the protocol requires.
-### The `app=True` Flag
+### The `app=True` flag
-The `app` parameter on `@mcp.tool` accepts `True`, an `AppConfig` object, or a dict. When you pass `True`, FastMCP checks whether the tool's return type is a Prefab type (`PrefabApp`, `Component`, or unions containing them). If the tool qualifies, FastMCP expands `True` into a full `AppConfig` — setting the renderer URI, CSP headers, and visibility — and stores it in the tool's `meta["ui"]` dict.
+`app` on `@mcp.tool` accepts `True`, an `AppConfig`, or a dict. When you pass `True`, FastMCP checks whether the tool's return type is a Prefab type (`PrefabApp`, `Component`, or unions containing them). If it qualifies, FastMCP expands `True` into a full `AppConfig` — setting the renderer URI, CSP headers, and visibility — and stores it in the tool's `meta["ui"]` dict.
-This expansion also triggers registration of the shared Prefab renderer resource (discussed below). The tool and the renderer are linked through a `resourceUri` field in the metadata: the tool says "render me with `ui://prefab/renderer.html`", and the host fetches that resource when it needs to display the result.
+This expansion also registers the shared Prefab renderer resource (below). The tool and the renderer are linked through a `resourceUri` field in the metadata: the tool says "render me with `ui://prefab/renderer.html`" and the host fetches that resource when it displays the result.
-Type inference works the same way. If your return type annotation is a Prefab type and you haven't set `app` explicitly, FastMCP auto-wires the metadata as if you'd written `app=True`.
+Type inference works the same way. If the return type is a Prefab type and you haven't set `app` explicitly, FastMCP auto-wires the metadata as if you'd written `app=True`.
-### FastMCPApp Registration
+### FastMCPApp registration
-`FastMCPApp` uses the same underlying mechanism but adds two things. First, it tags every tool — both `@app.ui()` entry points and `@app.tool()` backends — with `meta["fastmcp"]["app"]` set to the app's name. This tag is how the server identifies which app a tool belongs to when routing calls from the UI.
+`FastMCPApp` uses the same mechanism but adds two things. First, it tags every tool — both `@app.ui()` entry points and `@app.tool()` backends — with `meta["fastmcp"]["app"]` set to the app's name. That tag lets the server identify which app a tool belongs to when routing UI calls.
-Second, it sets `meta["ui"]["visibility"]` to control who can see each tool. Entry points default to `["model"]` (visible to the LLM). Backend tools default to `["app"]` (visible only to the UI). Hosts use this to filter the tool list — the model sees entry points, and the UI sees backends.
+Second, it sets `meta["ui"]["visibility"]` to control who can see each tool. Entry points default to `["model"]` (LLM-visible). Backend tools default to `["app"]` (UI-only). Hosts use this to filter the tool list.
## Serialization
-When a Prefab tool runs, its return value — a `PrefabApp` or a raw `Component` — needs to become a JSON blob that the renderer can interpret.
+When a Prefab tool runs, its return value — a `PrefabApp` or a bare `Component` — becomes a JSON blob the renderer can interpret.
-### PrefabApp.to_json()
+### `PrefabApp.to_json()`
-The serialization entry point is `PrefabApp.to_json()`. This method walks the component tree and produces a JSON object with three top-level keys: `view` (the component tree), `state` (initial state values), and `_meta` (routing metadata).
+The entry point is `PrefabApp.to_json()`. It walks the component tree and produces a JSON object with three top-level keys: `view` (the component tree), `state` (initial state values), and `_meta` (routing metadata).
-FastMCP passes a `tool_resolver` callback to `to_json()`. Whenever the component tree contains a `CallTool` action that references a function (not a string), the resolver converts it to a `ResolvedTool` with the function's registered name. This is how `CallTool(save_contact)` becomes `CallTool("save_contact")` in the wire format. The resolver also handles `unwrap_result` — a flag that tells the renderer to unwrap single-value results from the `{"result": value}` envelope that FastMCP uses for schema compliance.
+FastMCP passes a `tool_resolver` callback to `to_json()`. Whenever the tree contains a `CallTool` action that references a function (not a string), the resolver converts it to a `ResolvedTool` with the function's registered name. This is how `CallTool(save_contact)` becomes `CallTool("save_contact")` on the wire. The resolver also handles `unwrap_result` — a flag telling the renderer to unwrap single-value results from the `{"result": value}` envelope FastMCP uses for schema compliance.
-### The _meta.fastmcp.app Tag
+### The `_meta.fastmcp.app` tag
-After `to_json()` produces the JSON tree, FastMCP injects `_meta.fastmcp.app` with the app's name (if the tool belongs to a `FastMCPApp`). This tag rides along inside `structuredContent` all the way to the renderer.
+After `to_json()` produces the tree, FastMCP injects `_meta.fastmcp.app` with the app's name (if the tool belongs to a `FastMCPApp`). This tag rides along inside `structuredContent` all the way to the renderer.
-When the renderer calls a backend tool, it includes `_meta.fastmcp.app` in the `CallTool` request. The server sees this tag and routes the call through a special path that bypasses transforms — more on this in the next section.
+When the renderer calls a backend tool, it includes `_meta.fastmcp.app` in the `CallTool` request. The server sees this tag and routes the call through a special path that bypasses transforms (below).
-### ToolResult Assembly
+### ToolResult assembly
The final tool result has two parts: `content` (a list of `TextContent` blocks for the LLM) and `structuredContent` (the JSON tree for the renderer). By default, Prefab tools send `"[Rendered Prefab UI]"` as the text content — just enough for the LLM to know something was rendered. If you return a `ToolResult` explicitly, you control both halves.
-## Tool Call Routing
+## Tool call routing
-When a host calls a tool, the server needs to find it. Normal tool calls go through the provider chain, which applies transforms (namespace prefixes, visibility filters, etc.) before resolving the tool by name. But app UI calls need a different path.
+Normal tool calls go through the provider chain, which applies transforms (namespace prefixes, visibility filters) before resolving by name. App UI calls need a different path.
-### The get_app_tool Bypass
+### The `get_app_tool` bypass
-Backend tools registered with `@app.tool()` are typically hidden from the model (`visibility=["app"]`). Visibility transforms would filter them out of normal resolution. And namespace transforms might rename them — `save_contact` becomes `contacts_save_contact` — but the renderer still uses the original name.
+Backend tools are typically hidden from the model (`visibility=["app"]`). Visibility transforms would filter them out of normal resolution. And namespace transforms might rename them — `save_contact` becomes `contacts_save_contact` — while the renderer still uses the original name.
-`get_app_tool` solves both problems. When the server sees `_meta.fastmcp.app` on an incoming `CallTool` request, it calls `get_app_tool(app_name, tool_name)` instead of the normal `get_tool(name)`. This method walks the provider tree directly, skipping the transform chain entirely. It finds the tool by its original registered name and verifies that its `meta["fastmcp"]["app"]` matches the expected app identity.
+`get_app_tool` solves both problems. When the server sees `_meta.fastmcp.app` on an incoming `CallTool` request, it calls `get_app_tool(app_name, tool_name)` instead of the normal `get_tool(name)`. This walks the provider tree directly, skipping transforms. It finds the tool by its original registered name and verifies that its `meta["fastmcp"]["app"]` matches the expected app.
-This is why `CallTool("save_contact")` keeps working even when the server is mounted under a namespace prefix. The renderer sends the original name plus the app identity; the server uses `get_app_tool` to find the tool without transforms getting in the way.
+That's why `CallTool("save_contact")` keeps working when the server is mounted under a namespace. The renderer sends the original name plus the app identity; the server uses `get_app_tool` to find it without transforms in the way.
-Authorization checks still apply — `get_app_tool` bypasses transforms, but it runs auth checks against the tool's `auth` configuration before executing.
+Authorization still applies. `get_app_tool` bypasses transforms but runs auth checks against the tool's `auth` config before executing.
-### Provider Delegation
+### Provider delegation
-The `get_app_tool` method is defined on the `Provider` base class and overridden by aggregate and wrapped providers. Aggregate providers fan out the lookup across all child providers in parallel. Wrapped providers (like `FastMCPProvider`, which wraps a nested `FastMCP` server) delegate to the inner server's `get_app_tool`. This means backend tools are reachable through any depth of server composition.
+`get_app_tool` is defined on the `Provider` base class and overridden by aggregate and wrapped providers. Aggregate providers fan out the lookup across child providers in parallel. Wrapped providers (like `FastMCPProvider`, which wraps a nested `FastMCP` server) delegate to the inner server's `get_app_tool`. Backend tools are reachable through any depth of composition.
-## The Renderer
+## The renderer
The Prefab renderer is a self-contained JavaScript application that interprets the JSON component tree and renders it as a React UI.
-### The Shared Resource
+### The shared resource
-FastMCP registers the renderer as a `ui://prefab/renderer.html` resource with MIME type `text/html;profile=mcp-app`. The renderer HTML is bundled inside the `prefab-ui` Python package — `get_renderer_html()` returns it as a string. All Prefab tools on a server share this single resource, regardless of how many tools or apps are registered.
+FastMCP registers the renderer as a `ui://prefab/renderer.html` resource with MIME type `text/html;profile=mcp-app`. The HTML is bundled inside the `prefab-ui` Python package; `get_renderer_html()` returns it as a string. All Prefab tools on a server share this single resource.
-The resource also carries CSP metadata (via `get_renderer_csp()`) declaring which CDN domains the renderer needs to load its JavaScript dependencies. Hosts use this to configure the iframe's Content Security Policy.
+The resource also carries CSP metadata (via `get_renderer_csp()`) declaring the CDN domains the renderer needs. Hosts use this to configure the iframe's Content Security Policy.
-### postMessage Communication
+### `postMessage` communication
-The renderer lives in a sandboxed iframe. It communicates with the host using `postMessage` — the standard browser API for cross-origin iframe communication. The protocol follows the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) specification:
+The renderer lives in a sandboxed iframe and communicates with the host using `postMessage`. The protocol follows the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) spec:
-The host pushes the tool result (including `structuredContent`) into the iframe. The renderer parses the JSON component tree, initializes state, and renders the UI. When the user interacts with the UI — submitting a form, clicking a button — and that interaction triggers a `CallTool` action, the renderer sends a `callServerTool` message back to the host via `postMessage`. The host forwards this as a regular MCP `tools/call` request to the server, including `_meta.fastmcp.app` for routing.
+The host pushes the tool result (with `structuredContent`) into the iframe. The renderer parses the component tree, initializes state, and renders the UI. When the user interacts — submitting a form, clicking a button — and the interaction triggers a `CallTool` action, the renderer sends a `callServerTool` message back to the host via `postMessage`. The host forwards it as a regular MCP `tools/call` request to the server, including `_meta.fastmcp.app` for routing.
-The response flows back the same way: server to host, host to iframe via `postMessage`, renderer updates state with the result.
+The response flows back the same way: server → host → iframe via `postMessage`, and the renderer updates state with the result.
### AppBridge
-The `@modelcontextprotocol/ext-apps` JavaScript SDK provides the `App` class (sometimes called AppBridge) that manages the `postMessage` handshake. It handles connection negotiation, tool result delivery, server tool calls, and host context (like safe area insets and theme preferences). The Prefab renderer uses this SDK internally — you only interact with it directly when building [custom HTML apps](/apps/low-level).
+The `@modelcontextprotocol/ext-apps` JavaScript SDK provides the `App` class (sometimes called AppBridge) that manages the `postMessage` handshake. It handles connection negotiation, tool result delivery, server tool calls, and host context (safe area insets, theme preferences). The Prefab renderer uses it internally; you only touch it directly when building [custom HTML apps](/apps/low-level).
-## The Dev Server
+## The dev server
-`fastmcp dev apps` provides a local preview environment that simulates the host-side behavior without requiring a real MCP host client.
+`fastmcp dev apps` simulates the host-side behavior locally without a real MCP client.
-### Proxy Architecture
+### Proxy architecture
-The dev server runs two HTTP servers. Your MCP server starts on port 8000 (configurable) with the Streamable HTTP transport. The dev UI runs on port 8080 and serves a picker page that lists your app tools.
+Two HTTP servers. Your MCP server runs on port 8000 with the Streamable HTTP transport. The dev UI runs on port 8080 and serves a picker page that lists your app tools.
-A reverse proxy at `/mcp` on the dev server forwards requests to your MCP server. This is important because the renderer iframe runs on `localhost:8080`, and your MCP server runs on `localhost:8000`. Without the proxy, the renderer's `callServerTool` requests would be cross-origin and blocked by the browser. The proxy makes everything same-origin from the iframe's perspective.
+A reverse proxy at `/mcp` on the dev server forwards requests to your MCP server. This matters because the renderer iframe runs on `localhost:8080` and your MCP server runs on `localhost:8000` — without the proxy, the renderer's `callServerTool` requests would be cross-origin and the browser would block them. The proxy keeps everything same-origin from the iframe's perspective.
-### The Launch Flow
+### The launch flow
-When you select a tool and click launch, the dev UI calls the tool through the proxy, receives the `structuredContent` response, and opens a new tab. That tab loads the tool's renderer resource (fetched from the proxy) in an iframe, creates an AppBridge instance, and pushes the tool result into the renderer. From this point forward, the experience matches what a real host would provide — the renderer displays the UI, and any `CallTool` actions route back through the proxy to your MCP server.
+When you select a tool and click launch, the dev UI calls the tool through the proxy, receives the `structuredContent` response, and opens a new tab. That tab loads the tool's renderer resource (via the proxy), creates an AppBridge, and pushes the tool result into the renderer. From here on it matches what a real host provides: the renderer displays the UI, and any `CallTool` actions route back through the proxy to your server.
-Auto-reload is enabled by default, so changes to your server code restart the MCP server automatically. The dev UI stays running — just re-launch the tool to see your changes.
+Auto-reload is on by default, so changes to your server code restart the MCP server automatically. The dev UI keeps running — relaunch the tool to see changes.
diff --git a/docs/apps/demos/bar-chart.html b/docs/apps/demos/bar-chart.html
new file mode 100644
index 000000000..81372d859
--- /dev/null
+++ b/docs/apps/demos/bar-chart.html
@@ -0,0 +1,76 @@
+
+
+
+ Prefab
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/apps/demos/bar-chart.py b/docs/apps/demos/bar-chart.py
new file mode 100644
index 000000000..e2430b981
--- /dev/null
+++ b/docs/apps/demos/bar-chart.py
@@ -0,0 +1,23 @@
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import Column
+from prefab_ui.components.charts import BarChart, ChartSeries
+
+data = [
+ {"quarter": "Q1", "revenue": 42000, "costs": 28000},
+ {"quarter": "Q2", "revenue": 51000, "costs": 31000},
+ {"quarter": "Q3", "revenue": 47000, "costs": 29000},
+ {"quarter": "Q4", "revenue": 63000, "costs": 35000},
+]
+
+with PrefabApp() as app:
+ with Column(css_class="p-6"):
+ BarChart(
+ data=data,
+ series=[
+ ChartSeries(data_key="revenue", label="Revenue"),
+ ChartSeries(data_key="costs", label="Costs"),
+ ],
+ x_axis="quarter",
+ show_legend=True,
+ height=250,
+ )
diff --git a/docs/apps/demos/contacts.html b/docs/apps/demos/contacts.html
new file mode 100644
index 000000000..5831d639c
--- /dev/null
+++ b/docs/apps/demos/contacts.html
@@ -0,0 +1,172 @@
+
+
+
+ Prefab
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/apps/demos/contacts.py b/docs/apps/demos/contacts.py
new file mode 100644
index 000000000..0cbe60c0b
--- /dev/null
+++ b/docs/apps/demos/contacts.py
@@ -0,0 +1,78 @@
+from prefab_ui.actions import ShowToast
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import (
+ H3,
+ Badge,
+ Button,
+ Column,
+ DataTable,
+ DataTableColumn,
+ Form,
+ Input,
+ Row,
+ Select,
+ SelectOption,
+ Separator,
+)
+
+contacts = [
+ {"name": "Arthur Dent", "email": "arthur@earth.com", "category": "Customer"},
+ {"name": "Ford Prefect", "email": "ford@betelgeuse.org", "category": "Partner"},
+ {
+ "name": "Trillian Astra",
+ "email": "trillian@heartofgold.com",
+ "category": "Customer",
+ },
+ {"name": "Zaphod Beeblebrox", "email": "zaphod@galaxy.gov", "category": "Vendor"},
+]
+
+rows = [
+ {
+ "name": c["name"],
+ "email": c["email"],
+ "category": Badge(
+ c["category"],
+ variant="success"
+ if c["category"] == "Customer"
+ else "secondary"
+ if c["category"] == "Partner"
+ else "outline",
+ ),
+ }
+ for c in contacts
+]
+
+with PrefabApp() as app:
+ with Column(gap=4, css_class="p-6"):
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="email", header="Email"),
+ DataTableColumn(key="category", header="Category"),
+ ],
+ rows=rows,
+ search=True,
+ )
+
+ Separator()
+
+ H3("Add Contact")
+ with Form(
+ on_submit=ShowToast(
+ "Contact saved! (preview demo — no backend wired)",
+ variant="success",
+ ),
+ ):
+ with Row(gap=4):
+ Input(name="name", label="Name", placeholder="Full name", required=True)
+ Input(
+ name="email",
+ label="Email",
+ placeholder="name@example.com",
+ required=True,
+ )
+ with Select(name="category", label="Category"):
+ SelectOption(value="Customer", label="Customer")
+ SelectOption(value="Partner", label="Partner")
+ SelectOption(value="Vendor", label="Vendor")
+ Button("Save Contact")
diff --git a/docs/apps/demos/dashboard.html b/docs/apps/demos/dashboard.html
new file mode 100644
index 000000000..21c1c8924
--- /dev/null
+++ b/docs/apps/demos/dashboard.html
@@ -0,0 +1,157 @@
+
+
+
+ Prefab
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/apps/demos/dashboard.py b/docs/apps/demos/dashboard.py
new file mode 100644
index 000000000..06fe6285d
--- /dev/null
+++ b/docs/apps/demos/dashboard.py
@@ -0,0 +1,68 @@
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import (
+ Badge,
+ Column,
+ DataTable,
+ DataTableColumn,
+ Row,
+ Separator,
+)
+from prefab_ui.components.charts import BarChart, ChartSeries
+from prefab_ui.components.metric import Metric
+
+monthly = [
+ {"month": "Jan", "revenue": 48200, "costs": 31000},
+ {"month": "Feb", "revenue": 52100, "costs": 32500},
+ {"month": "Mar", "revenue": 61800, "costs": 34200},
+ {"month": "Apr", "revenue": 58400, "costs": 33800},
+]
+
+deals = [
+ {"account": "Acme Corp", "value": "$84,000", "stage": "Won"},
+ {"account": "Globex Inc", "value": "$52,000", "stage": "Negotiation"},
+ {"account": "Initech", "value": "$31,500", "stage": "Proposal"},
+ {"account": "Wayne Enterprises", "value": "$45,000", "stage": "Lost"},
+]
+
+rows = [
+ {
+ "account": d["account"],
+ "value": d["value"],
+ "stage": Badge(
+ d["stage"],
+ variant="success"
+ if d["stage"] == "Won"
+ else "destructive"
+ if d["stage"] == "Lost"
+ else "secondary",
+ ),
+ }
+ for d in deals
+]
+
+total = sum(m["revenue"] for m in monthly)
+
+with PrefabApp() as app:
+ with Column(gap=4, css_class="p-6"):
+ with Row(gap=6):
+ Metric(label="Revenue (Q1-Q4)", value=f"${total:,}")
+ Metric(label="Deals", value=f"{len(deals)}")
+ BarChart(
+ data=monthly,
+ series=[
+ ChartSeries(data_key="revenue", label="Revenue"),
+ ChartSeries(data_key="costs", label="Costs"),
+ ],
+ x_axis="month",
+ show_legend=True,
+ height=200,
+ )
+ Separator()
+ DataTable(
+ columns=[
+ DataTableColumn(key="account", header="Account", sortable=True),
+ DataTableColumn(key="value", header="Value", sortable=True),
+ DataTableColumn(key="stage", header="Stage"),
+ ],
+ rows=rows,
+ )
diff --git a/docs/apps/demos/data-table.html b/docs/apps/demos/data-table.html
new file mode 100644
index 000000000..fe84abcd4
--- /dev/null
+++ b/docs/apps/demos/data-table.html
@@ -0,0 +1,90 @@
+
+
+
+ Prefab
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/apps/demos/data-table.py b/docs/apps/demos/data-table.py
new file mode 100644
index 000000000..5100237bf
--- /dev/null
+++ b/docs/apps/demos/data-table.py
@@ -0,0 +1,24 @@
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import Column, DataTable, DataTableColumn
+
+employees = [
+ {"name": "Alice Chen", "role": "Staff Engineer", "dept": "Platform"},
+ {"name": "Bob Martinez", "role": "Lead Designer", "dept": "Design"},
+ {"name": "Carol Johnson", "role": "Senior Engineer", "dept": "Platform"},
+ {"name": "David Kim", "role": "Product Manager", "dept": "Product"},
+ {"name": "Eva Mueller", "role": "Engineer", "dept": "Platform"},
+ {"name": "Frank Lee", "role": "Data Scientist", "dept": "ML"},
+ {"name": "Grace Park", "role": "Eng Manager", "dept": "Platform"},
+]
+
+with PrefabApp() as app:
+ with Column(gap=4, css_class="p-6"):
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="role", header="Role", sortable=True),
+ DataTableColumn(key="dept", header="Dept", sortable=True),
+ ],
+ rows=employees,
+ search=True,
+ )
diff --git a/docs/apps/demos/hitchhikers.html b/docs/apps/demos/hitchhikers.html
new file mode 100644
index 000000000..7f26f9796
--- /dev/null
+++ b/docs/apps/demos/hitchhikers.html
@@ -0,0 +1,1105 @@
+
+
+
+ Prefab Showcase
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/apps/demos/hitchhikers.py b/docs/apps/demos/hitchhikers.py
new file mode 100644
index 000000000..1554e5165
--- /dev/null
+++ b/docs/apps/demos/hitchhikers.py
@@ -0,0 +1,461 @@
+"""The Hitchhiker's Guide dashboard from the Prefab welcome page.
+
+Run with:
+ prefab serve examples/hitchhikers-guide/dashboard.py
+ prefab export examples/hitchhikers-guide/dashboard.py
+"""
+
+from prefab_ui import PrefabApp
+from prefab_ui.actions import SetInterval, SetState, ShowToast
+from prefab_ui.components import (
+ Alert,
+ AlertDescription,
+ AlertTitle,
+ Badge,
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+ Carousel,
+ Checkbox,
+ Column,
+ Combobox,
+ ComboboxOption,
+ DataTable,
+ DataTableColumn,
+ DatePicker,
+ Dialog,
+ Grid,
+ GridItem,
+ HoverCard,
+ Loader,
+ Metric,
+ Muted,
+ P,
+ Progress,
+ Radio,
+ RadioGroup,
+ Ring,
+ Row,
+ Separator,
+ Slider,
+ Switch,
+ Text,
+ Tooltip,
+)
+from prefab_ui.components.charts import (
+ BarChart,
+ ChartSeries,
+ RadarChart,
+ Sparkline,
+)
+from prefab_ui.components.control_flow import Else, If
+from prefab_ui.rx import Rx
+
+ctx_tick = Rx("ctx_tick")
+
+# Context window: climbs from 24% to ~78%, then resets
+ctx_pct = (ctx_tick % 20) * 3 + 20
+ctx_variant = (ctx_pct > 70).then(
+ "destructive", (ctx_pct <= 33).then("success", "default")
+)
+
+with PrefabApp(
+ title="Prefab Showcase",
+ state={"ctx_tick": 0, "improbability": 42},
+ on_mount=SetInterval(
+ 400,
+ on_tick=SetState("ctx_tick", ctx_tick + 1),
+ ),
+) as app:
+ with Grid(columns={"default": 1, "md": 2, "lg": 4}, gap=4):
+ # ── Col 1 ─────────────────────────────────────────────────────────
+ with Column(gap=4):
+ with Card():
+ with CardHeader():
+ CardTitle("Register Towel")
+ CardDescription("The most important item in the galaxy")
+ with CardContent():
+ with Column(gap=3):
+ with Combobox(
+ placeholder="Type...",
+ search_placeholder="Search types...",
+ ):
+ ComboboxOption("Bath", value="bath")
+ ComboboxOption("Beach", value="beach")
+ ComboboxOption("Interstellar", value="interstellar")
+ ComboboxOption("Microfiber", value="micro")
+ DatePicker(placeholder="Registration date")
+ with CardFooter():
+ with Row(gap=2):
+ with Dialog(
+ title="Towel Registered!",
+ description="Your towel has been added to the galactic registry.",
+ ):
+ Button("Register")
+ Text("Don't forget to bring it.")
+ Button("Cancel", variant="outline")
+ with If("{{ !pressed }}"):
+ Button(
+ "This is probably the best button to press.",
+ variant="success",
+ on_click=SetState("pressed", True),
+ )
+ with Else():
+ Button(
+ "Please do not press this button again.",
+ variant="destructive",
+ on_click=SetState("pressed", False),
+ )
+
+ with Card():
+ with CardHeader():
+ CardTitle("Ship Status")
+ with CardContent():
+ with Column(gap=3):
+ with Row(
+ align="center",
+ css_class="justify-between",
+ ):
+ Text("heart-of-gold")
+ with HoverCard(open_delay=0, close_delay=200):
+ Badge("In Orbit", variant="default")
+ with Column(gap=2):
+ Text("heart-of-gold")
+ Muted("Deployed 2h ago")
+ Progress(
+ value=100,
+ max=100,
+ variant="success",
+ )
+ Progress(
+ value=100,
+ max=100,
+ indicator_class="bg-yellow-400",
+ )
+ with Row(
+ align="center",
+ css_class="justify-between",
+ ):
+ Text("vogon-poetry")
+ with Tooltip("64% — ETA 12 min", delay=0):
+ with Badge(variant="secondary"):
+ Loader(size="sm")
+ Text("Deploying")
+ Progress(value=64, max=100)
+ with Row(
+ align="center",
+ css_class="justify-between",
+ ):
+ Text("deep-thought")
+ with Tooltip(
+ "Computing... 7.5 million years remaining",
+ delay=0,
+ ):
+ with Badge(variant="outline"):
+ Loader(size="sm", variant="ios")
+ Text("Soon...")
+ Progress(value=12, max=100)
+ with Card():
+ with CardHeader():
+ CardTitle("Planet Ratings")
+ with CardContent():
+ RadarChart(
+ data=[
+ {"axis": "Views", "earth": 30, "mag": 95},
+ {"axis": "Fjords", "earth": 65, "mag": 100},
+ {"axis": "Pubs", "earth": 90, "mag": 10},
+ {"axis": "Mice", "earth": 40, "mag": 85},
+ {"axis": "Tea", "earth": 95, "mag": 15},
+ {"axis": "Safety", "earth": 45, "mag": 70},
+ ],
+ series=[
+ ChartSeries(dataKey="earth", label="Earth"),
+ ChartSeries(dataKey="mag", label="Magrathea"),
+ ],
+ axis_key="axis",
+ height=200,
+ show_legend=True,
+ show_tooltip=True,
+ )
+
+ # ── Col 2 ─────────────────────────────────────────────────────────
+ with Column(gap=4):
+ with Card():
+ with CardHeader():
+ CardTitle("Survival Odds")
+ with CardContent(css_class="w-fit mx-auto"):
+ Ring(
+ value=42,
+ label="42%",
+ variant="info",
+ size="lg",
+ thickness=12,
+ indicator_class="group-hover:drop-shadow-[0_0_24px_rgba(59,130,246,0.9)]",
+ )
+ with Card():
+ with CardHeader():
+ with Row(gap=2, align="center"):
+ CardTitle("Improbability Drive")
+ Loader(
+ variant="pulse",
+ size="sm",
+ css_class="text-blue-500",
+ )
+ with CardContent():
+ with Column(gap=2):
+ Slider(
+ min=0,
+ max=100,
+ value=42,
+ name="improbability",
+ )
+ with Row(
+ align="center",
+ css_class="justify-between",
+ ):
+ Muted("Probable")
+ Muted("Infinite")
+ with Carousel(auto_advance=3000, show_controls=False, direction="up"):
+ with Alert(variant="success", icon="circle-check"):
+ AlertTitle("Don't Panic")
+ AlertDescription("Normality achieved.")
+ with Alert(variant="destructive", icon="triangle-alert"):
+ AlertTitle("Display Department")
+ AlertDescription("Beware of the leopard.")
+ with Card():
+ with CardHeader():
+ CardTitle("Prefect Horizon Config")
+ with CardContent():
+ with Column(gap=3):
+ Switch(
+ label="Auto-scale agents",
+ value=True,
+ name="autoscale",
+ )
+ Separator()
+ Switch(
+ label="Code Mode",
+ value=True,
+ name="code_mode",
+ )
+ Separator()
+ Switch(
+ label="Tool call caching",
+ value=False,
+ name="cache",
+ )
+ with CardFooter():
+ Button(
+ "Save Preferences",
+ on_click=ShowToast("Preferences saved!"),
+ )
+ with Card():
+ with CardHeader():
+ CardTitle("Travel Class")
+ with CardContent():
+ with RadioGroup(name="travel_class"):
+ Radio(option="economy", label="Economy")
+ Radio(option="business", label="Business Class")
+ Radio(
+ option="improbability",
+ label="Infinite Improbability",
+ value=True,
+ )
+
+ # ── Cols 3–4: summary row, chart, then 2-col grid below ─────────
+ with GridItem(css_class="md:col-span-2"):
+ with Column(gap=4):
+ with Grid(columns=2, gap=4, css_class="h-32"):
+ with Card():
+ with CardHeader():
+ CardTitle("Context Window")
+ with CardContent():
+ with Column(
+ gap=6,
+ justify="center",
+ css_class="h-full",
+ ):
+ with Row(
+ align="center",
+ css_class="justify-between",
+ ):
+ Text(f"{ctx_pct}% used")
+ Muted(f"{ctx_pct * 2}k / 200k tokens")
+ with Tooltip(
+ "Auto-compact buffer: 12%",
+ delay=0,
+ ):
+ Progress(
+ value=ctx_pct,
+ max=100,
+ variant=ctx_variant,
+ )
+ with Card(css_class="pb-0 gap-0"):
+ with CardContent():
+ Metric(
+ label="Fjords designed",
+ value="1,847",
+ delta="+3 coastlines",
+ )
+ Sparkline(
+ data=[
+ 820,
+ 950,
+ 1100,
+ 980,
+ 1250,
+ 1400,
+ 1350,
+ 1500,
+ 1680,
+ 1847,
+ ],
+ variant="success",
+ fill=True,
+ css_class="h-16",
+ )
+ with Card():
+ with CardHeader():
+ CardTitle("Towel Incidents")
+ with CardContent():
+ BarChart(
+ data=[
+ {"month": "Jan", "lost": 8, "found": 5},
+ {"month": "Feb", "lost": 24, "found": 15},
+ {"month": "Mar", "lost": 12, "found": 28},
+ {"month": "Apr", "lost": 35, "found": 19},
+ {"month": "May", "lost": 18, "found": 38},
+ {"month": "Jun", "lost": 42, "found": 30},
+ ],
+ series=[
+ ChartSeries(dataKey="lost", label="Lost"),
+ ChartSeries(dataKey="found", label="Found"),
+ ],
+ x_axis="month",
+ height=200,
+ bar_radius=4,
+ show_legend=True,
+ show_tooltip=True,
+ show_grid=True,
+ )
+
+ with Grid(columns=2, gap=4):
+ with Column(gap=4):
+ with Card():
+ with CardContent():
+ with Column(gap=2):
+ Checkbox(label="Towel packed", value=True)
+ Checkbox(label="Guide charged", value=True)
+ Checkbox(
+ label="Babel fish inserted",
+ value=False,
+ )
+ with Card():
+ with CardHeader():
+ CardTitle("Marvin's Mood")
+ with CardContent():
+ with Column(gap=3):
+ P("How's life?")
+ with Column(gap=2):
+ Button(
+ "Meh",
+ on_click=ShowToast(
+ "Noted. Enthusiasm levels nominal."
+ ),
+ )
+ Button(
+ "Depressed",
+ variant="info",
+ on_click=ShowToast(
+ "I think you ought to "
+ "know I'm feeling very "
+ "depressed."
+ ),
+ )
+ Button(
+ "Don't talk to me about life",
+ variant="warning",
+ on_click=ShowToast(
+ "Brain the size of a "
+ "planet and they ask me "
+ "to pick up a piece of "
+ "paper."
+ ),
+ )
+
+ with Column(gap=4):
+ with Card():
+ with CardContent():
+ with Row(gap=2, align="center"):
+ Loader(variant="dots", size="sm")
+ Muted("Marvin is thinking...")
+ with Card():
+ with CardContent():
+ DataTable(
+ columns=[
+ DataTableColumn(
+ key="crew",
+ header="Crew",
+ sortable=True,
+ ),
+ DataTableColumn(
+ key="species",
+ header="Species",
+ sortable=True,
+ ),
+ DataTableColumn(
+ key="towel",
+ header="Towel?",
+ sortable=True,
+ ),
+ DataTableColumn(
+ key="status",
+ header="Status",
+ sortable=True,
+ ),
+ ],
+ rows=[
+ {
+ "crew": "Arthur Dent",
+ "species": "Human",
+ "towel": "Yes",
+ "status": "Confused",
+ },
+ {
+ "crew": "Ford Prefect",
+ "species": "Betelgeusian",
+ "towel": "Always",
+ "status": "Drinking",
+ },
+ {
+ "crew": "Zaphod",
+ "species": "Betelgeusian",
+ "towel": "Lost it",
+ "status": "Presidential",
+ },
+ {
+ "crew": "Trillian",
+ "species": "Human",
+ "towel": "Yes",
+ "status": "Navigating",
+ },
+ {
+ "crew": "Marvin",
+ "species": "Android",
+ "towel": "No point",
+ "status": "Depressed",
+ },
+ {
+ "crew": "Slartibartfast",
+ "species": "Magrathean",
+ "towel": "Somewhere",
+ "status": "Designing",
+ },
+ ],
+ search=True,
+ paginated=False,
+ )
diff --git a/docs/apps/demos/pie-chart.html b/docs/apps/demos/pie-chart.html
new file mode 100644
index 000000000..c712eeb33
--- /dev/null
+++ b/docs/apps/demos/pie-chart.html
@@ -0,0 +1,60 @@
+
+
+
+ Prefab
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/apps/demos/pie-chart.py b/docs/apps/demos/pie-chart.py
new file mode 100644
index 000000000..c1fb489e4
--- /dev/null
+++ b/docs/apps/demos/pie-chart.py
@@ -0,0 +1,21 @@
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import Column
+from prefab_ui.components.charts import PieChart
+
+data = [
+ {"category": "Bug", "count": 42},
+ {"category": "Feature", "count": 28},
+ {"category": "Docs", "count": 15},
+ {"category": "Infra", "count": 10},
+]
+
+with PrefabApp() as app:
+ with Column(css_class="p-6"):
+ PieChart(
+ data=data,
+ data_key="count",
+ name_key="category",
+ inner_radius=50,
+ show_legend=True,
+ height=240,
+ )
diff --git a/docs/apps/demos/reactive.html b/docs/apps/demos/reactive.html
new file mode 100644
index 000000000..29f05e2d3
--- /dev/null
+++ b/docs/apps/demos/reactive.html
@@ -0,0 +1,167 @@
+
+
+
+ Prefab
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/apps/demos/reactive.py b/docs/apps/demos/reactive.py
new file mode 100644
index 000000000..16f2f9829
--- /dev/null
+++ b/docs/apps/demos/reactive.py
@@ -0,0 +1,66 @@
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import (
+ Column,
+ Row,
+ Select,
+ SelectOption,
+ Switch,
+ Text,
+)
+from prefab_ui.components.charts import BarChart, ChartSeries
+from prefab_ui.components.control_flow import If
+from prefab_ui.components.metric import Metric
+from prefab_ui.rx import Rx
+
+region = Rx("region")
+
+north = [
+ {"month": "Jan", "sales": 22000},
+ {"month": "Feb", "sales": 25500},
+ {"month": "Mar", "sales": 24200},
+]
+south = [
+ {"month": "Jan", "sales": 5800},
+ {"month": "Feb", "sales": 6400},
+ {"month": "Mar", "sales": 5600},
+]
+west = [
+ {"month": "Jan", "sales": 6000},
+ {"month": "Feb", "sales": 6000},
+ {"month": "Mar", "sales": 5600},
+]
+
+with PrefabApp(
+ state={
+ "region": "north",
+ "north": north,
+ "south": south,
+ "west": west,
+ "show_target": True,
+ },
+) as app:
+ with Column(
+ gap=4,
+ css_class="p-6",
+ let={
+ "data": "{{ region == 'south' ? south : region == 'west' ? west : north }}",
+ },
+ ):
+ with Row(gap=4, align="center"):
+ with Select(name="region", css_class="w-40"):
+ SelectOption(value="north", label="North")
+ SelectOption(value="south", label="South")
+ SelectOption(value="west", label="West")
+ Switch(name="show_target", css_class="ml-auto")
+ Text("Show target", css_class="text-sm text-muted-foreground")
+ BarChart(
+ data=Rx("data"),
+ series=[ChartSeries(data_key="sales", label="Sales")],
+ x_axis="month",
+ height=200,
+ )
+ with If(Rx("show_target")):
+ Metric(
+ label="Q1 Target",
+ value="$75,000",
+ )
diff --git a/docs/apps/demos/team-directory-reactive.html b/docs/apps/demos/team-directory-reactive.html
new file mode 100644
index 000000000..f2ab5bf7e
--- /dev/null
+++ b/docs/apps/demos/team-directory-reactive.html
@@ -0,0 +1,237 @@
+
+
+
+ Prefab
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/apps/demos/team-directory-reactive.py b/docs/apps/demos/team-directory-reactive.py
new file mode 100644
index 000000000..b6aa004f7
--- /dev/null
+++ b/docs/apps/demos/team-directory-reactive.py
@@ -0,0 +1,116 @@
+from collections import Counter
+
+from prefab_ui.actions import SetState
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import (
+ H3,
+ Badge,
+ Card,
+ CardContent,
+ CardHeader,
+ Column,
+ DataTable,
+ DataTableColumn,
+ Grid,
+ Row,
+ Small,
+ Text,
+)
+from prefab_ui.components.charts import PieChart
+from prefab_ui.components.control_flow import If
+from prefab_ui.rx import STATE, Rx
+
+MEMBERS = [
+ {
+ "name": "Alice Chen",
+ "role": "Staff Engineer",
+ "office": "San Francisco",
+ "email": "alice@company.com",
+ "projects": 3,
+ },
+ {
+ "name": "Bob Martinez",
+ "role": "Lead Designer",
+ "office": "New York",
+ "email": "bob@company.com",
+ "projects": 5,
+ },
+ {
+ "name": "Carol Johnson",
+ "role": "Senior Engineer",
+ "office": "London",
+ "email": "carol@company.com",
+ "projects": 2,
+ },
+ {
+ "name": "David Kim",
+ "role": "Product Manager",
+ "office": "San Francisco",
+ "email": "david@company.com",
+ "projects": 7,
+ },
+ {
+ "name": "Eva Mueller",
+ "role": "Engineer",
+ "office": "Berlin",
+ "email": "eva@company.com",
+ "projects": 1,
+ },
+ {
+ "name": "Frank Lee",
+ "role": "Data Scientist",
+ "office": "San Francisco",
+ "email": "frank@company.com",
+ "projects": 4,
+ },
+ {
+ "name": "Grace Park",
+ "role": "Engineering Manager",
+ "office": "New York",
+ "email": "grace@company.com",
+ "projects": 6,
+ },
+]
+
+OFFICE_COUNTS = [
+ {"office": office, "count": count}
+ for office, count in Counter(m["office"] for m in MEMBERS).items()
+]
+
+with PrefabApp(state={"selected": None}) as app:
+ with Column(gap=4, css_class="p-6"):
+ with Grid(columns=[1, 2], gap=4):
+ PieChart(
+ data=OFFICE_COUNTS,
+ data_key="count",
+ name_key="office",
+ show_legend=True,
+ )
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="role", header="Role", sortable=True),
+ DataTableColumn(key="office", header="Office", sortable=True),
+ ],
+ rows=MEMBERS,
+ search=True,
+ on_row_click=SetState("selected", Rx("$event")),
+ )
+
+ with If(STATE.selected):
+ with Card():
+ with CardHeader():
+ with Row(gap=2, align="center"):
+ H3(Rx("selected.name"))
+ Badge(Rx("selected.office"))
+ with CardContent():
+ with Grid(columns=3, gap=4):
+ with Column(gap=0):
+ Small("Role")
+ Text(Rx("selected.role"))
+ with Column(gap=0):
+ Small("Email")
+ Text(Rx("selected.email"))
+ with Column(gap=0):
+ Small("Active Projects")
+ Text(Rx("selected.projects"))
diff --git a/docs/apps/demos/team-directory.html b/docs/apps/demos/team-directory.html
new file mode 100644
index 000000000..be577ddde
--- /dev/null
+++ b/docs/apps/demos/team-directory.html
@@ -0,0 +1,127 @@
+
+
+
+ Prefab
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/apps/demos/team-directory.py b/docs/apps/demos/team-directory.py
new file mode 100644
index 000000000..7cfe21bc9
--- /dev/null
+++ b/docs/apps/demos/team-directory.py
@@ -0,0 +1,39 @@
+from collections import Counter
+
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import Column, DataTable, DataTableColumn, Grid
+from prefab_ui.components.charts import PieChart
+
+members = [
+ {"name": "Alice Chen", "role": "Staff Engineer", "office": "San Francisco"},
+ {"name": "Bob Martinez", "role": "Lead Designer", "office": "New York"},
+ {"name": "Carol Johnson", "role": "Senior Engineer", "office": "London"},
+ {"name": "David Kim", "role": "Product Manager", "office": "San Francisco"},
+ {"name": "Eva Mueller", "role": "Engineer", "office": "Berlin"},
+ {"name": "Frank Lee", "role": "Data Scientist", "office": "San Francisco"},
+ {"name": "Grace Park", "role": "Engineering Manager", "office": "New York"},
+]
+
+office_counts = [
+ {"office": office, "count": count}
+ for office, count in Counter(m["office"] for m in members).items()
+]
+
+with PrefabApp() as app:
+ with Column(gap=4, css_class="p-6"):
+ with Grid(columns=[1, 2], gap=4):
+ PieChart(
+ data=office_counts,
+ data_key="count",
+ name_key="office",
+ show_legend=True,
+ )
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="role", header="Role", sortable=True),
+ DataTableColumn(key="office", header="Office", sortable=True),
+ ],
+ rows=members,
+ search=True,
+ )
diff --git a/docs/apps/development.mdx b/docs/apps/development.mdx
index 7045cd939..0d3a71ac7 100644
--- a/docs/apps/development.mdx
+++ b/docs/apps/development.mdx
@@ -3,7 +3,6 @@ title: Development
sidebarTitle: Development
description: Preview and test your app tools locally without a full MCP host.
icon: flask
-tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
@@ -14,11 +13,11 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
-`fastmcp dev apps` launches a browser-based preview for your app tools. It starts your MCP server and a local dev UI side by side — you pick a tool, fill in its arguments, and see the rendered result in a new tab. No MCP host client needed.
+`fastmcp dev apps` gives you a browser preview for your app tools without needing an MCP host client. It starts your server and a local dev UI side by side: you pick a tool, fill in its arguments, and the rendered result opens in a new tab.
-This works with both [Prefab apps](/apps/prefab) and [custom HTML apps](/apps/low-level).
+Works with both [Interactive Tools](/apps/prefab) and [custom HTML apps](/apps/low-level).
-## Quick Start
+## Quick start
```bash
fastmcp dev apps server.py
@@ -26,7 +25,7 @@ fastmcp dev apps server.py
The dev UI opens at `http://localhost:8080`. Your MCP server runs on port 8000 with auto-reload enabled by default — save a file and the server restarts automatically.
-## How It Works
+## How it works
The dev server does three things:
@@ -36,7 +35,7 @@ When you submit a form, the dev server **calls your tool** via the MCP protocol
A **reverse proxy** on `/mcp` forwards requests from the browser to your MCP server, avoiding CORS issues that would otherwise block the iframe-based renderer from talking to a different port.
-## MCP Inspector
+## MCP inspector
The dev UI includes an inspector panel on the left side that captures MCP traffic in real time. It shows JSON-RPC messages flowing between the browser and your server — requests, responses, and AppBridge `postMessage` traffic.
@@ -56,7 +55,7 @@ fastmcp dev apps server.py:mcp --mcp-port 9000 --dev-port 9090 --no-reload
| Dev Port | `--dev-port` | `8080` | Port for the dev UI |
| Auto-Reload | `--reload` / `--no-reload` | On | Watch files and restart the server on changes |
-## Multiple Tools
+## Multiple tools
If your server has multiple app tools, the picker shows a dropdown. Each tool gets its own form and launch button. The tool's `title` is displayed when available, falling back to the tool name.
diff --git a/docs/apps/examples.mdx b/docs/apps/examples.mdx
index 024808f5d..5078120e7 100644
--- a/docs/apps/examples.mdx
+++ b/docs/apps/examples.mdx
@@ -3,14 +3,13 @@ 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.
+Each tile below is a working FastMCP server you can run with `fastmcp dev apps` or connect to from any MCP host. Source lives in `examples/apps/` in the repository.
@@ -44,7 +43,7 @@ Every example below is a working FastMCP server you can run with `fastmcp dev ap
-## Running Examples
+## Running the examples
Preview any example in your browser with the dev server:
@@ -53,11 +52,11 @@ 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.
+The dev UI lets you pick a tool and fill in arguments. In a real deployment the LLM provides those arguments from conversation context — the quiz example especially shines when connected to a host like Goose or Claude Desktop, where the LLM generates the questions itself.
-## Standalone Examples
+## Standalone apps
-### Sales Dashboard
+### 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.
@@ -65,9 +64,9 @@ A full dashboard with KPI metrics, revenue trends, segment breakdown, and a deal
fastmcp dev apps examples/apps/sales_dashboard/sales_dashboard_server.py
```
-### System Monitor
+### 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.
+Reads live CPU, memory, and disk stats from your machine using `psutil`. Auto-refreshes via `SetInterval` calling a backend tool, with a dropdown to control the refresh rate. The chart accumulates up to 100 data points over time.
```bash
pip install psutil
@@ -82,59 +81,12 @@ The LLM generates trivia questions and passes them to the tool. The user answers
fastmcp dev apps examples/apps/quiz/quiz_server.py
```
-### Interactive Map
+### 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.
+Accepts addresses or place names, geocodes them via OpenStreetMap Nominatim (free, no API key), and renders an interactive Leaflet map using Prefab's `Embed` component with inline HTML. A reminder that Prefab apps can break out of built-in components when they need to.
```bash
fastmcp dev apps examples/apps/map/map_server.py
```
-## 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())
-```
+For ready-made building blocks like approvals, choice pickers, file uploads, and Pydantic forms, see the [Providers](/apps/providers/approval) group.
diff --git a/docs/apps/generative.mdx b/docs/apps/generative.mdx
index 86d306dd7..b6293d32b 100644
--- a/docs/apps/generative.mdx
+++ b/docs/apps/generative.mdx
@@ -10,7 +10,9 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
-Generative UI means the LLM writes the UI code at runtime. Instead of calling a pre-built tool with a fixed interface, the model writes Prefab Python code tailored to the current data and request. The user watches the UI build up in real time as the model generates code.
+
+
+With Generative UI, the LLM writes the UI code at runtime. Instead of calling a pre-built tool with a fixed shape, the model writes Prefab Python tailored to the current data and request. The user watches the UI stream in as the model generates it.
```python
from fastmcp import FastMCP
@@ -20,15 +22,15 @@ mcp = FastMCP("Prefab Studio")
mcp.add_provider(GenerativeUI())
```
-That's it. The `GenerativeUI` provider registers everything:
+One provider registers three things:
- **`generate_prefab_ui`** — a tool that accepts Python code, executes it in a Pyodide sandbox, and renders the result as a Prefab app
-- **`search_prefab_components`** — a tool that lets the LLM search the Prefab component library to discover what's available
-- **The generative renderer** — a `ui://` resource with browser-side Pyodide for streaming progressive rendering
+- **`search_prefab_components`** — a tool the LLM uses to discover what components are available
+- **The streaming renderer** — a `ui://` resource with browser-side Pyodide that progressively renders partial code as the LLM generates it
-## How It Works
+## How it works
-When the LLM decides to call `generate_prefab_ui`, it writes Prefab Python code into the `code` argument. The MCP Apps protocol creates the renderer iframe in parallel with the tool call, so the app is already running when partial arguments start flowing.
+When the LLM calls `generate_prefab_ui`, it writes Prefab Python code into the `code` argument. The MCP Apps protocol creates the renderer iframe in parallel with the tool call, so the app is already running by the time partial arguments start flowing.
As the LLM generates each token:
@@ -37,11 +39,11 @@ As the LLM generates each token:
3. Browser-side Pyodide executes whatever compiles successfully
4. The user sees components appear as they're written
-When the LLM finishes, the server runs the complete code in a server-side Pyodide sandbox for validation, and the renderer replaces the streaming preview with the final server-validated result.
+When the LLM finishes, the server runs the complete code in a server-side Pyodide sandbox for validation, and the renderer swaps the streaming preview for the final server-validated result.
-## What the LLM Writes
+## What the LLM writes
-The tool description includes code examples that teach the LLM the Prefab patterns. A typical generation looks like:
+The tool description includes examples that teach the model the Prefab patterns. A typical generation looks like:
```python
from prefab_ui.components import Column, Row, Heading, Text, Badge, Card, CardContent
@@ -73,9 +75,9 @@ with PrefabApp() as app:
Badge("+18%", variant="success")
```
-The model writes real Python — loops, f-strings, computation, helper functions. Prefab's component library gives it charts, tables, forms, cards, badges, and layout primitives to work with.
+The model writes real Python — loops, f-strings, computation, helper functions. Prefab gives it charts, tables, forms, cards, badges, and layout primitives to compose.
-## The Component Search Tool
+## The component search tool
Before writing code, the LLM can call `search_prefab_components` to discover what's available:
@@ -87,11 +89,11 @@ search_prefab_components("Chart")
...
```
-Passing `detail=True` returns full field descriptions and docstrings. The search tool introspects the actual Prefab classes at runtime, so it's always up to date with the installed version.
+Passing `detail=True` returns full field descriptions and docstrings. The search tool introspects Prefab classes at runtime, so it's always up to date with the installed version.
-## Passing Data
+## Passing data
-The `generate_prefab_ui` tool accepts a `data` parameter. Values passed here become global variables in the sandbox:
+The `generate_prefab_ui` tool accepts a `data` parameter. Values become global variables in the sandbox:
```python
# The LLM can reference 'sales_data' directly in its code
@@ -101,11 +103,11 @@ result = await generate_prefab_ui(
)
```
-This lets the model use real data from earlier in the conversation to build visualizations.
+This lets the model use data from earlier in the conversation to build visualizations.
## Configuration
-`GenerativeUI` accepts options for customizing tool names:
+`GenerativeUI` takes options for customizing tool names:
```python
GenerativeUI(
@@ -117,17 +119,16 @@ GenerativeUI(
## Requirements
-Generative UI requires `fastmcp[apps]` which installs `prefab-ui`. The Pyodide sandbox (for server-side validation) requires Deno — it installs automatically on first use.
+Generative UI needs `fastmcp[apps]`, which pulls in `prefab-ui`. The server-side Pyodide sandbox (for final validation) requires Deno — it installs automatically on first use.
-The streaming renderer loads Pyodide from CDN in the browser. The CSP is configured automatically by the provider — no manual setup needed.
+The streaming renderer loads Pyodide from CDN in the browser. The CSP is configured automatically by the provider — no manual setup.
-## Sandbox Limitations
+## 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`.
+The Pyodide sandbox includes the Python standard library and Prefab. External packages (NumPy, pandas, requests, etc.) are **not available** — the LLM's code must work with only built-in Python and Prefab. If the LLM imports something unavailable, the sandbox raises `ImportError`.
-## Next Steps
+## 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`
+- **[Interactive Tools](/apps/prefab)** — the component building blocks the LLM will use
+- **[Prefab component reference](https://prefab.prefect.io/docs/components)** — full component library
+- **[Development](/apps/development)** — preview generative tools locally with `fastmcp dev apps`
diff --git a/docs/apps/images/generative-ui.mp4 b/docs/apps/images/generative-ui.mp4
new file mode 100644
index 000000000..ca610181e
Binary files /dev/null and b/docs/apps/images/generative-ui.mp4 differ
diff --git a/docs/apps/interactive-apps.mdx b/docs/apps/interactive-apps.mdx
index fb2963114..f3463da66 100644
--- a/docs/apps/interactive-apps.mdx
+++ b/docs/apps/interactive-apps.mdx
@@ -1,45 +1,40 @@
---
title: FastMCPApp
sidebarTitle: FastMCPApp
-description: Managed tool binding, visibility, and composition for apps with heavy server interaction.
+description: Wire an interactive UI to backend tools with managed visibility and composition safety.
icon: puzzle-piece
tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
+import PrefabPinWarning from '/snippets/prefab-pin-warning.mdx'
-
-[Prefab](https://prefab.prefect.io) is in early, active development — its API changes frequently and breaking changes can occur with any release. Always pin `prefab-ui` to a specific version in your dependencies.
-
+
-Any [Prefab app](/apps/prefab) can call server tools — there's nothing stopping you from using `CallTool("tool_name")` in a regular `@mcp.tool(app=True)`. But once you have multiple backend tools, the management overhead adds up: Which tools should the model see vs. only the UI? What happens to string-based tool references when servers are composed under namespaces? How do you keep things wired correctly as the app grows?
+
-`FastMCPApp` is a class that solves these problems. It gives you two decorators that work together:
+Search a list, fill out a form, click save, the list updates. That pattern — UI that reads and writes data on the server — needs two things: backend tools that actually do the work, and a way to call them from the UI. `FastMCPApp` handles the wiring.
-- **`@app.ui()`** — entry-point tools the model calls to open the app. These return a Prefab UI.
-- **`@app.tool()`** — backend tools the UI calls via `CallTool`. These do the work.
+You'll build up to the contacts app above by the end of this page. Let's start with something smaller.
-Backend tools get globally stable identifiers that survive namespacing. Visibility is managed automatically — the model sees entry points, the UI sees backends. And `CallTool` accepts function references instead of strings, so references are refactorable and composition-safe.
+## A minimal interactive app
-## Your First Interactive App
-
-Here's a minimal app with a form that saves data:
+The smallest interactive app: a form that saves a note, and a list that updates when the user submits.
```python
from prefab_ui.actions import SetState, ShowToast
from prefab_ui.actions.mcp import CallTool
from prefab_ui.app import PrefabApp
from prefab_ui.components import (
- Badge, Button, Column, ForEach, Form,
- Heading, Input, Row, Separator, Text,
+ Badge, Button, Column, ForEach, Form, Heading,
+ Input, Row, Separator, Text,
)
from prefab_ui.rx import RESULT
from fastmcp import FastMCP, FastMCPApp
app = FastMCPApp("Notes")
-
notes_db: list[dict] = []
@@ -83,13 +78,23 @@ def notes_app() -> PrefabApp:
mcp = FastMCP("Notes Server", providers=[app])
```
-When the model calls `notes_app`, the user sees a form. Submitting it calls `add_note` on the server, updates the state with the result, and shows a toast — all without leaving the UI.
+The model sees one tool: `notes_app`. Calling it opens the UI. When the user submits the form, `CallTool("add_note")` fires, the server saves the note, returns the updated list, and `SetState("notes", RESULT)` writes that list back into state. `ForEach("notes")` re-renders. The model never sees `add_note` — it's UI-only.
-Let's break down the key concepts.
+## Why not just `@mcp.tool(app=True)`?
-## Entry Points: @app.ui()
+A fair question. Any [Interactive Tool](/apps/prefab) can call a server tool — there's nothing stopping you from putting `CallTool("add_note")` inside a regular `@mcp.tool(app=True)`. It works for one or two tools. Things get harder once the app grows:
-Entry points are what the model sees and calls to open your app. They return a Prefab UI, just like display tools:
+- Which tools should the model see, and which are UI-only?
+- What happens to `CallTool("add_note")` when you mount this server under a namespace and the tool becomes `notes_add_note`?
+- How do you keep it all wired correctly as you compose servers?
+
+`FastMCPApp` owns these concerns. Entry points register as model-visible. Backend tools register as UI-only by default. Backend tools get globally stable identifiers that survive namespacing, and `CallTool` accepts function references, so references stay valid when you compose servers.
+
+The rest of this page covers each piece in turn.
+
+## `@app.ui()` — entry points
+
+Entry points are what the model sees. They return a `PrefabApp` and default to `visibility=["model"]`, showing up in the LLM tool list but not callable from within the UI.
```python
@app.ui()
@@ -97,21 +102,15 @@ def dashboard() -> PrefabApp:
"""The model calls this to open the dashboard."""
with Column(gap=4, css_class="p-6") as view:
Heading("Dashboard")
- # ... build UI ...
+ ...
return PrefabApp(view=view)
```
-Entry points default to `visibility=["model"]` — they show up in the tool list for the LLM but aren't callable from within the app UI. They support the same options as `@mcp.tool`: `name`, `description`, `title`, `tags`, `icons`, `auth`, and `timeout`.
+`@app.ui()` supports the same options as `@mcp.tool`: `name`, `description`, `title`, `tags`, `icons`, `auth`, and `timeout`.
-```python
-@app.ui(title="Contact Manager", description="Open the contact management interface")
-def contact_manager() -> PrefabApp:
- ...
-```
+## `@app.tool()` — backend tools
-## Backend Tools: @app.tool()
-
-Backend tools do the work. The UI calls them via `CallTool`; they run on the server and return data:
+Backend tools do the work. By default they're visible only to the UI (`visibility=["app"]`), not the model.
```python
@app.tool()
@@ -121,7 +120,7 @@ def save_contact(name: str, email: str) -> list[dict]:
return list(db)
```
-By default, backend tools are only visible to the app UI (`visibility=["app"]`). The model doesn't see them in the tool list. If you want a tool callable by both the model and the UI, pass `model=True`:
+If you want a tool callable by both the model and the UI, pass `model=True`:
```python
@app.tool(model=True)
@@ -130,37 +129,32 @@ def list_contacts() -> list[dict]:
return list(db)
```
-Backend tools support `name`, `description`, `auth`, and `timeout`:
+Backend tools support `name`, `description`, `auth`, and `timeout`.
-```python
-@app.tool(description="Search contacts by name or email", timeout=10.0)
-def search(query: str) -> list[dict]:
- ...
-```
+## `CallTool` — UI → backend
-## Connecting UI to Backend: CallTool
-
-`CallTool` is the bridge between the UI and the server. Pass the name of a backend tool registered with `@app.tool()`:
+`CallTool` is how the UI invokes a backend tool. Pass the tool's name (or a direct function reference):
```python
from prefab_ui.actions.mcp import CallTool
-# Reference a backend tool by name
CallTool("save_contact", arguments={"name": "Alice", "email": "alice@example.com"})
-# Arguments can reference state with Rx
+# Or a function reference — resolves to a stable global key
+CallTool(save_contact, arguments={...})
+```
+
+Arguments can reference state with `Rx`:
+
+```python
from prefab_ui.rx import STATE
CallTool("search", arguments={"query": STATE.search_term})
```
-FastMCPApp resolves the name to the tool's stable global key automatically, so `CallTool("save_contact")` keeps working even when the server is mounted under a namespace.
+### Handling results
-You can also pass the function directly — `CallTool(save_contact)` — which can be convenient when the tool is defined in the same file. Both forms resolve identically.
-
-### Handling Results
-
-Server calls are asynchronous. Use `on_success` and `on_error` callbacks to handle outcomes:
+Server calls are async. Use `on_success` and `on_error` callbacks:
```python
from prefab_ui.actions import SetState, ShowToast
@@ -176,78 +170,54 @@ CallTool(
)
```
-`RESULT` is a reactive reference to the value the tool returned — available inside `on_success` callbacks. Similarly, `ERROR` (from `prefab_ui.rx`) is available inside `on_error`.
+`RESULT` is a reactive reference to the tool's return value, available inside `on_success`. `ERROR` (from `prefab_ui.rx`) is the counterpart inside `on_error`. Callbacks can be a single action or a list; they execute in order and short-circuit on error.
-Callbacks can be a single action or a list of actions. They execute in order, and an error in any action short-circuits the rest.
+### `result_key` shorthand
-### result_key Shorthand
-
-When a tool returns data that should replace a state key, `result_key` is a convenient shorthand for `on_success=SetState(key, RESULT)`:
+When a tool's return value should replace a state key, use `result_key`:
```python
CallTool("list_contacts", result_key="contacts")
-# equivalent to:
-CallTool(
- "list_contacts",
- on_success=SetState("contacts", RESULT),
-)
+# same as:
+CallTool("list_contacts", on_success=SetState("contacts", RESULT))
```
## Actions
-`CallTool` is one of several actions available in Prefab. Actions are events attached to component handlers like `on_click`, `on_submit`, and `on_change`.
+`CallTool` is one of several actions. Actions attach to handlers like `on_click`, `on_submit`, and `on_change`.
-### Client Actions
-
-These run instantly in the browser — no server round-trip:
+Client-side actions run instantly in the browser, no server round-trip:
```python
from prefab_ui.actions import SetState, ToggleState, AppendState, PopState, ShowToast
-# Set a value
SetState("count", 42)
-
-# Toggle a boolean
ToggleState("expanded")
-
-# Append to a list
AppendState("items", {"name": "New Item"})
-
-# Remove by index
PopState("items", 0)
-
-# Show a notification
ShowToast("Done!", variant="success")
```
-### Chaining Actions
-
-Pass a list to execute multiple actions in sequence:
+Pass a list to chain actions:
```python
-from prefab_ui.components import Button
-from prefab_ui.actions import SetState, ShowToast
-
Button(
"Reset",
on_click=[
SetState("query", ""),
SetState("results", []),
- ShowToast("Cleared", variant="default"),
+ ShowToast("Cleared"),
],
)
```
-### Loading States
+### Loading states
-A common pattern: show a loading indicator while a server call is in flight.
+A common pattern: disable a button and show a spinner while a call is in flight.
```python
-from prefab_ui.actions import SetState, ShowToast
-from prefab_ui.actions.mcp import CallTool
-from prefab_ui.components import Button
-from prefab_ui.rx import RESULT, Rx
+from prefab_ui.rx import Rx
saving = Rx("saving")
@@ -271,21 +241,17 @@ Button(
],
)
-# Pass state={"saving": False} to PrefabApp when returning
+# PrefabApp(view=view, state={"saving": False, ...})
```
## Forms
-Forms are the most common way to collect input and send it to the server. When a form submits, all named input values are gathered and passed as arguments to the `CallTool` action.
+Forms collect input and submit it to a tool. When submitted, named input values become the tool's arguments.
-### Manual Forms
-
-Build forms with individual input components:
+### Manual forms
```python
from prefab_ui.components import Form, Input, Select, SelectOption, Textarea, Button
-from prefab_ui.actions.mcp import CallTool
-from prefab_ui.actions import ShowToast
with Form(
on_submit=CallTool(
@@ -298,26 +264,19 @@ with Form(
SelectOption("Low", value="low")
SelectOption("Medium", value="medium")
SelectOption("High", value="high")
- SelectOption("Critical", value="critical")
Textarea(name="description", label="Description")
Button("Create Ticket")
```
-When submitted, the CallTool receives `{"title": "...", "priority": "...", "description": "..."}` as arguments to `create_ticket`.
+On submit, `CallTool` receives `{"title": ..., "priority": ..., "description": ...}`.
-### Pydantic Model Forms
+### Forms from Pydantic models
-For structured data, `Form.from_model()` generates the entire form from a Pydantic model — inputs, labels, and submit wiring:
+For structured input, `Form.from_model()` generates the whole form — inputs, labels, validation:
```python
from typing import Literal
-
from pydantic import BaseModel, Field
-from prefab_ui.components import Column, Heading, Form
-from prefab_ui.actions.mcp import CallTool
-from prefab_ui.actions import SetState, ShowToast
-from prefab_ui.app import PrefabApp
-from prefab_ui.rx import RESULT
class BugReport(BaseModel):
title: str = Field(title="Bug Title")
@@ -329,7 +288,6 @@ class BugReport(BaseModel):
@app.ui()
def report_bug() -> PrefabApp:
- """File a bug report."""
with Column(gap=4, css_class="p-6") as view:
Heading("Report a Bug")
Form.from_model(
@@ -337,7 +295,6 @@ def report_bug() -> PrefabApp:
on_submit=CallTool(
"create_bug",
on_success=ShowToast("Bug filed!", variant="success"),
- on_error=ShowToast("Failed to submit", variant="error"),
),
)
return PrefabApp(view=view)
@@ -345,69 +302,47 @@ def report_bug() -> PrefabApp:
@app.tool()
def create_bug(data: BugReport) -> str:
- """Create a bug report."""
- # save to database...
return f"Created: {data.title}"
```
-`str` fields become text inputs, `Literal` becomes a select dropdown, `bool` becomes a checkbox. Field titles and defaults are respected.
+`str` becomes a text input, `Literal` becomes a select, `bool` becomes a checkbox. Field titles and defaults are respected.
-## Composition and Namespacing
+## Composition and namespacing
-The reason `FastMCPApp` exists — and why you'd use it instead of plain `@mcp.tool(app=True)` with `CallTool("tool_name")` — is composition safety.
+The reason `FastMCPApp` exists — and why you'd pick it over plain `@mcp.tool(app=True)` with string-based `CallTool` — is composition safety.
When you mount a server under a namespace, tool names get prefixed:
```python
-from fastmcp import FastMCP
-
platform = FastMCP("Platform")
platform.mount("contacts", contacts_server)
# "save_contact" becomes "contacts_save_contact"
```
-If your UI used `CallTool("save_contact")`, it would break — the tool is now named `contacts_save_contact`. But `CallTool(save_contact)` with a function reference resolves to a globally stable key (like `save_contact-a1b2c3d4`) that bypasses the namespace entirely.
+`CallTool("save_contact")` would now be broken. But `CallTool(save_contact)` with a function reference resolves to a globally stable identifier that bypasses the namespace. Your app works the same whether standalone or mounted.
-This is why `FastMCPApp` assigns global keys to backend tools, and why `CallTool` accepts function references. Your app works the same whether it's running standalone or mounted inside a larger platform.
-
-### Mounting an App
+### Mounting
`FastMCPApp` is a Provider. Add it to a server with `providers=` or `add_provider`:
```python
-from fastmcp import FastMCP, FastMCPApp
-
-app = FastMCPApp("Contacts")
-
-@app.ui()
-def contact_manager() -> PrefabApp:
- ...
-
-@app.tool()
-def save_contact(name: str, email: str) -> dict:
- ...
-
-
-# Option 1: providers list
mcp = FastMCP("Platform", providers=[app])
-# Option 2: add_provider
+# or
mcp = FastMCP("Platform")
mcp.add_provider(app)
```
-Multiple apps can coexist on the same server:
+Multiple apps can coexist; each gets its own global keys, so there's no collision even if two apps have a tool named `save`.
```python
mcp = FastMCP("Platform", providers=[contacts_app, inventory_app, billing_app])
```
-Each app's backend tools have their own global keys, so there's no collision even if two apps have a tool named `save`.
+### Running standalone
-### Running Standalone
-
-For development, `FastMCPApp` has a convenience `run()` method that wraps itself in a temporary `FastMCP` server:
+For development, `FastMCPApp` has a `run()` shortcut that wraps itself in a temporary `FastMCP` server:
```python
app = FastMCPApp("Contacts")
@@ -417,9 +352,9 @@ if __name__ == "__main__":
app.run()
```
-## Complete Example: Contact Manager
+## A full example: contact manager
-This pulls together everything — entry points, backend tools, callable references, forms (both manual and Pydantic), state management, and actions:
+This brings everything together — entry point, backend tools, Pydantic form, manual form, state, actions, and multi-visibility.
```python expandable
from __future__ import annotations
@@ -437,8 +372,6 @@ from prefab_ui.rx import RESULT, Rx
from pydantic import BaseModel, Field
from fastmcp import FastMCP, FastMCPApp
-# Data
-
contacts_db: list[dict] = [
{"name": "Arthur Dent", "email": "arthur@earth.com", "category": "Customer"},
{"name": "Ford Prefect", "email": "ford@betelgeuse.org", "category": "Partner"},
@@ -451,8 +384,6 @@ class ContactModel(BaseModel):
category: Literal["Customer", "Vendor", "Partner", "Other"] = "Other"
-# App
-
app = FastMCPApp("Contacts")
@@ -528,11 +459,11 @@ if __name__ == "__main__":
mcp.run()
```
-This example is also available as a runnable server at `examples/apps/contacts/contacts_server.py`.
+Also available as a runnable server at `examples/apps/contacts/contacts_server.py`.
-## Next Steps
+## Next steps
-- **[Prefab Apps](/apps/prefab)** — Components, state, and reactive displays (the building blocks)
-- **[Patterns](/apps/patterns)** — Copy-paste examples for common UIs
-- **[Development](/apps/development)** — Preview and test app tools locally
-- **[Prefab UI Docs](https://prefab.prefect.io)** — Full component reference and advanced patterns
+- **[Interactive Tools](/apps/prefab)** — the building blocks: charts, tables, dashboards, reactive state
+- **[Examples](/apps/examples)** — complete working servers
+- **[Development](/apps/development)** — preview and test app tools locally
+- **[Prefab UI docs](https://prefab.prefect.io)** — full component reference
diff --git a/docs/apps/low-level.mdx b/docs/apps/low-level.mdx
index adda74799..ccef52b0a 100644
--- a/docs/apps/low-level.mdx
+++ b/docs/apps/low-level.mdx
@@ -3,18 +3,17 @@ title: Custom HTML Apps
sidebarTitle: Custom HTML
description: Build apps with your own HTML, CSS, and JavaScript using the MCP Apps extension directly.
icon: code
-tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
-The [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) is an open protocol that lets tools return interactive UIs — an HTML page rendered in a sandboxed iframe inside the host client. [Prefab UI](/apps/prefab) builds on this protocol so you never have to think about it, but when you need full control — custom rendering, a specific JavaScript framework, maps, 3D, video — you can use the MCP Apps extension directly.
+Everything on this page is for when you want full control: your own HTML, your own JavaScript framework, a map library, a 3D viewer, custom video playback. [Interactive Tools](/apps/prefab) wrap the MCP Apps extension so you never have to think about it — this page is what you reach for when you need to think about it.
-This page covers how to write custom HTML apps and wire them up in FastMCP. You'll be working with the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK for host communication, and FastMCP's `AppConfig` for resource and CSP management.
+You'll be working with two things: the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK for host communication, and FastMCP's `AppConfig` for resources and CSP.
-## How It Works
+## How it works
An MCP App has two parts:
@@ -66,7 +65,7 @@ def my_tool() -> str:
return "result"
```
-### Tool Visibility
+### Tool visibility
The `visibility` field controls where a tool appears:
@@ -88,7 +87,7 @@ def refresh_data() -> str:
return fetch_latest()
```
-### AppConfig Fields
+### AppConfig fields
| Field | Type | Description |
|-------|------|-------------|
@@ -103,9 +102,9 @@ def refresh_data() -> str:
On **resources**, `resource_uri` and `visibility` must not be set — the resource *is* the UI. Use `AppConfig` on resources only for `csp`, `permissions`, and other display settings.
-## UI Resources
+## UI resources
-Resources using the `ui://` scheme are automatically served with the MIME type `text/html;profile=mcp-app`. You don't need to set this manually.
+Resources using the `ui://` scheme are automatically served with the MIME type `text/html;profile=mcp-app`. No need to set it manually.
```python
@mcp.resource("ui://my-app/view.html")
@@ -115,7 +114,7 @@ def my_view() -> str:
The HTML can be anything — a full single-page app, a simple display, or a complex interactive tool. The host renders it in a sandboxed iframe and establishes a `postMessage` channel for communication.
-### Writing the App HTML
+### Writing the app HTML
Your HTML app communicates with the host using the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK. The simplest approach is to load it from a CDN:
@@ -204,7 +203,7 @@ def my_view() -> str:
Hosts may or may not grant these permissions. Your app should use JavaScript feature detection as a fallback.
-## Example: QR Code Server
+## Example: a QR code server
This example creates a tool that generates QR codes and an app that renders them as images. It's based on the [official MCP Apps example](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/qr-server). Requires the `qrcode[pil]` package.
@@ -286,7 +285,7 @@ def view() -> str:
The tool generates a QR code as a base64 PNG. The resource loads the MCP Apps JS SDK from unpkg (declared in the CSP), listens for tool results, and renders the image. The host wires them together — when the LLM calls `generate_qr`, the QR code appears in an interactive frame inside the conversation.
-## Checking Client Support
+## Checking client support
Not all hosts support the Apps extension. You can check at runtime using the tool's [context](/servers/context):
diff --git a/docs/apps/overview.mdx b/docs/apps/overview.mdx
index cb3c7c1ea..ea4890bd7 100644
--- a/docs/apps/overview.mdx
+++ b/docs/apps/overview.mdx
@@ -3,179 +3,70 @@ title: Apps
sidebarTitle: Overview
description: Give your tools interactive UIs rendered directly in the conversation.
icon: grid-2
-tag: NEW
+mode: center
---
import { VersionBadge } from '/snippets/version-badge.mdx'
+import PrefabPinWarning from '/snippets/prefab-pin-warning.mdx'
-MCP tools normally return text. That works for answers, but not for data the user wants to *explore* — a revenue chart they can hover over, a sortable employee directory, a form that submits structured input. MCP Apps let your tools return interactive UIs rendered right inside the conversation.
+A FastMCP app is a tool that returns an interactive UI instead of text. When the host calls it, the user sees a chart, a table, a form, or a whole dashboard rendered right inside the conversation, with working sort, search, tooltips, and state.
-
-
-
+
+
+
-FastMCP builds on the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) with [Prefab](https://prefab.prefect.io), a Python component library that compiles to interactive UIs. You write Python; the user sees charts, tables, forms, and dashboards.
+The dashboard above is a [Prefab](https://prefab.prefect.io) showcase — a taste of what you can deliver from a FastMCP tool. Every card, chart, slider, dialog, and carousel is a Python component. Build a composition like this, add `@mcp.tool(app=True)`, and the host renders it inside the conversation.
-
-The examples throughout the Apps docs require the `apps` extra:
+Under the hood, FastMCP builds on the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) and uses Prefab to describe UIs in Python.
```bash
pip install "fastmcp[apps]"
```
-This installs [Prefab UI](https://prefab.prefect.io), the component library used to build app UIs.
-
+
-
-FastMCP pins a **minimum** version of `prefab-ui` for compatibility but intentionally does **not** pin an upper bound. Prefab is a rapidly evolving library with frequent breaking changes. If you are deploying to production, you **must** pin `prefab-ui` to a specific version in your own dependencies. Without a pin, a fresh deploy could pull a newer Prefab version that changes component APIs, breaking your app.
-
+## Pick your path
-## Which Approach?
+Four patterns cover almost everything you'd want to build. Most apps start with Interactive Tools; you only reach for the others when you've hit a specific limit.
-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.
+### [Interactive Tools](/apps/prefab) — start here
-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
-
-
-
-The quickest way to give a tool a visual UI. Add `app=True` to any tool and return a Prefab component — when the host calls it, the user sees an interactive UI instead of a JSON blob:
+Add `app=True` to a tool and return a Prefab component. Charts, tables, dashboards, and client-side interactivity (toggles, tabs, filtering) all work without any server round-trips.
```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import Column, Heading
-from prefab_ui.components.charts import BarChart, ChartSeries
-from fastmcp import FastMCP
-
-mcp = FastMCP("Dashboard")
-
-
@mcp.tool(app=True)
-def revenue_chart(year: int) -> PrefabApp:
- """Show annual revenue as an interactive bar chart."""
- data = [
- {"quarter": "Q1", "revenue": 42000},
- {"quarter": "Q2", "revenue": 51000},
- {"quarter": "Q3", "revenue": 47000},
- {"quarter": "Q4", "revenue": 63000},
- ]
-
- with Column(gap=4, css_class="p-6") as view:
- Heading(f"{year} Revenue")
- BarChart(
- data=data,
- series=[ChartSeries(data_key="revenue", label="Revenue")],
- x_axis="quarter",
- )
-
- return PrefabApp(view=view)
+def team_directory() -> DataTable:
+ return DataTable(columns=[...], rows=employees, search=True)
```
-Prefab apps aren't limited to static displays. Prefab's state system and client-side actions (toggles, tabs, conditionals) all work. You can even call other tools from the UI using `CallTool`. There's no hard wall on what a Prefab app can do.
+### [FastMCPApp](/apps/interactive-apps) — when the UI calls back to the server
-See [Prefab Apps](/apps/prefab) for the full guide.
+Forms that save data, buttons that trigger backend work, search that hits a database. `FastMCPApp` manages the wiring between UI actions and backend tools, with stable tool identifiers that survive server composition.
-### FastMCPApp
+### [Generative UI](/apps/generative) — when the LLM writes the UI
-
-
-When your app has a lot of server-side interaction — forms that save data, search that queries a database, multi-step workflows — managing the connection between UI and backend tools gets complicated fast. Which tools should the model see vs. only the UI? What happens to tool references when servers are composed under namespaces? How do you keep `CallTool("save_contact")` working when the tool name changes?
-
-`FastMCPApp` is a class that solves these problems. It gives you two decorators that work together:
-
-- **`@app.ui()`** — entry-point tools the model calls to open the app
-- **`@app.tool()`** — backend tools the UI calls via `CallTool`
-
-Backend tools get stable identifiers that survive namespacing, visibility is managed automatically (the model sees entry points, the UI sees backends), and `CallTool` accepts tool names that resolve correctly regardless of how servers are composed:
+Register one provider and the model can write Prefab code tailored to the current data and request. The user watches the UI build up as the model generates it.
```python
-from prefab_ui.actions import SetState, ShowToast
-from prefab_ui.actions.mcp import CallTool
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import (
- Column, Heading, Form, Input, Button, ForEach, Row, Text, Badge, Separator,
-)
-from prefab_ui.rx import RESULT
-from fastmcp import FastMCP, FastMCPApp
-
-app = FastMCPApp("Contacts")
-
-
-@app.tool()
-def save_contact(name: str, email: str) -> list[dict]:
- """Save a contact and return the updated list."""
- db.append({"name": name, "email": email})
- return list(db)
-
-
-@app.ui()
-def contact_manager() -> PrefabApp:
- """Open the contact manager."""
- with Column(gap=6, css_class="p-6") as view:
- Heading("Contacts")
- with ForEach("contacts") as contact:
- with Row(gap=2):
- Text(contact.name)
- Badge(contact.email)
- Separator()
- with Form(
- on_submit=CallTool(
- "save_contact",
- on_success=[
- SetState("contacts", RESULT),
- ShowToast("Saved!", variant="success"),
- ],
- )
- ):
- Input(name="name", label="Name", required=True)
- Input(name="email", label="Email", required=True)
- Button("Save")
-
- return PrefabApp(view=view, state={"contacts": list(db)})
-
-
-mcp = FastMCP("Server", providers=[app])
-```
-
-You *can* build server-interactive UIs without `FastMCPApp` — it's all the same protocol underneath. But once you have multiple tools, composition concerns, or visibility requirements, `FastMCPApp` handles the complexity so you don't have to.
-
-See [FastMCPApp](/apps/interactive-apps) for the full guide.
-
-### Generative UI
-
-
-
-Instead of pre-building a UI, the LLM can write one from scratch. The `GenerativeUI` provider registers tools that let the model write Prefab Python code, execute it in a sandbox, and render the result — with streaming so the user watches the UI build up in real time.
-
-```python
-from fastmcp import FastMCP
-from fastmcp.apps.generative import GenerativeUI
-
-mcp = FastMCP("Prefab Studio")
mcp.add_provider(GenerativeUI())
```
-See [Generative UI](/apps/generative) for the full guide, or the [provider reference](/apps/providers/generative) for configuration options.
+### [Custom HTML](/apps/low-level) — when you need full control
-### Custom HTML
+Write your own HTML, CSS, and JavaScript. Use a specific framework, drop in a map or 3D viewer, embed video. You're talking to the MCP Apps protocol directly.
-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.
+## What's next
-## Previewing Apps Locally
-
-The `fastmcp dev apps` command launches a browser-based preview for your app tools — no MCP host client needed. See [Development](/apps/development).
-
-```bash
-fastmcp dev apps server.py
-```
+- **[Quickstart](/apps/quickstart)** — build a working app in a minute
+- **[Examples](/apps/examples)** — complete working servers you can run today
+- **[Providers](/apps/providers/approval)** — ready-made capabilities (approvals, choice pickers, file upload, forms) you add with one line
+- **[Development](/apps/development)** — preview app tools locally with `fastmcp dev apps`
diff --git a/docs/apps/patterns.mdx b/docs/apps/patterns.mdx
deleted file mode 100644
index b0ff80376..000000000
--- a/docs/apps/patterns.mdx
+++ /dev/null
@@ -1,431 +0,0 @@
----
-title: Patterns
-sidebarTitle: Patterns
-description: Copy-paste examples for common tool UIs.
-icon: grid-2-plus
-tag: NEW
----
-
-import { VersionBadge } from '/snippets/version-badge.mdx'
-
-
-
-Each pattern below is a complete, copy-pasteable tool. They're organized by what you're building — pick the one closest to your use case, paste it, and adapt.
-
-For the full set of available components — layout containers, form controls, overlays, and more — see the [Prefab component reference](https://prefab.prefect.io/docs/components).
-
-## Charts
-
-Prefab includes [bar, line, area, pie, radar, and radial charts](https://prefab.prefect.io/docs/components/charts). They render client-side with tooltips, legends, and responsive sizing.
-
-### Bar Chart
-
-```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import Column, Heading
-from prefab_ui.components.charts import BarChart, ChartSeries
-from fastmcp import FastMCP
-
-mcp = FastMCP("Charts")
-
-
-@mcp.tool(app=True)
-def quarterly_revenue(year: int) -> PrefabApp:
- """Show quarterly revenue as a bar chart."""
- data = [
- {"quarter": "Q1", "revenue": 42000, "costs": 28000},
- {"quarter": "Q2", "revenue": 51000, "costs": 31000},
- {"quarter": "Q3", "revenue": 47000, "costs": 29000},
- {"quarter": "Q4", "revenue": 63000, "costs": 35000},
- ]
-
- with Column(gap=4, css_class="p-6") as view:
- Heading(f"{year} Revenue vs Costs")
- BarChart(
- data=data,
- series=[
- ChartSeries(data_key="revenue", label="Revenue"),
- ChartSeries(data_key="costs", label="Costs"),
- ],
- x_axis="quarter",
- show_legend=True,
- )
-
- return PrefabApp(view=view)
-```
-
-Multiple `ChartSeries` entries plot different data keys. Add `stacked=True` to stack bars, or `horizontal=True` to flip the axes.
-
-### Area Chart
-
-`LineChart` and `AreaChart` share the same API as `BarChart`, with `curve` for interpolation and `show_dots` for data points:
-
-```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import Column, Heading
-from prefab_ui.components.charts import AreaChart, ChartSeries
-from fastmcp import FastMCP
-
-mcp = FastMCP("Charts")
-
-
-@mcp.tool(app=True)
-def usage_trend() -> PrefabApp:
- """Show API usage over time."""
- data = [
- {"date": "Feb 1", "requests": 1200},
- {"date": "Feb 2", "requests": 1350},
- {"date": "Feb 3", "requests": 980},
- {"date": "Feb 4", "requests": 1500},
- {"date": "Feb 5", "requests": 1420},
- ]
-
- with Column(gap=4, css_class="p-6") as view:
- Heading("API Usage")
- AreaChart(
- data=data,
- series=[ChartSeries(data_key="requests", label="Requests")],
- x_axis="date",
- curve="smooth",
- height=250,
- )
-
- return PrefabApp(view=view)
-```
-
-### Pie and Donut Charts
-
-`PieChart` uses `data_key` (the numeric value) and `name_key` (the label). Set `inner_radius` for a donut:
-
-```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import Column, Heading
-from prefab_ui.components.charts import PieChart
-from fastmcp import FastMCP
-
-mcp = FastMCP("Charts")
-
-
-@mcp.tool(app=True)
-def ticket_breakdown() -> PrefabApp:
- """Show open tickets by category."""
- data = [
- {"category": "Bug", "count": 23},
- {"category": "Feature", "count": 15},
- {"category": "Docs", "count": 8},
- {"category": "Infra", "count": 12},
- ]
-
- with Column(gap=4, css_class="p-6") as view:
- Heading("Open Tickets")
- PieChart(
- data=data,
- data_key="count",
- name_key="category",
- show_legend=True,
- inner_radius=60,
- )
-
- return PrefabApp(view=view)
-```
-
-## Data Tables
-
-[DataTable](https://prefab.prefect.io/docs/components/data-display/data-table) provides sortable columns, full-text search, and pagination — all client-side:
-
-```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import Column, Heading, DataTable, DataTableColumn
-from fastmcp import FastMCP
-
-mcp = FastMCP("Directory")
-
-
-@mcp.tool(app=True)
-def employee_directory() -> PrefabApp:
- """Show a searchable, sortable employee directory."""
- employees = [
- {"name": "Alice Chen", "department": "Engineering", "role": "Staff Engineer", "location": "SF"},
- {"name": "Bob Martinez", "department": "Design", "role": "Lead Designer", "location": "NYC"},
- {"name": "Carol Johnson", "department": "Engineering", "role": "Senior Engineer", "location": "London"},
- {"name": "David Kim", "department": "Product", "role": "Product Manager", "location": "SF"},
- {"name": "Eva Müller", "department": "Engineering", "role": "Engineer", "location": "Berlin"},
- ]
-
- with Column(gap=4, css_class="p-6") as view:
- Heading("Employee Directory")
- DataTable(
- columns=[
- DataTableColumn(key="name", header="Name", sortable=True),
- DataTableColumn(key="department", header="Department", sortable=True),
- DataTableColumn(key="role", header="Role"),
- DataTableColumn(key="location", header="Office", sortable=True),
- ],
- rows=employees,
- search=True,
- paginated=True,
- page_size=15,
- )
-
- return PrefabApp(view=view)
-```
-
-## Status Displays
-
-Cards, badges, progress bars, and grids combine naturally for dashboards:
-
-```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import (
- Column, Row, Grid, Heading, Text, Muted, Badge,
- Card, CardContent, Progress, Separator,
-)
-from fastmcp import FastMCP
-
-mcp = FastMCP("Monitoring")
-
-
-@mcp.tool(app=True)
-def system_status() -> PrefabApp:
- """Show current system health."""
- services = [
- {"name": "API Gateway", "status": "healthy", "ok": True, "latency_ms": 12, "uptime_pct": 99.9},
- {"name": "Database", "status": "healthy", "ok": True, "latency_ms": 3, "uptime_pct": 99.99},
- {"name": "Cache", "status": "degraded", "ok": False, "latency_ms": 45, "uptime_pct": 98.2},
- {"name": "Queue", "status": "healthy", "ok": True, "latency_ms": 8, "uptime_pct": 99.8},
- ]
- all_ok = all(s["ok"] for s in services)
-
- with Column(gap=4, css_class="p-6") as view:
- with Row(gap=2, align="center"):
- Heading("System Status")
- Badge(
- "All Healthy" if all_ok else "Degraded",
- variant="success" if all_ok else "destructive",
- )
- Separator()
- with Grid(columns=2, gap=4):
- for svc in services:
- with Card():
- with CardContent():
- with Row(gap=2, align="center"):
- Text(svc["name"], css_class="font-medium")
- Badge(
- svc["status"],
- variant="success" if svc["ok"] else "destructive",
- )
- Muted(f"Response: {svc['latency_ms']}ms")
- Progress(value=svc["uptime_pct"])
-
- return PrefabApp(view=view)
-```
-
-## Reactive Displays
-
-These patterns use state and `Rx()` for client-side interactivity — no server calls needed.
-
-### Feature Toggles
-
-```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import Column, Heading, Switch, Alert, If, Separator
-from prefab_ui.rx import Rx
-from fastmcp import FastMCP
-
-mcp = FastMCP("Flags")
-
-
-@mcp.tool(app=True)
-def feature_flags() -> PrefabApp:
- """Toggle feature flags with live preview."""
- with Column(gap=4, css_class="p-6") as view:
- Heading("Feature Flags")
- Switch(name="dark_mode", label="Dark Mode")
- Switch(name="beta", label="Beta Features")
- Separator()
- with If(Rx("dark_mode")):
- Alert(title="Dark mode enabled", description="UI will use dark theme.")
- with If(Rx("beta")):
- Alert(
- title="Beta features active",
- description="Experimental features are now visible.",
- variant="warning",
- )
-
- return PrefabApp(view=view, state={"dark_mode": False, "beta": False})
-```
-
-### Tabs
-
-```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import (
- Column, Heading, Text, Muted, Badge, Row,
- DataTable, DataTableColumn, Tabs, Tab, ForEach,
-)
-from fastmcp import FastMCP
-
-mcp = FastMCP("Projects")
-
-
-@mcp.tool(app=True)
-def project_overview() -> PrefabApp:
- """Show project details organized in tabs."""
- project = {
- "name": "FastMCP v3",
- "description": "Next generation MCP framework with Apps support.",
- "status": "Active",
- "members": [
- {"name": "Alice Chen", "role": "Lead"},
- {"name": "Bob Martinez", "role": "Design"},
- ],
- "activity": [
- {"timestamp": "2 hours ago", "message": "Merged PR #342"},
- {"timestamp": "1 day ago", "message": "Released v3.0.1"},
- ],
- }
-
- with Column(gap=4, css_class="p-6") as view:
- Heading(project["name"])
- with Tabs():
- with Tab("Overview"):
- Text(project["description"])
- with Row(gap=4):
- Badge(project["status"])
-
- with Tab("Members"):
- DataTable(
- columns=[
- DataTableColumn(key="name", header="Name", sortable=True),
- DataTableColumn(key="role", header="Role"),
- ],
- rows=project["members"],
- )
-
- with Tab("Activity"):
- with ForEach("activity") as item:
- with Row(gap=2):
- Muted(item.timestamp)
- Text(item.message)
-
- return PrefabApp(view=view, state={"activity": project["activity"]})
-```
-
-### Accordion
-
-```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import (
- Column, Heading, Row, Text, Badge, Progress,
- Accordion, AccordionItem,
-)
-from fastmcp import FastMCP
-
-mcp = FastMCP("API Monitor")
-
-
-@mcp.tool(app=True)
-def api_health() -> PrefabApp:
- """Show health details for each API endpoint."""
- endpoints = [
- {"path": "/api/users", "status": 200, "healthy": True, "avg_ms": 45, "p99_ms": 120, "uptime_pct": 99.9},
- {"path": "/api/orders", "status": 200, "healthy": True, "avg_ms": 82, "p99_ms": 250, "uptime_pct": 99.7},
- {"path": "/api/search", "status": 200, "healthy": True, "avg_ms": 150, "p99_ms": 500, "uptime_pct": 99.5},
- {"path": "/api/webhooks", "status": 503, "healthy": False, "avg_ms": 2000, "p99_ms": 5000, "uptime_pct": 95.1},
- ]
-
- with Column(gap=4, css_class="p-6") as view:
- Heading("API Health")
- with Accordion(multiple=True):
- for ep in endpoints:
- with AccordionItem(ep["path"]):
- with Row(gap=4):
- Badge(
- f"{ep['status']}",
- variant="success" if ep["healthy"] else "destructive",
- )
- Text(f"Avg: {ep['avg_ms']}ms")
- Text(f"P99: {ep['p99_ms']}ms")
- Progress(value=ep["uptime_pct"])
-
- return PrefabApp(view=view)
-```
-
-## Interactive Patterns
-
-These patterns call server tools. For context on `FastMCPApp`, `@app.tool()`, and `CallTool`, see [FastMCPApp](/apps/interactive-apps).
-
-### Contact Form
-
-```python
-from prefab_ui.actions import SetState, ShowToast
-from prefab_ui.actions.mcp import CallTool
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import (
- Badge, Button, Column, ForEach, Form, Heading,
- Input, Muted, Row, Select, SelectOption, Separator, Text, Textarea,
-)
-from prefab_ui.rx import RESULT
-from fastmcp import FastMCP, FastMCPApp
-
-app = FastMCPApp("Contacts")
-
-contacts_db: list[dict] = [
- {"name": "Zaphod Beeblebrox", "email": "zaphod@galaxy.gov", "category": "Partner"},
-]
-
-
-@app.tool()
-def save_contact(
- name: str, email: str, category: str = "Other", notes: str = "",
-) -> list[dict]:
- """Save a new contact and return the updated list."""
- contacts_db.append({"name": name, "email": email, "category": category})
- return list(contacts_db)
-
-
-@app.ui()
-def contact_form() -> PrefabApp:
- """Contact list with an add form."""
- with Column(gap=6, css_class="p-6") as view:
- Heading("Contacts")
-
- with ForEach("contacts") as contact:
- with Row(gap=2, align="center"):
- Text(contact.name, css_class="font-medium")
- Muted(contact.email)
- Badge(contact.category)
-
- Separator()
-
- with Form(
- on_submit=CallTool(
- "save_contact",
- on_success=[
- SetState("contacts", RESULT),
- ShowToast("Contact saved!", variant="success"),
- ],
- on_error=ShowToast("Failed to save", variant="error"),
- )
- ):
- Input(name="name", label="Full Name", required=True)
- Input(name="email", label="Email", input_type="email", required=True)
- with Select(name="category", label="Category"):
- SelectOption("Customer", value="Customer")
- SelectOption("Vendor", value="Vendor")
- SelectOption("Partner", value="Partner")
- SelectOption("Other", value="Other")
- Textarea(name="notes", label="Notes", placeholder="Optional notes...")
- Button("Save Contact")
-
- return PrefabApp(view=view, state={"contacts": list(contacts_db)})
-
-
-mcp = FastMCP("Server", providers=[app])
-```
-
-## Next Steps
-
-- **[FastMCPApp](/apps/interactive-apps)** — Managed tool binding for server-connected UIs
-- **[Development](/apps/development)** — Preview app tools locally with `fastmcp dev apps`
-- **[Prefab UI Docs](https://prefab.prefect.io)** — Full component reference, layout guides, and more
diff --git a/docs/apps/prefab.mdx b/docs/apps/prefab.mdx
index 156767869..00c4b4e84 100644
--- a/docs/apps/prefab.mdx
+++ b/docs/apps/prefab.mdx
@@ -1,321 +1,254 @@
---
-title: Prefab UI
-sidebarTitle: Prefab UI
-description: The component library behind FastMCP apps — charts, tables, dashboards, forms, and reactive displays.
+title: Interactive Tools
+sidebarTitle: Interactive Tools
+description: Turn your tools into interactive UIs with charts, tables, and dashboards.
icon: palette
tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
+import PrefabPinWarning from '/snippets/prefab-pin-warning.mdx'
-
-[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.
+
-The simplest way to use it: add `app=True` to a tool and return Prefab components. The host renders an interactive UI instead of text. This works for everything from static charts to reactive dashboards with client-side state — no server round-trips needed.
+Believe it or not, that dashboard is a FastMCP tool. The chart has tooltips. The table is sortable. The badges are styled by deal stage. The whole thing is about 40 lines of Python, and the user sees it right inside their conversation instead of a wall of JSON.
-For apps that need server interaction (forms, search, CRUD), see [FastMCPApp](/apps/interactive-apps) which adds managed tool binding on top of Prefab UI. For LLM-generated UIs, see [Generative UI](/apps/generative).
+The pattern behind every example on this page is the same: add `app=True` to your tool, build a UI with [Prefab](https://prefab.prefect.io) components, and return it as a `PrefabApp`. Prefab has [100+ components](https://prefab.prefect.io/docs/components), from data tables and charts to forms and progress bars. You compose them in Python; the host renders them as a live, interactive application.
-## Getting Started
+## Start with a table
-Here's a tool that returns a bar chart:
+Most tools return data the user wants to explore. A `DataTable` is often the smallest useful upgrade — your data goes from a JSON blob to a searchable, sortable table:
+
+
```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import Column, Heading
-from prefab_ui.components.charts import BarChart, ChartSeries
-from fastmcp import FastMCP
-
-mcp = FastMCP("Dashboard")
-
-
-@mcp.tool(app=True)
-def revenue_chart(year: int) -> PrefabApp:
- """Show annual revenue as an interactive bar chart."""
- data = [
- {"quarter": "Q1", "revenue": 42000},
- {"quarter": "Q2", "revenue": 51000},
- {"quarter": "Q3", "revenue": 47000},
- {"quarter": "Q4", "revenue": 63000},
- ]
-
- with Column(gap=4, css_class="p-6") as view:
- Heading(f"{year} Revenue")
- BarChart(
- data=data,
- series=[ChartSeries(data_key="revenue", label="Revenue")],
- x_axis="quarter",
- )
-
- return PrefabApp(view=view)
-```
-
-The `app=True` flag tells FastMCP this tool returns a UI. When a host calls the tool, the user sees an interactive chart instead of a JSON blob. The [Patterns](/apps/patterns) page has more examples.
-
-## Layout and Components
-
-Prefab uses Python's `with` statement to express nesting. Containers like `Column`, `Row`, and `Grid` collect their children automatically:
-
-```python
-from prefab_ui.components import (
- Column, Row, Grid, Heading, Text, Muted, Badge,
- Card, CardContent, Separator,
-)
-
-with Column(gap=4, css_class="p-6") as view:
- Heading("Team Status")
- Separator()
- with Grid(columns=2, gap=4):
- with Card():
- with CardContent():
- Text("API Gateway", css_class="font-medium")
- Badge("healthy", variant="success")
- with Card():
- with CardContent():
- Text("Cache", css_class="font-medium")
- Badge("degraded", variant="destructive")
-```
-
-You can also use Python loops to generate components at build time:
-
-```python
-services = [
- {"name": "API", "status": "healthy", "ok": True},
- {"name": "Cache", "status": "degraded", "ok": False},
-]
-
-with Grid(columns=2, gap=4):
- for svc in services:
- with Card():
- with CardContent():
- Text(svc["name"])
- Badge(
- svc["status"],
- variant="success" if svc["ok"] else "destructive",
- )
-```
-
-Build-time loops produce static content — the data is baked into the component tree at construction time. For dynamic iteration over state that changes at render time, use `ForEach` (covered below).
-
-The full component library — layout containers, data display, charts, forms, overlays — is documented in the [Prefab component reference](https://prefab.prefect.io/docs/components).
-
-## State and Reactivity
-
-Display tools can be interactive without calling the server. The key is **state** — a client-side key-value store that lives in the browser. Components read from state, actions mutate it, and the UI re-renders automatically.
-
-### Declaring State
-
-Pass a `state` dict to `PrefabApp` to declare initial state, then use `Rx("key")` to create reactive references:
-
-```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import Column, Heading, Switch, Alert, If
-from prefab_ui.rx import Rx
-from fastmcp import FastMCP
-
-mcp = FastMCP("Flags")
-
-
-@mcp.tool(app=True)
-def feature_flags() -> PrefabApp:
- """Toggle feature flags with live preview."""
- with Column(gap=4, css_class="p-6") as view:
- Heading("Feature Flags")
- Switch(name="dark_mode", label="Dark Mode")
- Switch(name="beta", label="Beta Features")
-
- with If(Rx("dark_mode")):
- Alert(title="Dark mode enabled")
- with If(Rx("beta")):
- Alert(title="Beta features active", variant="warning")
-
- return PrefabApp(view=view, state={"dark_mode": False, "beta": False})
-```
-
-Three things to notice here:
-
-The `state` dict on `PrefabApp` declares the keys and their starting values. `Rx("dark_mode")` creates a reactive reference that compiles to `{{ dark_mode }}` in the wire protocol.
-
-Interactive components with a `name` prop automatically bind to state. The `Switch(name="dark_mode")` syncs its on/off value to the `dark_mode` state key on every toggle — no event wiring needed.
-
-`If(Rx("dark_mode"))` shows its children only when the state key is truthy. When the switch flips, the condition re-evaluates instantly in the browser.
-
-### Reactive References with Rx
-
-The `Rx` class is how you reference state in component props:
-
-```python
-from prefab_ui.rx import Rx
-
-count = Rx("count")
-```
-
-Rx objects support arithmetic, comparisons, and formatting — they compile to expressions the renderer evaluates at render time:
-
-```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import Column, Text, Slider
-from prefab_ui.rx import Rx
-from fastmcp import FastMCP
-
-mcp = FastMCP("Calculator")
-
-
-@mcp.tool(app=True)
-def tip_calculator() -> PrefabApp:
- """Calculate tip with a slider."""
- tip_pct = Rx("tip_pct")
- bill = Rx("bill")
-
- tip_amount = tip_pct / 100 * bill
- total = bill + tip_amount
-
- with Column(gap=4, css_class="p-6") as view:
- Slider(name="bill", label="Bill Amount", min=0, max=500, step=0.5)
- Slider(name="tip_pct", label="Tip %", min=0, max=50)
- Text(f"Tip: {tip_amount.currency()}")
- Text(f"Total: {total.currency()}")
-
- return PrefabApp(view=view, state={"bill": 50.00, "tip_pct": 18})
-```
-
-`Rx("tip_pct") / 100 * Rx("bill")` builds a compound expression — it doesn't do the math in Python. The renderer evaluates it live as the sliders move. The `.currency()` pipe formats the result as currency.
-
-#### Pipes
-
-Rx objects support formatting pipes that transform values at render time:
-
-```python
-from prefab_ui.rx import Rx
-
-price = Rx("price")
-ratio = Rx("ratio")
-name = Rx("name")
-
-price.currency() # $42.50
-price.currency("EUR") # EUR format
-ratio.percent() # 85%
-name.upper() # ALICE
-name.truncate(10) # alice (or truncated if longer)
-```
-
-Number pipes include `currency`, `percent`, `number`, `compact`, `round`, and `abs`. String pipes include `upper`, `lower`, and `truncate`. See the [Prefab expression docs](https://prefab.prefect.io/docs/concepts/expressions) for the full list.
-
-#### Conditionals
-
-The `.then()` method creates ternary expressions:
-
-```python
-from prefab_ui.rx import Rx
-
-connected = Rx("connected")
-
-Badge(
- connected.then("Online", "Offline"),
- variant=connected.then("success", "destructive"),
-)
-```
-
-### Dynamic Iteration with ForEach
-
-Python `for` loops generate static content at build time. When you need to iterate over state that can change — a list that grows, items that get filtered — use `ForEach`:
-
-```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import Column, Heading, ForEach, Row, Text, Badge
+from prefab_ui.components import DataTable, DataTableColumn
from fastmcp import FastMCP
mcp = FastMCP("Directory")
@mcp.tool(app=True)
-def team_list() -> PrefabApp:
- """Show the current team."""
- members = [
- {"name": "Alice", "role": "Engineering"},
- {"name": "Bob", "role": "Design"},
+def team_directory() -> DataTable:
+ """Browse the team directory."""
+ employees = [
+ {"name": "Alice Chen", "role": "Staff Engineer", "dept": "Platform"},
+ {"name": "Bob Martinez", "role": "Lead Designer", "dept": "Design"},
+ {"name": "Carol Johnson", "role": "Senior Engineer", "dept": "Platform"},
+ {"name": "David Kim", "role": "Product Manager", "dept": "Product"},
+ {"name": "Eva Mueller", "role": "Engineer", "dept": "Platform"},
+ {"name": "Frank Lee", "role": "Data Scientist", "dept": "ML"},
+ {"name": "Grace Park", "role": "Eng Manager", "dept": "Platform"},
]
- with Column(gap=4, css_class="p-6") as view:
- Heading("Team")
- with ForEach("members") as member:
- with Row(gap=2, align="center"):
- Text(member.name, css_class="font-medium")
- Badge(member.role)
-
- return PrefabApp(view=view, state={"members": members})
-```
-
-`ForEach("members")` iterates over the `members` state key. The `as member` gives you an Rx proxy scoped to each item, so `member.name` resolves to `{{ $item.name }}` in the wire protocol. If the `members` state changes (e.g., through an action), the list re-renders automatically.
-
-### Conditional Rendering
-
-`If`, `Elif`, and `Else` control what's visible based on state:
-
-```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import Column, Select, SelectOption, If, Elif, Else, Text
-from prefab_ui.rx import Rx
-
-tier = Rx("tier")
-
-with Column(gap=4) as view:
- with Select(name="tier", label="Plan"):
- SelectOption("Free", value="free")
- SelectOption("Pro", value="pro")
- SelectOption("Enterprise", value="enterprise")
- with If(tier == "enterprise"):
- Text("Full access to all features")
- with Elif(tier == "pro"):
- Text("Advanced features unlocked")
- with Else():
- Text("Basic features only")
-
-# Pass state={"tier": "free"} to PrefabApp when returning
-```
-
-Changes are instant — switching the dropdown re-evaluates the conditions in the browser.
-
-## Giving the LLM Context
-
-By default, Prefab sends `"[Rendered Prefab UI]"` as the text content for the LLM. If the model needs to reason about the data, wrap your return in a `ToolResult` with a meaningful summary:
-
-```python
-from prefab_ui.app import PrefabApp
-from prefab_ui.components import Column, Heading
-from prefab_ui.components.charts import BarChart, ChartSeries
-from fastmcp import FastMCP
-from fastmcp.tools import ToolResult
-
-mcp = FastMCP("Sales")
-
-
-@mcp.tool(app=True)
-def sales_overview(year: int) -> ToolResult:
- """Show sales data visually and summarize for the model."""
- data = get_sales_data(year)
- total = sum(row["revenue"] for row in data)
-
- with Column(gap=4, css_class="p-6") as view:
- Heading("Sales Overview")
- BarChart(data=data, series=[ChartSeries(data_key="revenue")])
-
- return ToolResult(
- content=f"Total revenue for {year}: ${total:,} across {len(data)} quarters",
- structured_content=view,
+ return DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="role", header="Role", sortable=True),
+ DataTableColumn(key="dept", header="Dept", sortable=True),
+ ],
+ rows=employees,
+ search=True,
)
```
-The user sees the chart. The LLM sees the summary string.
+That's it. Add `app=True`, return a Prefab component instead of raw dicts. FastMCP handles the rendering, sandboxing, and security. No wrapper class needed for simple cases like this.
-## Advanced
+## Add charts
-
-`app=True` auto-wires the Prefab renderer with default CSP settings. If your app loads external resources — embedding iframes, fetching from APIs, loading scripts — use `PrefabAppConfig` to add the required domains:
+When numbers tell a better story as a visual, swap in a chart. The API is the same: pass your data as a list of dicts, tell the chart which keys to plot.
+
+
+
+```python
+@mcp.tool(app=True)
+def quarterly_revenue(year: int) -> BarChart:
+ """Show quarterly revenue as a bar chart."""
+ data = [
+ {"quarter": "Q1", "revenue": 42000, "costs": 28000},
+ {"quarter": "Q2", "revenue": 51000, "costs": 31000},
+ {"quarter": "Q3", "revenue": 47000, "costs": 29000},
+ {"quarter": "Q4", "revenue": 63000, "costs": 35000},
+ ]
+
+ return BarChart(
+ data=data,
+ series=[
+ ChartSeries(data_key="revenue", label="Revenue"),
+ ChartSeries(data_key="costs", label="Costs"),
+ ],
+ x_axis="quarter",
+ show_legend=True,
+ )
+```
+
+Each `ChartSeries` plots a different key from the data. `BarChart`, `LineChart`, `AreaChart`, `PieChart`, `RadarChart`, and `RadialChart` all follow the same pattern. Hover over the bars to see tooltips.
+
+
+
+```python
+@mcp.tool(app=True)
+def ticket_breakdown() -> PieChart:
+ """Show open tickets by category."""
+ data = [
+ {"category": "Bug", "count": 42},
+ {"category": "Feature", "count": 28},
+ {"category": "Docs", "count": 15},
+ {"category": "Infra", "count": 10},
+ ]
+
+ return PieChart(
+ data=data,
+ data_key="count",
+ name_key="category",
+ inner_radius=50,
+ show_legend=True,
+ )
+```
+
+See the [Prefab chart docs](https://prefab.prefect.io/docs/components) for stacking, curves, custom colors, and more.
+
+## Compose a dashboard
+
+Tables and charts are useful on their own, but the real power comes from composing them. `Column` stacks children vertically, `Row` lays them out side by side, and `with` blocks establish nesting — the indentation is the layout.
+
+
+
+```python expandable
+@mcp.tool(app=True)
+def sales_dashboard() -> PrefabApp:
+ """Show sales KPIs, trends, and deals."""
+ monthly = [
+ {"month": "Jan", "revenue": 48200, "costs": 31000},
+ {"month": "Feb", "revenue": 52100, "costs": 32500},
+ {"month": "Mar", "revenue": 61800, "costs": 34200},
+ {"month": "Apr", "revenue": 58400, "costs": 33800},
+ ]
+ deals = [
+ {"account": "Acme Corp", "value": "$84,000", "stage": "Won"},
+ {"account": "Globex Inc", "value": "$52,000", "stage": "Negotiation"},
+ {"account": "Initech", "value": "$31,500", "stage": "Proposal"},
+ {"account": "Wayne Enterprises", "value": "$45,000", "stage": "Lost"},
+ ]
+
+ rows = [
+ {
+ "account": d["account"],
+ "value": d["value"],
+ "stage": Badge(
+ d["stage"],
+ variant="success" if d["stage"] == "Won"
+ else "destructive" if d["stage"] == "Lost"
+ else "secondary",
+ ),
+ }
+ for d in deals
+ ]
+
+ total = sum(m["revenue"] for m in monthly)
+
+ with PrefabApp() as app:
+ with Column(gap=4, css_class="p-6"):
+ with Row(gap=6):
+ Metric(label="Revenue (Q1-Q4)", value=f"${total:,}")
+ Metric(label="Deals", value=f"{len(deals)}")
+ BarChart(
+ data=monthly,
+ series=[
+ ChartSeries(data_key="revenue", label="Revenue"),
+ ChartSeries(data_key="costs", label="Costs"),
+ ],
+ x_axis="month",
+ show_legend=True,
+ )
+ Separator()
+ DataTable(
+ columns=[
+ DataTableColumn(key="account", header="Account", sortable=True),
+ DataTableColumn(key="value", header="Value", sortable=True),
+ DataTableColumn(key="stage", header="Stage"),
+ ],
+ rows=rows,
+ )
+
+ return app
+```
+
+Notice how `Badge` components can be placed inside table cells — any Prefab component works as a cell value, so you can put progress bars, icons, or buttons in your tables too.
+
+## Make it reactive
+
+Everything above renders once from the data your Python provides. But interactive tools can also respond to user input in real time, without any server round-trips. Prefab's state system lets components read and write client-side values, so the UI updates instantly as the user interacts with it.
+
+
+
+Try switching regions in the dropdown, and toggling the switch on and off.
+
+```python expandable
+from prefab_ui.rx import Rx
+
+@mcp.tool(app=True)
+def regional_sales() -> PrefabApp:
+ """Sales by region with a live filter."""
+ north = [
+ {"month": "Jan", "sales": 22000},
+ {"month": "Feb", "sales": 25500},
+ {"month": "Mar", "sales": 24200},
+ ]
+ south = [
+ {"month": "Jan", "sales": 5800},
+ {"month": "Feb", "sales": 6400},
+ {"month": "Mar", "sales": 5600},
+ ]
+ west = [
+ {"month": "Jan", "sales": 6000},
+ {"month": "Feb", "sales": 6000},
+ {"month": "Mar", "sales": 5600},
+ ]
+
+ with PrefabApp(
+ state={
+ "region": "north",
+ "north": north, "south": south, "west": west,
+ "show_target": True,
+ },
+ ) as app:
+ with Column(
+ gap=4,
+ css_class="p-6",
+ let={"data": "{{ region == 'south' ? south"
+ " : region == 'west' ? west"
+ " : north }}"},
+ ):
+ with Row(gap=4, align="center"):
+ with Select(name="region", css_class="w-40"):
+ SelectOption(value="north", label="North")
+ SelectOption(value="south", label="South")
+ SelectOption(value="west", label="West")
+ Switch(name="show_target", css_class="ml-auto")
+ Text("Show target", css_class="text-sm text-muted-foreground")
+ BarChart(
+ data=Rx("data"),
+ series=[ChartSeries(data_key="sales", label="Sales")],
+ x_axis="month",
+ )
+ with If(Rx("show_target")):
+ Metric(label="Q1 Target", value="$75,000")
+
+ return app
+```
+
+The `state` dict on `PrefabApp` declares initial values. The `Select` writes to the `region` key on every change. A `let` binding picks the matching dataset, and the chart re-renders. The `Switch` toggles a `Metric` on and off through `If(Rx("show_target"))`. All of this happens in the browser — no calls back to your server.
+
+`Rx` is a reactive reference: `Rx("region")` compiles to an expression the renderer evaluates live. It supports arithmetic, comparisons, formatting pipes (`.currency()`, `.percent()`), and ternary conditionals (`.then()`). For the full state system, see the [Prefab state docs](https://prefab.prefect.io/docs/concepts/state) and [expression docs](https://prefab.prefect.io/docs/concepts/expressions).
+
+## Content Security Policy
+
+Interactive tools render in a sandboxed iframe with a strict CSP. If your tool loads external resources — embedding iframes, fetching from APIs, loading scripts — add the required domains:
```python
from fastmcp.apps import PrefabAppConfig, ResourceCSP
@@ -327,40 +260,37 @@ def dashboard_with_embed() -> PrefabApp:
...
```
-`PrefabAppConfig()` with no arguments is equivalent to `app=True`. It auto-sets the renderer URI and merges the renderer's CSP with any additional domains you provide.
-
+`PrefabAppConfig()` with no arguments is equivalent to `app=True`.
-
-If your return type annotation is a Prefab type — `PrefabApp`, `Component`, or unions containing them — FastMCP enables app rendering automatically, even without `app=True`:
+## Giving the LLM context
+
+By default, the LLM sees `"[Rendered Prefab UI]"` as the tool result. If the model needs to reason about the data, return a `ToolResult` with a text summary alongside the UI:
```python
-@mcp.tool
-def greet(name: str) -> PrefabApp:
- return PrefabApp(view=Heading(f"Hello, {name}!"))
-```
-
-Explicit `app=True` is recommended for clarity.
-
-
-
-Prefab tools and [custom HTML tools](/apps/low-level) coexist on the same server:
-
-```python
-from fastmcp.apps import AppConfig
+from fastmcp.tools import ToolResult
@mcp.tool(app=True)
-def team_directory() -> PrefabApp:
- ...
+def sales_overview(year: int) -> ToolResult:
+ """Show sales visually, summarize for the model."""
+ data = get_sales_data(year)
+ total = sum(row["revenue"] for row in data)
-@mcp.tool(app=AppConfig(resource_uri="ui://my-app/map.html"))
-def map_view() -> str:
- ...
+ with Column(gap=4, css_class="p-6") as view:
+ BarChart(data=data, series=[ChartSeries(data_key="revenue")])
+
+ return ToolResult(
+ content=f"Total revenue for {year}: ${total:,} across {len(data)} quarters",
+ structured_content=view,
+ )
```
-
-## Next Steps
+The user sees the chart. The model sees the summary.
-- **[FastMCPApp](/apps/interactive-apps)** — Managed tool binding for apps with heavy server interaction
-- **[Patterns](/apps/patterns)** — Charts, tables, dashboards, and other common examples
-- **[Development](/apps/development)** — Preview app tools locally with `fastmcp dev apps`
-- **[Prefab UI Docs](https://prefab.prefect.io)** — Full component reference, advanced state patterns, and more
+## Next steps
+
+- **[FastMCPApp](/apps/interactive-apps)** — when your UI needs to call backend tools (forms, search, CRUD)
+- **[Generative UI](/apps/generative)** — let the LLM design the UI at runtime
+- **[Custom HTML](/apps/low-level)** — when Prefab isn't enough (maps, 3D, your own framework)
+- **[Examples](/apps/examples)** — complete working servers you can run today
+- **[Development](/apps/development)** — preview your tools locally with `fastmcp dev apps`
+- **[Prefab UI](https://prefab.prefect.io)** — full component reference with 100+ components, theming, and advanced patterns
diff --git a/docs/apps/providers/approval.mdx b/docs/apps/providers/approval.mdx
index 15b683d15..8ac7b8dd1 100644
--- a/docs/apps/providers/approval.mdx
+++ b/docs/apps/providers/approval.mdx
@@ -70,7 +70,7 @@ request_approval(
)
```
-## How It Works
+## How it works
When the user clicks a button, two things happen:
diff --git a/docs/apps/providers/choice.mdx b/docs/apps/providers/choice.mdx
index 71672a95c..c29c1b2bc 100644
--- a/docs/apps/providers/choice.mdx
+++ b/docs/apps/providers/choice.mdx
@@ -62,7 +62,7 @@ choose(
)
```
-## How It Works
+## How it works
Each option renders as a full-width button in a vertical stack. When the user clicks one:
diff --git a/docs/apps/providers/file-upload.mdx b/docs/apps/providers/file-upload.mdx
index 13cf2402e..b9709d946 100644
--- a/docs/apps/providers/file-upload.mdx
+++ b/docs/apps/providers/file-upload.mdx
@@ -49,7 +49,7 @@ FileUpload(
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
+## 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.
@@ -77,7 +77,7 @@ class SharedUpload(FileUpload):
return "__shared__"
```
-## Custom Storage
+## 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.
diff --git a/docs/apps/providers/form.mdx b/docs/apps/providers/form.mdx
index ca598f33f..e61dc0ce0 100644
--- a/docs/apps/providers/form.mdx
+++ b/docs/apps/providers/form.mdx
@@ -44,7 +44,7 @@ This registers two tools:
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
+## Field mapping
`FormInput` uses Prefab's `Form.from_model()`, which maps Pydantic types to form components:
@@ -89,7 +89,7 @@ FormInput(
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
+## Multiple forms
Add multiple providers for different models — each gets its own tool:
diff --git a/docs/apps/providers/generative.mdx b/docs/apps/providers/generative.mdx
deleted file mode 100644
index a0795c939..000000000
--- a/docs/apps/providers/generative.mdx
+++ /dev/null
@@ -1,74 +0,0 @@
----
-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
index 91265f884..e9c39ef21 100644
--- a/docs/apps/quickstart.mdx
+++ b/docs/apps/quickstart.mdx
@@ -1,7 +1,7 @@
---
title: Quickstart
sidebarTitle: Quickstart
-description: Build your first MCP app in under a minute.
+description: Build your first FastMCP app in under a minute.
icon: rocket
tag: NEW
---
@@ -10,33 +10,29 @@ 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.
+By the end of this page, you'll have a working tool that returns this:
-This tutorial builds a working app from scratch. Here's what you'll have in about a minute:
+
-
-
-
+A pie chart the user can hover, a table they can sort and search — and a single Python tool.
-## Setup
-
-Install FastMCP with the `apps` extra, which pulls in Prefab UI:
+## Install
```bash
pip install "fastmcp[apps]"
```
-## A Tool That Returns a UI
+The `apps` extra pulls in [Prefab](https://prefab.prefect.io), the Python component library used to build app UIs.
-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.
+## Write the tool
-Create `server.py`:
+Create `server.py`. The interesting parts: `app=True` tells FastMCP this tool renders a UI, and `with PrefabApp() as app:` is the canonical pattern for composing one.
```python server.py expandable
from collections import Counter
from prefab_ui.app import PrefabApp
-from prefab_ui.components import Column, Grid, Heading, DataTable, DataTableColumn
+from prefab_ui.components import Column, DataTable, DataTableColumn, Grid
from prefab_ui.components.charts import PieChart
from fastmcp import FastMCP
@@ -63,7 +59,6 @@ def team_directory() -> PrefabApp:
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,
@@ -84,40 +79,42 @@ def team_directory() -> PrefabApp:
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 reads top-to-bottom. `PrefabApp()` is the root; everything inside its `with` block becomes the UI. `Column` stacks children vertically, `Grid` lays them out in columns. `DataTable` takes rows and column definitions and gives you sort and search for free.
-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.
+`app=True` does the rest: it sets up the renderer resource, the content security policy, and the metadata that tells the host "this tool returns a UI." The host loads the result in a sandboxed iframe where the user can interact with it — all client-side, no round-trips.
-## Running It
+## Preview it
-FastMCP includes a dev server that renders your app tools in a browser, no MCP host needed:
+FastMCP ships 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:
+Open `http://localhost:8080`, pick `team_directory`, and try sorting columns and searching.
-
+
+## Make it reactive
+
+The UI above renders once from your Python. Prefab apps can also respond to user input live, without any server round-trips. The key concept is **state**: a client-side key-value store that components read from and write to.
+
+Click a row in the demo below to see a detail card appear:
+
+
+
+Add a few imports, give each member a couple more fields, wire up a click handler, and render a detail card when something's selected:
+
```python expandable server.py
from collections import Counter
from prefab_ui.actions import SetState
from prefab_ui.app import PrefabApp
from prefab_ui.components import (
- Card, CardContent, CardHeader, Column, Grid, H3, Heading, Muted,
- Row, DataTable, DataTableColumn, Badge, Small, Text,
+ Badge, Card, CardContent, CardHeader, Column, DataTable, DataTableColumn,
+ Grid, H3, Row, Small, Text,
)
from prefab_ui.components.charts import PieChart
from prefab_ui.components.control_flow import If
@@ -129,16 +126,12 @@ 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},
+ # ... more members ...
]
OFFICE_COUNTS = [
- {"office": office, "count": count}
- for office, count in Counter(m["office"] for m in MEMBERS).items()
+ {"office": o, "count": c}
+ for o, c in Counter(m["office"] for m in MEMBERS).items()
]
@@ -147,7 +140,6 @@ 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,
@@ -187,22 +179,18 @@ def team_directory() -> PrefabApp:
return app
```
-Three new ideas here:
+Three new ideas do all the work:
-**`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).
+- **`on_row_click=SetState("selected", Rx("$event"))`** — clicking a row writes its data into the `selected` state key. `$event` is the clicked row dict.
+- **`Rx("selected.name")`** — a reactive reference. It doesn't hold a Python value; it compiles to a browser-side expression that re-evaluates whenever `selected` changes, so `Text(Rx("selected.name"))` always shows the latest clicked name.
+- **`If(STATE.selected)`** — conditionally renders its body. Before any click, `selected` is `None` and the card stays hidden.
-**`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.
+The `state={"selected": None}` dict on `PrefabApp` sets the initial value. Everything else happens in the browser — no round-trips to your server when the user clicks.
-**`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.
+## Where to go next
-The `state` dict on `PrefabApp` sets initial values when the app loads. Run `fastmcp dev apps server.py` again and try clicking a row.
+You've built a tool that returns an interactive, reactive UI. This pattern covers a huge range of use cases: build a visualization, return it, and the user gets it rendered right in the conversation.
-## 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.
+- **[Interactive Tools](/apps/prefab)** — charts, tables, dashboards, reactive state, with live demos
+- **[FastMCPApp](/apps/interactive-apps)** — when the UI needs to call back to your server (forms, search, CRUD)
+- **[Examples](/apps/examples)** — complete working servers you can run today
diff --git a/docs/docs.json b/docs/docs.json
index 843d67c7e..3dcbc1afc 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -197,19 +197,10 @@
"pages": [
"apps/overview",
"apps/quickstart",
- "apps/examples",
- {
- "collapsed": true,
- "group": "Building Apps",
- "icon": "hammer",
- "pages": [
- "apps/prefab",
- "apps/interactive-apps",
- "apps/generative",
- "apps/patterns"
- ],
- "tag": "NEW"
- },
+ "apps/prefab",
+ "apps/interactive-apps",
+ "apps/generative",
+ "apps/low-level",
{
"collapsed": true,
"group": "Providers",
@@ -218,21 +209,19 @@
"apps/providers/approval",
"apps/providers/choice",
"apps/providers/file-upload",
- "apps/providers/form",
- "apps/providers/generative"
+ "apps/providers/form"
],
"tag": "NEW"
},
{
"collapsed": true,
- "group": "Advanced",
- "icon": "gear",
+ "group": "Reference",
+ "icon": "book",
"pages": [
"apps/development",
- "apps/architecture",
- "apps/low-level"
- ],
- "tag": "NEW"
+ "apps/examples",
+ "apps/architecture"
+ ]
}
]
},
@@ -296,6 +285,7 @@
"integrations/eunomia-authorization",
"integrations/github",
"integrations/google",
+ "integrations/keycloak",
"integrations/oci",
"integrations/permit",
"integrations/propelauth",
@@ -412,6 +402,14 @@
]
},
"redirects": [
+ {
+ "destination": "/apps/generative",
+ "source": "/apps/providers/generative"
+ },
+ {
+ "destination": "/apps/prefab",
+ "source": "/apps/patterns"
+ },
{
"destination": "/cli/overview",
"source": "/patterns/cli"
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.
+
- 
+
+ 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/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/python-sdk-pages.json b/docs/python-sdk-pages.json
index e55c709df..86cfabc6b 100644
--- a/docs/python-sdk-pages.json
+++ b/docs/python-sdk-pages.json
@@ -196,6 +196,7 @@
"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",
@@ -315,6 +316,7 @@
"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",
diff --git a/docs/python-sdk/fastmcp-cli-generate.mdx b/docs/python-sdk/fastmcp-cli-generate.mdx
index ebedaa186..0d9285189 100644
--- a/docs/python-sdk/fastmcp-cli-generate.mdx
+++ b/docs/python-sdk/fastmcp-cli-generate.mdx
@@ -33,7 +33,7 @@ generate_cli_script(server_name: str, server_spec: str, transport_code: str, ext
Generate the full CLI script source code.
-### `generate_skill_content`
+### `generate_skill_content`
```python
generate_skill_content(server_name: str, cli_filename: str, tools: list[mcp.types.Tool]) -> str
@@ -43,7 +43,7 @@ generate_skill_content(server_name: str, cli_filename: str, tools: list[mcp.type
Generate a SKILL.md file for a generated CLI script.
-### `generate_cli_command`
+### `generate_cli_command`
```python
generate_cli_command(server_spec: Annotated[str, cyclopts.Parameter(help='Server URL, Python file, MCPConfig JSON, discovered name, or .js file')], output: Annotated[str, cyclopts.Parameter(help='Output file path (default: cli.py)')] = 'cli.py') -> None
diff --git a/docs/python-sdk/fastmcp-cli-install-goose.mdx b/docs/python-sdk/fastmcp-cli-install-goose.mdx
index cd2a8cc9a..af5aed24c 100644
--- a/docs/python-sdk/fastmcp-cli-install-goose.mdx
+++ b/docs/python-sdk/fastmcp-cli-install-goose.mdx
@@ -29,7 +29,7 @@ Generate a Goose deeplink for installing an MCP extension.
- A goose://extension?... deeplink URL.
-### `install_goose`
+### `install_goose`
```python
install_goose(file: Path, server_object: str | None, name: str) -> bool
@@ -49,7 +49,7 @@ Install FastMCP server in Goose via deeplink.
- True if installation was successful, False otherwise.
-### `goose_command`
+### `goose_command`
```python
goose_command(server_spec: str) -> 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-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-template.mdx b/docs/python-sdk/fastmcp-resources-template.mdx
index 540866c5a..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
@@ -216,7 +230,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
@@ -234,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 def0830fd..8f7954cd9 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-oauth_proxy-proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx
index 866c26570..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 904968111..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 3b834c67b..e0f46689c 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx
@@ -71,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 ea22062f4..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-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-jwt.mdx b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx
index b049a4d64..68c39df63 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx
@@ -97,7 +97,7 @@ Validate a JWT bearer token and return an AccessToken when the token is valid.
- AccessToken | None: An AccessToken populated from token claims if the token is valid; `None` if the token is expired, has an invalid signature or format, fails issuer/audience/scope validation, or any other validation error occurs.
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
@@ -115,7 +115,7 @@ to our existing load_access_token method.
- AccessToken object if valid, None if invalid or expired
-### `StaticTokenVerifier`
+### `StaticTokenVerifier`
Simple static token verifier for testing and development.
@@ -136,7 +136,7 @@ WARNING: Never use this in production - tokens are stored in plain text!
**Methods:**
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-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 86cd88f59..7917f1504 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).
@@ -82,10 +82,31 @@ IMPORTANT SETUP REQUIREMENTS:
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.
+
**Methods:**
-#### `get_routes`
+#### `set_mcp_path`
+
+```python
+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.
+
+
+#### `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 396e16c50..9148f3661 100644
--- a/docs/python-sdk/fastmcp-server-context.mdx
+++ b/docs/python-sdk/fastmcp-server-context.mdx
@@ -577,37 +577,37 @@ regardless of this setting.
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
@@ -634,9 +634,17 @@ Clients must send an empty object ("{}")in response.
- `response_type`: The type of the response, which should be a primitive
type or dataclass or BaseModel. If it is a primitive type, an
object schema with a single "value" field will be generated.
+- `response_title`: Optional label to display for the wrapped ``value``
+field when ``response_type`` is a scalar, Literal, Enum, or one
+of the dict/list shorthand forms. Overrides the auto-generated
+"Value" label. Raises ``TypeError`` if passed with a BaseModel,
+dataclass, or ``None`` response type (use ``Field(title=...)``
+on the model instead).
+- `response_description`: Optional description to attach to the wrapped
+``value`` field. Same scope rules as ``response_title``.
-#### `set_state`
+#### `set_state`
```python
set_state(self, key: str, value: Any) -> None
@@ -657,7 +665,7 @@ requests.
The key is automatically prefixed with the session identifier.
-#### `get_state`
+#### `get_state`
```python
get_state(self, key: str) -> Any
@@ -671,7 +679,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 +690,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 +714,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 +738,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 64186a6cd..927434df2 100644
--- a/docs/python-sdk/fastmcp-server-dependencies.mdx
+++ b/docs/python-sdk/fastmcp-server-dependencies.mdx
@@ -15,74 +15,7 @@ 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
@@ -103,7 +36,7 @@ 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
@@ -117,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]
@@ -143,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
@@ -153,7 +86,7 @@ get_context() -> Context
Get the current FastMCP Context instance directly.
-### `get_server`
+### `get_server`
```python
get_server() -> FastMCP
@@ -173,7 +106,7 @@ started the worker).
- `RuntimeError`: If no server in context
-### `get_http_request`
+### `get_http_request`
```python
get_http_request() -> Request
@@ -187,7 +120,7 @@ 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]
@@ -208,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
@@ -227,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]
@@ -252,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]
@@ -278,7 +211,7 @@ time, so all injection goes through the unified DI system.
which will be filtered out)
-### `CurrentContext`
+### `CurrentContext`
```python
CurrentContext() -> Context
@@ -297,7 +230,7 @@ current MCP operation (tool/resource/prompt call).
- `RuntimeError`: If no active context found (during resolution)
-### `OptionalCurrentContext`
+### `OptionalCurrentContext`
```python
OptionalCurrentContext() -> Context | None
@@ -307,7 +240,7 @@ OptionalCurrentContext() -> Context | None
Get the current FastMCP Context, or None when no context is active.
-### `CurrentDocket`
+### `CurrentDocket`
```python
CurrentDocket() -> Docket
@@ -327,7 +260,7 @@ automatically creates for background task scheduling.
- `ImportError`: If fastmcp[tasks] not installed
-### `CurrentWorker`
+### `CurrentWorker`
```python
CurrentWorker() -> Worker
@@ -347,7 +280,7 @@ automatically creates for background task processing.
- `ImportError`: If fastmcp[tasks] not installed
-### `CurrentFastMCP`
+### `CurrentFastMCP`
```python
CurrentFastMCP() -> FastMCP
@@ -365,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
@@ -385,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]
@@ -403,7 +336,7 @@ transport.
- A dependency that resolves to a dictionary of header name -> value
-### `CurrentAccessToken`
+### `CurrentAccessToken`
```python
CurrentAccessToken() -> AccessToken
@@ -422,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
@@ -447,62 +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.
-
-
-### `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, session_id: str, task_id: str, ttl_seconds: int) -> None
-```
-
-Store this snapshot as a single Redis key.
-
-
-### `ProgressLike`
+### `ProgressLike`
Protocol for progress tracking interface.
@@ -513,7 +391,7 @@ and Docket's Progress (worker context).
**Methods:**
-#### `current`
+#### `current`
```python
current(self) -> int | None
@@ -522,7 +400,7 @@ current(self) -> int | None
Current progress value.
-#### `total`
+#### `total`
```python
total(self) -> int
@@ -531,7 +409,7 @@ total(self) -> int
Total/target progress value.
-#### `message`
+#### `message`
```python
message(self) -> str | None
@@ -540,7 +418,7 @@ message(self) -> str | None
Current progress message.
-#### `set_total`
+#### `set_total`
```python
set_total(self, total: int) -> None
@@ -549,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
@@ -558,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
@@ -567,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.
@@ -579,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
@@ -606,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
@@ -615,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
@@ -624,7 +502,7 @@ set_message(self, message: str | None) -> None
Update the progress status message.
-### `Progress`
+### `Progress`
Progress dependency that works in both server and worker contexts.
@@ -639,7 +517,7 @@ share mutable state.
**Methods:**
-#### `current`
+#### `current`
```python
current(self) -> int | None
@@ -648,7 +526,7 @@ current(self) -> int | None
Current progress value.
-#### `total`
+#### `total`
```python
total(self) -> int
@@ -657,7 +535,7 @@ total(self) -> int
Total/target progress value.
-#### `message`
+#### `message`
```python
message(self) -> str | None
@@ -666,7 +544,7 @@ message(self) -> str | None
Current progress message.
-#### `set_total`
+#### `set_total`
```python
set_total(self, total: int) -> None
@@ -675,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
@@ -684,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-elicitation.mdx b/docs/python-sdk/fastmcp-server-elicitation.mdx
index 85f140c6e..8b33a04c2 100644
--- a/docs/python-sdk/fastmcp-server-elicitation.mdx
+++ b/docs/python-sdk/fastmcp-server-elicitation.mdx
@@ -10,7 +10,7 @@ sidebarTitle: elicitation
### `parse_elicit_response_type`
```python
-parse_elicit_response_type(response_type: Any) -> ElicitConfig
+parse_elicit_response_type(response_type: Any, response_title: str | None = None, response_description: str | None = None) -> ElicitConfig
```
@@ -27,8 +27,15 @@ Supports multiple syntaxes:
- Scalar types (bool, int, float, str, Literal, Enum): single value
- Other types (dataclass, BaseModel): use directly
+The ``response_title`` and ``response_description`` arguments customize the
+label and description of the wrapped ``value`` property for the scalar/dict/list
+shorthand forms. They are only valid when FastMCP is wrapping the response
+type; passing them with a full BaseModel/dataclass (or ``None``) raises
+``TypeError``, because in those cases the user already controls field
+metadata via ``Field(title=..., description=...)``.
-### `handle_elicit_accept`
+
+### `handle_elicit_accept`
```python
handle_elicit_accept(config: ElicitConfig, content: Any) -> AcceptedElicitation[Any]
@@ -45,7 +52,7 @@ Handle an accepted elicitation response.
- AcceptedElicitation with the extracted/validated data
-### `get_elicitation_schema`
+### `get_elicitation_schema`
```python
get_elicitation_schema(response_type: type[T]) -> dict[str, Any]
@@ -58,7 +65,7 @@ Get the schema for an elicitation response.
- `response_type`: The type of the response
-### `validate_elicitation_json_schema`
+### `validate_elicitation_json_schema`
```python
validate_elicitation_json_schema(schema: dict[str, Any]) -> None
diff --git a/docs/python-sdk/fastmcp-server-middleware-error_handling.mdx b/docs/python-sdk/fastmcp-server-middleware-error_handling.mdx
index 3089f4d2c..d60c2468b 100644
--- a/docs/python-sdk/fastmcp-server-middleware-error_handling.mdx
+++ b/docs/python-sdk/fastmcp-server-middleware-error_handling.mdx
@@ -50,7 +50,7 @@ backoff to avoid overwhelming the server or external dependencies.
**Methods:**
-#### `on_request`
+#### `on_request`
```python
on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any
diff --git a/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx b/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx
index 0602510ce..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,7 @@ get_app_tool(self, app_name: str, tool_name: str) -> Tool | None
Delegate to nested server's get_app_tool, wrapping for middleware.
-#### `get_tool_by_hash`
+#### `get_tool_by_hash`
```python
get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None
@@ -228,7 +228,7 @@ 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`
+#### `get_tasks`
```python
get_tasks(self) -> Sequence[FastMCPComponent]
@@ -242,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-prefab_synthesis.mdx b/docs/python-sdk/fastmcp-server-providers-prefab_synthesis.mdx
index b3b2c9734..c06fc8b7a 100644
--- a/docs/python-sdk/fastmcp-server-providers-prefab_synthesis.mdx
+++ b/docs/python-sdk/fastmcp-server-providers-prefab_synthesis.mdx
@@ -23,7 +23,7 @@ the app name + tool name). CSP on the resource is the tool's
## Functions
-### `synthesize_prefab_resources`
+### `synthesize_prefab_resources`
```python
synthesize_prefab_resources(server: FastMCP) -> list[Resource]
@@ -33,7 +33,7 @@ synthesize_prefab_resources(server: FastMCP) -> list[Resource]
Return fresh synthetic Prefab resources for all prefab tools. Pure.
-### `synthesize_prefab_resource_by_uri`
+### `synthesize_prefab_resource_by_uri`
```python
synthesize_prefab_resource_by_uri(server: FastMCP, uri: str) -> Resource | None
@@ -43,7 +43,7 @@ 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`
+### `rewrite_tool_meta_for_wire`
```python
rewrite_tool_meta_for_wire(tool: Tool) -> Tool
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/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx
index 73c65b091..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
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/elicitation.mdx b/docs/servers/elicitation.mdx
index 600a3d2fe..923e704c6 100644
--- a/docs/servers/elicitation.mdx
+++ b/docs/servers/elicitation.mdx
@@ -156,6 +156,28 @@ async def pick_a_boolean(ctx: Context) -> str:
```
+#### Customizing the Field Label
+
+
+
+When FastMCP wraps a scalar, `Literal`, `Enum`, or one of the constrained-option shorthands, the wrapper's `value` property is labelled `"Value"` by default — and some clients (including VS Code) render that label directly in the UI. Pass `response_title` and `response_description` to override it:
+
+```python
+@mcp.tool
+async def confirm_purchase(ctx: Context) -> str:
+ result = await ctx.elicit(
+ "Buy 1x Baguette?",
+ response_type=bool,
+ response_title="Confirm purchase",
+ response_description="Approve this transaction?",
+ )
+ if result.action == "accept":
+ return "Purchased" if result.data else "Declined"
+ return "No response"
+```
+
+These arguments only apply when FastMCP is adding the wrapper. For structured responses (`BaseModel`, dataclass, `TypedDict`), set the metadata on the individual fields via `Field(title=..., description=...)` — passing `response_title` or `response_description` alongside a model type raises `TypeError`.
+
### No Response
Sometimes, the goal of an elicitation is to simply get a user to approve or reject an action. Pass `None` as the response type to indicate that no data is expected. The `data` field will be `None` when the user accepts.
diff --git a/docs/servers/telemetry.mdx b/docs/servers/telemetry.mdx
index 1055dc0c0..bf755ceba 100644
--- a/docs/servers/telemetry.mdx
+++ b/docs/servers/telemetry.mdx
@@ -61,14 +61,14 @@ The server creates spans for each operation using [MCP semantic conventions](htt
| Span Name | Description |
|-----------|-------------|
| `tools/call {name}` | Tool execution (e.g., `tools/call get_weather`) |
-| `resources/read {uri}` | Resource read (e.g., `resources/read config://database`) |
+| `resources/read` | Resource read (URI in `mcp.resource.uri` attribute, not span name) |
| `prompts/get {name}` | Prompt render (e.g., `prompts/get greeting`) |
For mounted servers, an additional `delegate {name}` span shows the delegation to the child server.
### Client Spans
-The FastMCP client creates spans for outgoing requests with the same naming pattern (`tools/call {name}`, `resources/read {uri}`, `prompts/get {name}`).
+The FastMCP client creates spans for outgoing requests with the same naming pattern (`tools/call {name}`, `resources/read`, `prompts/get {name}`).
### Span Hierarchy
@@ -186,21 +186,16 @@ def risky_operation() -> str:
raise ValueError("Something went wrong")
# The span will have:
-# - status = ERROR
+# - status = ERROR with exception message as description
+# - error.type = "tool_error" (or exception class name for non-tool errors)
# - exception event with stack trace
```
## Attributes Reference
-### RPC Semantic Conventions
-
-Standard [RPC semantic conventions](https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/):
-
-| Attribute | Value |
-|-----------|-------|
-| `rpc.system` | `"mcp"` |
-| `rpc.service` | Server name |
-| `rpc.method` | MCP protocol method |
+
+**Migrating from v3.1 or earlier:** The `rpc.system`, `rpc.service`, and `rpc.method` span attributes were removed in favor of the [MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/) listed below. If you have dashboards or alerts keyed on those `rpc.*` attributes, update them to use `mcp.method.name` and the `fastmcp.*` attributes instead.
+
### MCP Semantic Conventions
@@ -211,6 +206,9 @@ FastMCP implements the [OpenTelemetry MCP semantic conventions](https://opentele
| `mcp.method.name` | The MCP method being called (`tools/call`, `resources/read`, `prompts/get`) |
| `mcp.session.id` | Session identifier for the MCP connection |
| `mcp.resource.uri` | The resource URI (for resource operations) |
+| `gen_ai.tool.name` | Tool name (on `tools/call` spans) |
+| `gen_ai.prompt.name` | Prompt name (on `prompts/get` spans) |
+| `error.type` | Error classification (`tool_error` for ToolError, otherwise exception class name) |
### Auth Attributes
diff --git a/docs/snippets/prefab-pin-warning.mdx b/docs/snippets/prefab-pin-warning.mdx
new file mode 100644
index 000000000..098181fba
--- /dev/null
+++ b/docs/snippets/prefab-pin-warning.mdx
@@ -0,0 +1,3 @@
+
+[Prefab](https://prefab.prefect.io) is under active development with frequent breaking changes. FastMCP sets a minimum `prefab-ui` version but does not pin an upper bound — **pin `prefab-ui` to a specific version in your own dependencies** before deploying.
+
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/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
index 84d2b44b1..9a79ff566 100644
--- a/examples/auth/clerk_oauth/README.md
+++ b/examples/auth/clerk_oauth/README.md
@@ -9,7 +9,7 @@ Demonstrates FastMCP server protection with Clerk OAuth.
- Create or select an application
- Go to Developers > OAuth Applications
- Create an OAuth 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
- Note your instance domain (e.g., `saving-primate-16.clerk.accounts.dev`)
diff --git a/examples/auth/clerk_oauth/server.py b/examples/auth/clerk_oauth/server.py
index 74b1e4687..e7d080734 100644
--- a/examples/auth/clerk_oauth/server.py
+++ b/examples/auth/clerk_oauth/server.py
@@ -21,7 +21,7 @@ auth = ClerkProvider(
domain=os.getenv("FASTMCP_SERVER_AUTH_CLERK_DOMAIN") or "",
client_id=os.getenv("FASTMCP_SERVER_AUTH_CLERK_CLIENT_ID") or "",
client_secret=os.getenv("FASTMCP_SERVER_AUTH_CLERK_CLIENT_SECRET") or "",
- base_url="http://localhost:8000",
+ 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"],
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/testing_demo/pyproject.toml b/examples/testing_demo/pyproject.toml
index dce14d85e..6130b9cf2 100644
--- a/examples/testing_demo/pyproject.toml
+++ b/examples/testing_demo/pyproject.toml
@@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"fastmcp>=2.0.0",
- "pytest>=8.3.3",
+ "pytest>=9.0.3",
"pytest-asyncio>=1.2.0",
"dirty-equals>=0.9.0",
]
diff --git a/examples/testing_demo/uv.lock b/examples/testing_demo/uv.lock
index da907335b..c0b8e5f07 100644
--- a/examples/testing_demo/uv.lock
+++ b/examples/testing_demo/uv.lock
@@ -933,7 +933,7 @@ wheels = [
[[package]]
name = "pytest"
-version = "9.0.2"
+version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -944,9 +944,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
@@ -1292,7 +1292,7 @@ dependencies = [
requires-dist = [
{ name = "dirty-equals", specifier = ">=0.9.0" },
{ name = "fastmcp", specifier = ">=2.0.0" },
- { name = "pytest", specifier = ">=8.3.3" },
+ { name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
]
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 1572feef0..d3748bbb4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -180,6 +180,7 @@ error-on-warning = true
fixable = ["ALL"]
ignore = [
"COM812",
+ "PERF203", # try-except in loop — all existing hits are intentional (retry loops, error skipping)
"PLR0913", # Too many arguments, MCP Servers have a lot of arguments, OKAY?!
"SIM102", # Dont require combining if statements
]
@@ -194,12 +195,14 @@ extend-select = [
"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
+ "PERF", # perflint: Performance anti-patterns (unnecessary copies, allocations)
"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
+ "T20", # flake8-print: Catch accidental print() in library code
"TID", # flake8-tidy-imports: Banned imports and relative import enforcement
"UP", # pyupgrade: Modernize syntax for newer Python versions
]
@@ -211,6 +214,10 @@ known-first-party = ["fastmcp"]
"__init__.py" = ["F401", "I001", "RUF013"]
# allow imports not at the top of the file
"src/fastmcp/__init__.py" = ["E402"]
+# CLI and example code legitimately uses print() for user-facing output
+"src/fastmcp/cli/**.py" = ["T20"]
+"src/fastmcp/client/oauth_callback.py" = ["T20"]
+"src/fastmcp/contrib/**/example.py" = ["T20"]
"!src/**.py" = [ # Only enforce extended ruff rules for code in src/
"B", # flake8-bugbear
"C4", # flake8-comprehensions
@@ -221,12 +228,14 @@ known-first-party = ["fastmcp"]
"INP", # flake8-no-pep420
"ISC", # flake8-implicit-str-concat
"LOG", # flake8-logging
+ "PERF", # perflint
"PIE", # flake8-pie
"PLE", # pylint-error
"RSE", # flake8-raise
"RUF", # Ruff-specific
"SIM", # flake8-simplify
"SLOT", # flake8-slots
+ "T20", # flake8-print
"TID", # flake8-tidy-imports
]
diff --git a/src/fastmcp/cli/generate.py b/src/fastmcp/cli/generate.py
index b5e652909..c46cac50e 100644
--- a/src/fastmcp/cli/generate.py
+++ b/src/fastmcp/cli/generate.py
@@ -261,7 +261,7 @@ def _tool_function_source(tool: mcp.types.Tool) -> str:
# Build call arguments, using parsed versions for JSON params
call_arg_parts = []
- for prop_name, _ in properties.items():
+ for prop_name in properties:
safe_name = _to_python_identifier(prop_name)
if any(pn == prop_name for pn, _ in json_params):
call_arg_parts.append(f"{prop_name!r}: {safe_name}_parsed")
@@ -313,8 +313,7 @@ def generate_cli_script(
lines.append("from rich.console import Console")
lines.append("")
lines.append("from fastmcp import Client")
- for imp in sorted(extra_imports):
- lines.append(imp)
+ lines.extend(sorted(extra_imports))
lines.append("")
# --- Transport config ---
@@ -506,8 +505,7 @@ def generate_cli_script(
"# ---------------------------------------------------------------------------"
)
- for tool in tools:
- lines.append(_tool_function_source(tool))
+ lines.extend(_tool_function_source(tool) for tool in tools)
# --- Entry point ---
lines.append("")
diff --git a/src/fastmcp/cli/install/goose.py b/src/fastmcp/cli/install/goose.py
index 16161dcf1..8d5712833 100644
--- a/src/fastmcp/cli/install/goose.py
+++ b/src/fastmcp/cli/install/goose.py
@@ -47,8 +47,7 @@ def generate_goose_deeplink(
extension_id = _slugify(name)
params: list[str] = [f"cmd={quote(command, safe='')}"]
- for arg in args:
- params.append(f"arg={quote(arg, safe='')}")
+ params.extend(f"arg={quote(arg, safe='')}" for arg in args)
params.append(f"id={quote(extension_id, safe='')}")
params.append(f"name={quote(name, safe='')}")
params.append(f"description={quote(description, safe='')}")
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/prompts.py b/src/fastmcp/client/mixins/prompts.py
index 4b87bf270..de503b448 100644
--- a/src/fastmcp/client/mixins/prompts.py
+++ b/src/fastmcp/client/mixins/prompts.py
@@ -49,12 +49,18 @@ class ClientPromptsMixin:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
"""
- logger.debug(f"[{self.name}] called list_prompts")
+ with client_span(
+ "prompts/list",
+ "prompts/list",
+ "",
+ session_id=self.transport.get_session_id(),
+ ):
+ logger.debug(f"[{self.name}] called list_prompts")
- result = await self._await_with_session_monitoring(
- self.session.list_prompts(cursor=cursor)
- )
- return result
+ result = await self._await_with_session_monitoring(
+ self.session.list_prompts(cursor=cursor)
+ )
+ return result
async def list_prompts(
self: Client,
@@ -130,6 +136,7 @@ class ClientPromptsMixin:
"prompts/get",
name,
session_id=self.transport.get_session_id(),
+ prompt_name=name,
):
logger.debug(f"[{self.name}] called get_prompt: {name}")
diff --git a/src/fastmcp/client/mixins/resources.py b/src/fastmcp/client/mixins/resources.py
index c0dc27fff..3cc845d50 100644
--- a/src/fastmcp/client/mixins/resources.py
+++ b/src/fastmcp/client/mixins/resources.py
@@ -48,12 +48,18 @@ class ClientResourcesMixin:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
"""
- logger.debug(f"[{self.name}] called list_resources")
+ with client_span(
+ "resources/list",
+ "resources/list",
+ "",
+ session_id=self.transport.get_session_id(),
+ ):
+ logger.debug(f"[{self.name}] called list_resources")
- result = await self._await_with_session_monitoring(
- self.session.list_resources(cursor=cursor)
- )
- return result
+ result = await self._await_with_session_monitoring(
+ self.session.list_resources(cursor=cursor)
+ )
+ return result
async def list_resources(
self: Client,
@@ -118,12 +124,18 @@ class ClientResourcesMixin:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
"""
- logger.debug(f"[{self.name}] called list_resource_templates")
+ with client_span(
+ "resources/templates/list",
+ "resources/templates/list",
+ "",
+ session_id=self.transport.get_session_id(),
+ ):
+ logger.debug(f"[{self.name}] called list_resource_templates")
- result = await self._await_with_session_monitoring(
- self.session.list_resource_templates(cursor=cursor)
- )
- return result
+ result = await self._await_with_session_monitoring(
+ self.session.list_resource_templates(cursor=cursor)
+ )
+ return result
async def list_resource_templates(
self: Client,
@@ -193,7 +205,7 @@ class ClientResourcesMixin:
"""
uri_str = str(uri)
with client_span(
- f"resources/read {uri_str}",
+ "resources/read",
"resources/read",
uri_str,
session_id=self.transport.get_session_id(),
diff --git a/src/fastmcp/client/mixins/tools.py b/src/fastmcp/client/mixins/tools.py
index aec595019..07634b82e 100644
--- a/src/fastmcp/client/mixins/tools.py
+++ b/src/fastmcp/client/mixins/tools.py
@@ -7,6 +7,7 @@ import weakref
from typing import TYPE_CHECKING, Any, Literal, overload
import mcp.types
+from opentelemetry.trace import Status, StatusCode
from pydantic import RootModel
if TYPE_CHECKING:
@@ -52,12 +53,18 @@ class ClientToolsMixin:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
"""
- logger.debug(f"[{self.name}] called list_tools")
+ with client_span(
+ "tools/list",
+ "tools/list",
+ "",
+ session_id=self.transport.get_session_id(),
+ ):
+ logger.debug(f"[{self.name}] called list_tools")
- result = await self._await_with_session_monitoring(
- self.session.list_tools(cursor=cursor)
- )
- return result
+ result = await self._await_with_session_monitoring(
+ self.session.list_tools(cursor=cursor)
+ )
+ return result
async def list_tools(
self: Client,
@@ -144,7 +151,8 @@ class ClientToolsMixin:
"tools/call",
name,
session_id=self.transport.get_session_id(),
- ):
+ tool_name=name,
+ ) as span:
logger.debug(f"[{self.name}] called call_tool: {name}")
# Inject trace context into meta for propagation to server
@@ -159,6 +167,18 @@ class ClientToolsMixin:
meta=propagated_meta if propagated_meta else None,
)
)
+
+ # Reflect tool-level errors on the span so callers see ERROR
+ # status even though the MCP protocol call itself succeeded.
+ if result.isError and span.is_recording():
+ span.set_attribute("error.type", "tool_error")
+ description = ""
+ if result.content and isinstance(
+ result.content[0], mcp.types.TextContent
+ ):
+ description = result.content[0].text
+ span.set_status(Status(StatusCode.ERROR, description))
+
return result
async def _parse_call_tool_result(
diff --git a/src/fastmcp/client/sampling/handlers/anthropic.py b/src/fastmcp/client/sampling/handlers/anthropic.py
index 0939121d1..945da5e2f 100644
--- a/src/fastmcp/client/sampling/handlers/anthropic.py
+++ b/src/fastmcp/client/sampling/handlers/anthropic.py
@@ -216,12 +216,11 @@ class AnthropicSamplingHandler:
# Extract text content from the result
result_content: str | list[TextBlockParam] = ""
if item.content:
- text_blocks: list[TextBlockParam] = []
- for sub_item in item.content:
- if isinstance(sub_item, TextContent):
- text_blocks.append(
- TextBlockParam(type="text", text=sub_item.text)
- )
+ text_blocks: list[TextBlockParam] = [
+ TextBlockParam(type="text", text=sub_item.text)
+ for sub_item in item.content
+ if isinstance(sub_item, TextContent)
+ ]
if len(text_blocks) == 1:
result_content = text_blocks[0]["text"]
elif text_blocks:
@@ -270,12 +269,11 @@ class AnthropicSamplingHandler:
if isinstance(content, ToolResultContent):
result_content_str: str | list[TextBlockParam] = ""
if content.content:
- text_parts: list[TextBlockParam] = []
- for item in content.content:
- if isinstance(item, TextContent):
- text_parts.append(
- TextBlockParam(type="text", text=item.text)
- )
+ text_parts: list[TextBlockParam] = [
+ TextBlockParam(type="text", text=item.text)
+ for item in content.content
+ if isinstance(item, TextContent)
+ ]
if len(text_parts) == 1:
result_content_str = text_parts[0]["text"]
elif text_parts:
diff --git a/src/fastmcp/client/sampling/handlers/google_genai.py b/src/fastmcp/client/sampling/handlers/google_genai.py
index 614138722..7404fb1eb 100644
--- a/src/fastmcp/client/sampling/handlers/google_genai.py
+++ b/src/fastmcp/client/sampling/handlers/google_genai.py
@@ -280,9 +280,9 @@ def _convert_messages_to_google_genai_content(
# Handle list content (tool calls + results)
if isinstance(content, list):
- parts: list[Part] = []
- for item in content:
- parts.append(_sampling_content_to_google_genai_part(item))
+ parts: list[Part] = [
+ _sampling_content_to_google_genai_part(item) for item in content
+ ]
if message.role == "user":
google_messages.append(UserContent(parts=parts))
diff --git a/src/fastmcp/client/sampling/handlers/openai.py b/src/fastmcp/client/sampling/handlers/openai.py
index bbd6a0ae5..2c8db6935 100644
--- a/src/fastmcp/client/sampling/handlers/openai.py
+++ b/src/fastmcp/client/sampling/handlers/openai.py
@@ -231,10 +231,11 @@ class OpenAISamplingHandler:
# Collect tool results (added after assistant message)
content_text = ""
if item.content:
- result_texts = []
- for sub_item in item.content:
- if isinstance(sub_item, TextContent):
- result_texts.append(sub_item.text)
+ result_texts = [
+ sub_item.text
+ for sub_item in item.content
+ if isinstance(sub_item, TextContent)
+ ]
content_text = "\n".join(result_texts)
tool_messages.append(
ChatCompletionToolMessageParam(
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/telemetry.py b/src/fastmcp/client/telemetry.py
index 10d6d825f..e66cd7b47 100644
--- a/src/fastmcp/client/telemetry.py
+++ b/src/fastmcp/client/telemetry.py
@@ -5,6 +5,7 @@ from contextlib import contextmanager
from opentelemetry.trace import Span, SpanKind, Status, StatusCode
+from fastmcp.exceptions import ToolError as _ToolError
from fastmcp.telemetry import get_tracer
@@ -15,6 +16,8 @@ def client_span(
component_key: str,
session_id: str | None = None,
resource_uri: str | None = None,
+ tool_name: str | None = None,
+ prompt_name: str | None = None,
) -> Generator[Span, None, None]:
"""Create a CLIENT span with standard MCP attributes.
@@ -22,25 +25,32 @@ def client_span(
"""
tracer = get_tracer()
with tracer.start_as_current_span(name, kind=SpanKind.CLIENT) as span:
- attrs: dict[str, str] = {
- # RPC semantic conventions
- "rpc.system": "mcp",
- "rpc.method": method,
- # MCP semantic conventions
- "mcp.method.name": method,
- # FastMCP-specific attributes
- "fastmcp.component.key": component_key,
- }
- if session_id:
- attrs["mcp.session.id"] = session_id
- if resource_uri:
- attrs["mcp.resource.uri"] = resource_uri
- span.set_attributes(attrs)
+ if span.is_recording():
+ attrs: dict[str, str] = {
+ # MCP semantic conventions
+ "mcp.method.name": method,
+ # FastMCP-specific attributes
+ "fastmcp.component.key": component_key,
+ }
+ if session_id is not None:
+ attrs["mcp.session.id"] = session_id
+ if resource_uri:
+ attrs["mcp.resource.uri"] = resource_uri
+ if tool_name is not None:
+ attrs["gen_ai.tool.name"] = tool_name
+ if prompt_name is not None:
+ attrs["gen_ai.prompt.name"] = prompt_name
+ span.set_attributes(attrs)
try:
yield span
except Exception as e:
- span.record_exception(e)
- span.set_status(Status(StatusCode.ERROR))
+ if span.is_recording():
+ error_type = (
+ "tool_error" if isinstance(e, _ToolError) else type(e).__qualname__
+ )
+ span.set_attribute("error.type", error_type)
+ span.record_exception(e)
+ span.set_status(Status(StatusCode.ERROR, str(e)))
raise
diff --git a/src/fastmcp/prompts/function_prompt.py b/src/fastmcp/prompts/function_prompt.py
index 872b1c9d7..77b38e49d 100644
--- a/src/fastmcp/prompts/function_prompt.py
+++ b/src/fastmcp/prompts/function_prompt.py
@@ -363,7 +363,7 @@ 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."""
diff --git a/src/fastmcp/resources/base.py b/src/fastmcp/resources/base.py
index bd86459b8..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
diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py
index 265e88f91..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."""
diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py
index 3903d513f..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
@@ -332,20 +343,24 @@ class AuthProvider(TokenVerifierProtocol):
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):
@@ -359,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]:
@@ -406,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,
):
@@ -415,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
@@ -424,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
@@ -444,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
@@ -497,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.
@@ -507,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:
@@ -518,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)
@@ -593,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,
@@ -604,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.
@@ -611,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/oauth_proxy/proxy.py b/src/fastmcp/server/auth/oauth_proxy/proxy.py
index 206314c61..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,
@@ -281,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
@@ -343,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,
diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py
index ebf048c5a..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
@@ -255,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")
@@ -370,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,
diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py
index 5b1017c6a..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,
@@ -83,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"])
@@ -113,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,
diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py
index aa654e966..6f2cfcd56 100644
--- a/src/fastmcp/server/auth/providers/aws.py
+++ b/src/fastmcp/server/auth/providers/aws.py
@@ -125,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",
@@ -143,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.
@@ -184,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,
diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py
index e2abb6125..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,
@@ -131,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")
@@ -242,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,
diff --git a/src/fastmcp/server/auth/providers/clerk.py b/src/fastmcp/server/auth/providers/clerk.py
index 409a065b3..b465faf39 100644
--- a/src/fastmcp/server/auth/providers/clerk.py
+++ b/src/fastmcp/server/auth/providers/clerk.py
@@ -277,6 +277,7 @@ class ClerkProvider(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,
@@ -301,6 +302,8 @@ class ClerkProvider(OAuthProxy):
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")
@@ -364,6 +367,7 @@ class ClerkProvider(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,
allowed_client_redirect_uris=allowed_client_redirect_uris,
diff --git a/src/fastmcp/server/auth/providers/discord.py b/src/fastmcp/server/auth/providers/discord.py
index d646743f9..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,
@@ -215,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")
@@ -266,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,
diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py
index b8f5a16e2..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,
@@ -230,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")
@@ -281,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,
diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py
index 28c31a0d8..219e6c0db 100644
--- a/src/fastmcp/server/auth/providers/google.py
+++ b/src/fastmcp/server/auth/providers/google.py
@@ -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,
@@ -258,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")
@@ -341,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,
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/jwt.py b/src/fastmcp/server/auth/providers/jwt.py
index 17194329f..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 is not None and exp < time.time():
- self.logger.debug(
- "Token validation failed: expired token for client %s", client_id
+ 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,12 +496,13 @@ 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(
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 98011e4ed..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,
@@ -141,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"])
@@ -158,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,
diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py
index 85dd8feca..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,
@@ -184,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")
@@ -234,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,
@@ -273,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)
```
"""
@@ -293,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,
@@ -305,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("/"))
@@ -324,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,
)
@@ -345,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/context.py b/src/fastmcp/server/context.py
index afaae2f5a..cc66419e7 100644
--- a/src/fastmcp/server/context.py
+++ b/src/fastmcp/server/context.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
+import warnings
import weakref
from collections.abc import Callable, Generator, Mapping, Sequence
from contextlib import contextmanager
@@ -26,6 +27,8 @@ from starlette.requests import Request
from typing_extensions import TypeVar
from uncalled_for import SharedContext
+import fastmcp
+from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.resources.base import ResourceResult
from fastmcp.server.elicitation import (
AcceptedElicitation,
@@ -1017,6 +1020,9 @@ class Context:
self,
message: str,
response_type: None,
+ *,
+ response_title: str | None = None,
+ response_description: str | None = None,
) -> (
AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation
): ...
@@ -1029,6 +1035,9 @@ class Context:
self,
message: str,
response_type: type[T],
+ *,
+ response_title: str | None = None,
+ response_description: str | None = None,
) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ...
"""When response_type is not None, the accepted elicitation will contain the
@@ -1039,6 +1048,9 @@ class Context:
self,
message: str,
response_type: list[str],
+ *,
+ response_title: str | None = None,
+ response_description: str | None = None,
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
"""When response_type is a list of strings, the accepted elicitation will
@@ -1049,6 +1061,9 @@ class Context:
self,
message: str,
response_type: dict[str, dict[str, str]],
+ *,
+ response_title: str | None = None,
+ response_description: str | None = None,
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
"""When response_type is a dict mapping keys to title dicts, the accepted
@@ -1059,6 +1074,9 @@ class Context:
self,
message: str,
response_type: list[list[str]],
+ *,
+ response_title: str | None = None,
+ response_description: str | None = None,
) -> (
AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
): ...
@@ -1071,6 +1089,9 @@ class Context:
self,
message: str,
response_type: list[dict[str, dict[str, str]]],
+ *,
+ response_title: str | None = None,
+ response_description: str | None = None,
) -> (
AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
): ...
@@ -1088,6 +1109,9 @@ class Context:
| list[list[str]]
| list[dict[str, dict[str, str]]]
| None = None,
+ *,
+ response_title: str | None = None,
+ response_description: str | None = None,
) -> (
AcceptedElicitation[T]
| AcceptedElicitation[dict[str, Any]]
@@ -1109,22 +1133,47 @@ class Context:
"value" field will be generated for the MCP interaction and
automatically deconstructed into the primitive type upon response.
- If the response_type is None, the generated schema will be that of an
- empty object in order to comply with the MCP protocol requirements.
- Clients must send an empty object ("{}")in response.
+ Passing ``response_type=None`` (or omitting it) is deprecated and will
+ be removed in a future version. The resulting empty-schema form-mode
+ request is ambiguous and causes some clients (e.g. VS Code) to hang on
+ an empty form. Pass an explicit ``response_type`` describing the data
+ you want back.
Args:
message: A human-readable message explaining what information is needed
response_type: The type of the response, which should be a primitive
type or dataclass or BaseModel. If it is a primitive type, an
object schema with a single "value" field will be generated.
+ response_title: Optional label to display for the wrapped ``value``
+ field when ``response_type`` is a scalar, Literal, Enum, or one
+ of the dict/list shorthand forms. Overrides the auto-generated
+ "Value" label. Raises ``TypeError`` if passed with a BaseModel,
+ dataclass, or ``None`` response type (use ``Field(title=...)``
+ on the model instead).
+ response_description: Optional description to attach to the wrapped
+ ``value`` field. Same scope rules as ``response_title``.
Note:
This method works transparently in both request and background task
contexts. In background task mode (SEP-1686), it will set the task
status to "input_required" and wait for the client to provide input.
"""
- config = parse_elicit_response_type(response_type)
+ if response_type is None and fastmcp.settings.deprecation_warnings:
+ warnings.warn(
+ "Calling ctx.elicit() without a response_type is deprecated "
+ "and will be removed in a future version. The empty-schema "
+ "form-mode request is ambiguous under the current MCP spec "
+ "and causes some clients (e.g. VS Code) to render an empty, "
+ "non-functional form. Pass an explicit response_type "
+ "describing the data you expect back.",
+ FastMCPDeprecationWarning,
+ stacklevel=2,
+ )
+ config = parse_elicit_response_type(
+ response_type,
+ response_title=response_title,
+ response_description=response_description,
+ )
if self.is_background_task:
# Background task mode: use task-aware elicitation
diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py
index 447b8e7c7..eb1f24c3b 100644
--- a/src/fastmcp/server/dependencies.py
+++ b/src/fastmcp/server/dependencies.py
@@ -10,14 +10,10 @@ from __future__ import annotations
import contextlib
import importlib.metadata
import inspect
-import json
-import logging
import weakref
-from collections import OrderedDict
from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from contextvars import ContextVar
-from dataclasses import dataclass
from datetime import datetime, timezone
from functools import lru_cache
from types import TracebackType
@@ -45,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
@@ -86,337 +79,30 @@ __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)
-# --- Unified task context snapshot ---
-
-
-@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
-
- @classmethod
- def capture(cls) -> TaskContextSnapshot:
- """Capture current context for background task execution."""
- access_token = get_access_token()
- ctx = get_context()
- request_context = ctx.request_context
- 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
- ),
- )
-
- @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"),
- )
-
- 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,
- }
- )
-
- async def save(
- self,
- docket: Docket,
- session_id: str,
- task_id: str,
- ttl_seconds: int,
- ) -> None:
- """Store this snapshot as a single Redis key."""
- key = docket.key(f"fastmcp:task:{session_id}:{task_id}:snapshot")
- async with docket.redis() as redis:
- await redis.set(key, self.to_json(), ex=ttl_seconds)
-
-
-# Cache keyed by task_id so stale entries from previous tasks in the same
-# asyncio context are automatically ignored (Docket workers may reuse contexts).
-_task_snapshot: ContextVar[tuple[str, TaskContextSnapshot] | None] = ContextVar(
- "task_snapshot", default=None
-)
-
-
-def _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 _redis_key(session_id: str, task_id: str) -> str:
- """Build the Redis key suffix for a task snapshot."""
- return f"fastmcp:task:{session_id}:{task_id}:snapshot"
-
-
-async def _load_task_snapshot_async(
- session_id: str, 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
-
- 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(_redis_key(session_id, 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",
- session_id,
- task_id,
- exc_info=True,
- )
- return 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.session_id, task_info.task_id)
-
-
-def _load_task_snapshot_sync(
- session_id: str, 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(_redis_key(session_id, 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",
- session_id,
- 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)
-
-
# --- Docket availability check ---
_DOCKET_AVAILABLE: bool | None = None
@@ -662,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:
@@ -1073,15 +755,20 @@ class _CurrentContext(Dependency["Context"]):
# Check if we're in a Docket worker context
task_info = get_task_context()
if task_info is not None:
- session = get_task_session(task_info.session_id)
server = get_server()
# Load unified snapshot (sets _task_snapshot ContextVar)
snapshot = await _load_task_snapshot_async(
- task_info.session_id, task_info.task_id
+ task_info.task_scope, task_info.task_id
)
origin_request_id = snapshot.origin_request_id if snapshot else None
+ # Session ID is stored in the snapshot for notification delivery
+ snapshot_session_id = snapshot.session_id if snapshot else None
+ session = (
+ get_task_session(snapshot_session_id) if snapshot_session_id else None
+ )
+
ctx = Context(
fastmcp=server,
session=session,
diff --git a/src/fastmcp/server/elicitation.py b/src/fastmcp/server/elicitation.py
index caa53049e..5947d223b 100644
--- a/src/fastmcp/server/elicitation.py
+++ b/src/fastmcp/server/elicitation.py
@@ -129,7 +129,11 @@ class ElicitConfig:
is_raw: bool
-def parse_elicit_response_type(response_type: Any) -> ElicitConfig:
+def parse_elicit_response_type(
+ response_type: Any,
+ response_title: str | None = None,
+ response_description: str | None = None,
+) -> ElicitConfig:
"""Parse response_type into schema and handling configuration.
Supports multiple syntaxes:
@@ -142,8 +146,25 @@ def parse_elicit_response_type(response_type: Any) -> ElicitConfig:
- `list[X]` type annotation: multi-select with type
- Scalar types (bool, int, float, str, Literal, Enum): single value
- Other types (dataclass, BaseModel): use directly
+
+ The ``response_title`` and ``response_description`` arguments customize the
+ label and description of the wrapped ``value`` property for the scalar/dict/list
+ shorthand forms. They are only valid when FastMCP is wrapping the response
+ type; passing them with a full BaseModel/dataclass (or ``None``) raises
+ ``TypeError``, because in those cases the user already controls field
+ metadata via ``Field(title=..., description=...)``.
"""
+ has_response_metadata = (
+ response_title is not None or response_description is not None
+ )
+
if response_type is None:
+ if has_response_metadata:
+ raise TypeError(
+ "response_title and response_description are not supported when "
+ "response_type is None, because the elicitation schema has no "
+ "fields to label."
+ )
return ElicitConfig(
schema={"type": "object", "properties": {}},
response_type=None,
@@ -151,23 +172,46 @@ def parse_elicit_response_type(response_type: Any) -> ElicitConfig:
)
if isinstance(response_type, dict):
- return _parse_dict_syntax(response_type)
+ config = _parse_dict_syntax(response_type)
+ elif isinstance(response_type, list):
+ config = _parse_list_syntax(response_type)
+ elif get_origin(response_type) is list:
+ config = _parse_generic_list(response_type)
+ elif _is_scalar_type(response_type):
+ config = _parse_scalar_type(response_type)
+ else:
+ # Other types (dataclass, BaseModel, etc.) - use directly
+ if has_response_metadata:
+ raise TypeError(
+ "response_title and response_description are only supported when "
+ "response_type is a scalar, Literal, Enum, or the dict/list "
+ "shorthand forms. For BaseModel or dataclass response types, use "
+ "Field(title=..., description=...) on the individual fields."
+ )
+ return ElicitConfig(
+ schema=get_elicitation_schema(response_type),
+ response_type=response_type,
+ is_raw=False,
+ )
- if isinstance(response_type, list):
- return _parse_list_syntax(response_type)
+ if has_response_metadata:
+ _apply_value_metadata(config.schema, response_title, response_description)
+ return config
- if get_origin(response_type) is list:
- return _parse_generic_list(response_type)
- if _is_scalar_type(response_type):
- return _parse_scalar_type(response_type)
-
- # Other types (dataclass, BaseModel, etc.) - use directly
- return ElicitConfig(
- schema=get_elicitation_schema(response_type),
- response_type=response_type,
- is_raw=False,
- )
+def _apply_value_metadata(
+ schema: dict[str, Any],
+ title: str | None,
+ description: str | None,
+) -> None:
+ """Override title/description on the wrapped ``value`` property in-place."""
+ value_schema = schema.get("properties", {}).get("value")
+ if value_schema is None:
+ return
+ if title is not None:
+ value_schema["title"] = title
+ if description is not None:
+ value_schema["description"] = description
def _is_scalar_type(response_type: Any) -> bool:
diff --git a/src/fastmcp/server/middleware/error_handling.py b/src/fastmcp/server/middleware/error_handling.py
index 5b235e804..81a13bb84 100644
--- a/src/fastmcp/server/middleware/error_handling.py
+++ b/src/fastmcp/server/middleware/error_handling.py
@@ -181,8 +181,18 @@ class RetryMiddleware(Middleware):
self.logger = logger or logging.getLogger("fastmcp.retry")
def _should_retry(self, error: Exception) -> bool:
- """Determine if an error should trigger a retry."""
- return isinstance(error, self.retry_exceptions)
+ """Determine if an error should trigger a retry.
+
+ Checks both the error itself and its ``__cause__``, since FastMCP
+ wraps tool exceptions as ``ToolError(...) from original``. Only one
+ level of cause is inspected — middleware below this one must not
+ re-wrap errors with a new ``from`` clause, or the real type will be
+ hidden from the retry decision.
+ """
+ if isinstance(error, self.retry_exceptions):
+ return True
+ cause = error.__cause__
+ return cause is not None and isinstance(cause, self.retry_exceptions)
def _calculate_delay(self, attempt: int) -> float:
"""Calculate delay for the given attempt number."""
diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py
index dcdea14fc..8ae2c18d3 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
# -----------------------------------------------------------------------------
@@ -139,7 +109,10 @@ class FastMCPProviderTool(Tool):
version = VersionSpec(eq=self.version) if self.version else None
with delegate_span(
- self._original_name or "", "FastMCPProvider", self._original_name or ""
+ self._original_name or "",
+ "FastMCPProvider",
+ self._original_name or "",
+ method="tools/call",
):
return await self._server.call_tool(
self._original_name,
@@ -232,7 +205,10 @@ class FastMCPProviderResource(Resource):
version = VersionSpec(eq=self.version) if self.version else None
with delegate_span(
- self._original_uri or "", "FastMCPProvider", self._original_uri or ""
+ self._original_uri or "",
+ "FastMCPProvider",
+ self._original_uri or "",
+ method="resources/read",
):
return await self._server.read_resource(
self._original_uri, version=version, task_meta=task_meta
@@ -311,7 +287,10 @@ class FastMCPProviderPrompt(Prompt):
version = VersionSpec(eq=self.version) if self.version else None
with delegate_span(
- self._original_name or "", "FastMCPProvider", self._original_name or ""
+ self._original_name or "",
+ "FastMCPProvider",
+ self._original_name or "",
+ method="prompts/get",
):
return await self._server.render_prompt(
self._original_name, arguments, version=version, task_meta=task_meta
@@ -394,7 +373,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,
@@ -424,13 +403,16 @@ 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
with delegate_span(
- original_uri, "FastMCPProvider", self._original_uri_template or ""
+ original_uri,
+ "FastMCPProvider",
+ self._original_uri_template or "",
+ method="resources/read",
):
return await self._server.read_resource(
original_uri, version=version, task_meta=task_meta
@@ -443,9 +425,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
diff --git a/src/fastmcp/server/providers/prefab_synthesis.py b/src/fastmcp/server/providers/prefab_synthesis.py
index ddbf83248..2ee639d8c 100644
--- a/src/fastmcp/server/providers/prefab_synthesis.py
+++ b/src/fastmcp/server/providers/prefab_synthesis.py
@@ -173,9 +173,11 @@ def _walk_prefab_tools(server: FastMCP) -> list[Tool]:
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)
+ results.extend(
+ component
+ for component in src._components.values()
+ if isinstance(component, Tool) and _is_prefab_tool(component)
+ )
# Recurse into aggregate children
from fastmcp.server.providers.aggregate import AggregateProvider
diff --git a/src/fastmcp/server/providers/proxy.py b/src/fastmcp/server/providers/proxy.py
index b59024c70..4876d7d30 100644
--- a/src/fastmcp/server/providers/proxy.py
+++ b/src/fastmcp/server/providers/proxy.py
@@ -119,7 +119,10 @@ class ProxyTool(Tool):
"""Executes the tool by making a call through the client."""
backend_name = self._backend_name or self.name
with client_span(
- f"tools/call {backend_name}", "tools/call", backend_name
+ f"tools/call {backend_name}",
+ "tools/call",
+ backend_name,
+ tool_name=backend_name,
) as span:
span.set_attribute("fastmcp.provider.type", "ProxyProvider")
client = await self._get_client()
@@ -235,7 +238,7 @@ class ProxyResource(Resource):
backend_uri = self._backend_uri or str(self.uri)
with client_span(
- f"resources/read {backend_uri}",
+ "resources/read",
"resources/read",
backend_uri,
resource_uri=backend_uri,
@@ -450,7 +453,10 @@ class ProxyPrompt(Prompt):
"""Render the prompt by making a call through the client."""
backend_name = self._backend_name or self.name
with client_span(
- f"prompts/get {backend_name}", "prompts/get", backend_name
+ f"prompts/get {backend_name}",
+ "prompts/get",
+ backend_name,
+ prompt_name=backend_name,
) as span:
span.set_attribute("fastmcp.provider.type", "ProxyProvider")
client = await self._get_client()
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index 967ef1ceb..74f8a3e71 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -620,31 +620,33 @@ class FastMCP(
call_next=lambda context: self.list_tools(run_middleware=False),
)
- # Get all tools, apply session transforms, then filter enabled
- # and model-visible (app-only tools are hidden from the model).
- tools = list(await super().list_tools())
- tools = await apply_session_transforms(tools)
- tools = [t for t in tools if is_enabled(t) and _is_model_visible(t)]
+ # Core logic: list tools
+ with server_span("tools/list", "tools/list", self.name, "tool", ""):
+ # Get all tools, apply session transforms, then filter enabled
+ # and model-visible (app-only tools are hidden from the model).
+ tools = list(await super().list_tools())
+ tools = await apply_session_transforms(tools)
+ tools = [t for t in tools if is_enabled(t) 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)
+ # 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:
- if not skip_auth and tool.auth is not None:
- ctx = AuthContext(token=token, component=tool)
- try:
- if not await run_auth_checks(tool.auth, ctx):
+ skip_auth, token = _get_auth_context()
+ authorized: list[Tool] = []
+ for tool in tools:
+ if not skip_auth and tool.auth is not None:
+ ctx = AuthContext(token=token, component=tool)
+ try:
+ if not await run_auth_checks(tool.auth, ctx):
+ continue
+ except AuthorizationError:
continue
- except AuthorizationError:
- continue
- authorized.append(tool)
- return authorized
+ authorized.append(tool)
+ return authorized
async def _get_tool(
self, name: str, version: VersionSpec | None = None
@@ -754,32 +756,36 @@ class FastMCP(
call_next=lambda context: self.list_resources(run_middleware=False),
)
- # Get all resources, apply session transforms, then filter enabled
- resources = list(await super().list_resources())
- resources = await apply_session_transforms(resources)
- resources = [r for r in resources if is_enabled(r)]
+ # Core logic: list resources
+ with server_span(
+ "resources/list", "resources/list", self.name, "resource", ""
+ ):
+ # Get all resources, apply session transforms, then filter enabled
+ resources = list(await super().list_resources())
+ 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,
- )
+ # 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))
+ resources.extend(await synthesize_prefab_resources(self))
- skip_auth, token = _get_auth_context()
- authorized: list[Resource] = []
- for resource in resources:
- if not skip_auth and resource.auth is not None:
- ctx = AuthContext(token=token, component=resource)
- try:
- if not await run_auth_checks(resource.auth, ctx):
+ skip_auth, token = _get_auth_context()
+ authorized: list[Resource] = []
+ for resource in resources:
+ if not skip_auth and resource.auth is not None:
+ ctx = AuthContext(token=token, component=resource)
+ try:
+ if not await run_auth_checks(resource.auth, ctx):
+ continue
+ except AuthorizationError:
continue
- except AuthorizationError:
- continue
- authorized.append(resource)
- return authorized
+ authorized.append(resource)
+ return authorized
async def _get_resource(
self, uri: str, version: VersionSpec | None = None
@@ -887,23 +893,31 @@ class FastMCP(
),
)
- # Get all templates, apply session transforms, then filter enabled
- templates = list(await super().list_resource_templates())
- templates = await apply_session_transforms(templates)
- templates = [t for t in templates if is_enabled(t)]
+ # Core logic: list resource templates
+ with server_span(
+ "resources/templates/list",
+ "resources/templates/list",
+ self.name,
+ "resource_template",
+ "",
+ ):
+ # Get all templates, apply session transforms, then filter enabled
+ templates = list(await super().list_resource_templates())
+ templates = await apply_session_transforms(templates)
+ templates = [t for t in templates if is_enabled(t)]
- skip_auth, token = _get_auth_context()
- authorized: list[ResourceTemplate] = []
- for template in templates:
- if not skip_auth and template.auth is not None:
- ctx = AuthContext(token=token, component=template)
- try:
- if not await run_auth_checks(template.auth, ctx):
+ skip_auth, token = _get_auth_context()
+ authorized: list[ResourceTemplate] = []
+ for template in templates:
+ if not skip_auth and template.auth is not None:
+ ctx = AuthContext(token=token, component=template)
+ try:
+ if not await run_auth_checks(template.auth, ctx):
+ continue
+ except AuthorizationError:
continue
- except AuthorizationError:
- continue
- authorized.append(template)
- return authorized
+ authorized.append(template)
+ return authorized
async def _get_resource_template(
self, uri: str, version: VersionSpec | None = None
@@ -1011,23 +1025,25 @@ class FastMCP(
call_next=lambda context: self.list_prompts(run_middleware=False),
)
- # Get all prompts, apply session transforms, then filter enabled
- prompts = list(await super().list_prompts())
- prompts = await apply_session_transforms(prompts)
- prompts = [p for p in prompts if is_enabled(p)]
+ # Core logic: list prompts
+ with server_span("prompts/list", "prompts/list", self.name, "prompt", ""):
+ # Get all prompts, apply session transforms, then filter enabled
+ prompts = list(await super().list_prompts())
+ prompts = await apply_session_transforms(prompts)
+ prompts = [p for p in prompts if is_enabled(p)]
- skip_auth, token = _get_auth_context()
- authorized: list[Prompt] = []
- for prompt in prompts:
- if not skip_auth and prompt.auth is not None:
- ctx = AuthContext(token=token, component=prompt)
- try:
- if not await run_auth_checks(prompt.auth, ctx):
+ skip_auth, token = _get_auth_context()
+ authorized: list[Prompt] = []
+ for prompt in prompts:
+ if not skip_auth and prompt.auth is not None:
+ ctx = AuthContext(token=token, component=prompt)
+ try:
+ if not await run_auth_checks(prompt.auth, ctx):
+ continue
+ except AuthorizationError:
continue
- except AuthorizationError:
- continue
- authorized.append(prompt)
- return authorized
+ authorized.append(prompt)
+ return authorized
async def _get_prompt(
self, name: str, version: VersionSpec | None = None
@@ -1204,7 +1220,12 @@ class FastMCP(
# Core logic: find and execute tool
with server_span(
- f"tools/call {name}", "tools/call", self.name, "tool", name
+ f"tools/call {name}",
+ "tools/call",
+ self.name,
+ "tool",
+ name,
+ tool_name=name,
) as span:
# Try normal display-name resolution first.
tool: Tool | None = await self.get_tool(name, version=version)
@@ -1341,7 +1362,7 @@ class FastMCP(
# Core logic: find and read resource (providers queried in parallel)
with server_span(
- f"resources/read {uri}",
+ "resources/read",
"resources/read",
self.name,
"resource",
@@ -1506,7 +1527,12 @@ class FastMCP(
# Core logic: find and render prompt (providers queried in parallel)
# Use get_prompt to apply transforms and filter disabled
with server_span(
- f"prompts/get {name}", "prompts/get", self.name, "prompt", name
+ f"prompts/get {name}",
+ "prompts/get",
+ self.name,
+ "prompt",
+ name,
+ prompt_name=name,
) as span:
prompt = await self.get_prompt(name, version=version)
if prompt is None:
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 2fe9eb83f..aa9d73bdc 100644
--- a/src/fastmcp/server/tasks/handlers.py
+++ b/src/fastmcp/server/tasks/handlers.py
@@ -15,13 +15,17 @@ from mcp.shared.exceptions import McpError
from mcp.types import INTERNAL_ERROR, ErrorData
from fastmcp.server.dependencies import (
- TaskContextSnapshot,
_current_docket,
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,12 +73,16 @@ 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
# Try the server's own Docket first; fall back to the ContextVar for
# mounted children (whose parent server owns the Docket instance).
@@ -94,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:
@@ -104,16 +112,14 @@ 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"
- )
+ prefix = task_redis_prefix(task_scope)
+ task_meta_key = docket.key(f"{prefix}:{server_task_id}")
+ created_at_key = docket.key(f"{prefix}:{server_task_id}:created_at")
+ poll_interval_key = docket.key(f"{prefix}:{server_task_id}:poll_interval")
poll_interval_ms = int(component.task_config.poll_interval.total_seconds() * 1000)
- # Snapshot all context (access token, headers, origin request ID) as a single key
+ # 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:
@@ -121,14 +127,12 @@ async def submit_to_docket(
await redis.set(created_at_key, created_at.isoformat(), ex=ttl_seconds)
await redis.set(poll_interval_key, str(poll_interval_ms), ex=ttl_seconds)
- await snapshot.save(docket, session_id, server_task_id, 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.
@@ -160,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]
@@ -168,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:
@@ -183,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/server/telemetry.py b/src/fastmcp/server/telemetry.py
index 6c263225d..974d4dcf6 100644
--- a/src/fastmcp/server/telemetry.py
+++ b/src/fastmcp/server/telemetry.py
@@ -7,6 +7,7 @@ from mcp.server.lowlevel.server import request_ctx
from opentelemetry.context import Context
from opentelemetry.trace import Span, SpanKind, Status, StatusCode
+from fastmcp.exceptions import ToolError as _ToolError
from fastmcp.telemetry import extract_trace_context, get_tracer
@@ -60,6 +61,8 @@ def server_span(
component_type: str,
component_key: str,
resource_uri: str | None = None,
+ tool_name: str | None = None,
+ prompt_name: str | None = None,
) -> Generator[Span, None, None]:
"""Create a SERVER span with standard MCP attributes and auth context.
@@ -71,28 +74,34 @@ def server_span(
context=_get_parent_trace_context(),
kind=SpanKind.SERVER,
) as span:
- attrs: dict[str, str] = {
- # RPC semantic conventions
- "rpc.system": "mcp",
- "rpc.service": server_name,
- "rpc.method": method,
- # MCP semantic conventions
- "mcp.method.name": method,
- # FastMCP-specific attributes
- "fastmcp.server.name": server_name,
- "fastmcp.component.type": component_type,
- "fastmcp.component.key": component_key,
- **get_auth_span_attributes(),
- **get_session_span_attributes(),
- }
- if resource_uri is not None:
- attrs["mcp.resource.uri"] = resource_uri
- span.set_attributes(attrs)
+ if span.is_recording():
+ attrs: dict[str, str] = {
+ # MCP semantic conventions
+ "mcp.method.name": method,
+ # FastMCP-specific attributes
+ "fastmcp.server.name": server_name,
+ "fastmcp.component.type": component_type,
+ "fastmcp.component.key": component_key,
+ **get_auth_span_attributes(),
+ **get_session_span_attributes(),
+ }
+ if resource_uri is not None:
+ attrs["mcp.resource.uri"] = resource_uri
+ if tool_name is not None:
+ attrs["gen_ai.tool.name"] = tool_name
+ if prompt_name is not None:
+ attrs["gen_ai.prompt.name"] = prompt_name
+ span.set_attributes(attrs)
try:
yield span
except Exception as e:
- span.record_exception(e)
- span.set_status(Status(StatusCode.ERROR))
+ if span.is_recording():
+ error_type = (
+ "tool_error" if isinstance(e, _ToolError) else type(e).__qualname__
+ )
+ span.set_attribute("error.type", error_type)
+ span.record_exception(e)
+ span.set_status(Status(StatusCode.ERROR, str(e)))
raise
@@ -101,6 +110,7 @@ def delegate_span(
name: str,
provider_type: str,
component_key: str,
+ method: str | None = None,
) -> Generator[Span, None, None]:
"""Create an INTERNAL span for provider delegation.
@@ -109,17 +119,24 @@ def delegate_span(
"""
tracer = get_tracer()
with tracer.start_as_current_span(f"delegate {name}") as span:
- span.set_attributes(
- {
+ if span.is_recording():
+ attrs: dict[str, str] = {
"fastmcp.provider.type": provider_type,
"fastmcp.component.key": component_key,
}
- )
+ if method is not None:
+ attrs["mcp.method.name"] = method
+ span.set_attributes(attrs)
try:
yield span
except Exception as e:
- span.record_exception(e)
- span.set_status(Status(StatusCode.ERROR))
+ if span.is_recording():
+ error_type = (
+ "tool_error" if isinstance(e, _ToolError) else type(e).__qualname__
+ )
+ span.set_attribute("error.type", error_type)
+ span.record_exception(e)
+ span.set_status(Status(StatusCode.ERROR, str(e)))
raise
diff --git a/src/fastmcp/server/transforms/prompts_as_tools.py b/src/fastmcp/server/transforms/prompts_as_tools.py
index 078b250d0..2caa13c0f 100644
--- a/src/fastmcp/server/transforms/prompts_as_tools.py
+++ b/src/fastmcp/server/transforms/prompts_as_tools.py
@@ -104,7 +104,7 @@ class PromptsAsTools(Transform):
result: list[dict[str, Any]] = []
for p in prompts:
- result.append(
+ result.append( # noqa: PERF401
{
"name": p.name,
"description": p.description,
diff --git a/src/fastmcp/server/transforms/resources_as_tools.py b/src/fastmcp/server/transforms/resources_as_tools.py
index 780e513b7..2b0350205 100644
--- a/src/fastmcp/server/transforms/resources_as_tools.py
+++ b/src/fastmcp/server/transforms/resources_as_tools.py
@@ -110,7 +110,7 @@ class ResourcesAsTools(Transform):
result: list[dict[str, Any]] = []
for r in resources:
- result.append(
+ result.append( # noqa: PERF401
{
"uri": str(r.uri),
"name": r.name,
@@ -120,7 +120,7 @@ class ResourcesAsTools(Transform):
)
for t in templates:
- result.append(
+ result.append( # noqa: PERF401
{
"uri_template": t.uri_template,
"name": t.name,
diff --git a/src/fastmcp/tools/function_parsing.py b/src/fastmcp/tools/function_parsing.py
index 9b9b0870e..50d217ce6 100644
--- a/src/fastmcp/tools/function_parsing.py
+++ b/src/fastmcp/tools/function_parsing.py
@@ -67,8 +67,6 @@ logger = get_logger(__name__)
@dataclass
class _WrappedResult(Generic[T]):
- """Generic wrapper for non-object return types."""
-
result: T
diff --git a/src/fastmcp/utilities/inspect.py b/src/fastmcp/utilities/inspect.py
index 369351ba5..2e3348e22 100644
--- a/src/fastmcp/utilities/inspect.py
+++ b/src/fastmcp/utilities/inspect.py
@@ -136,7 +136,7 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
# Extract detailed prompt information
prompt_infos = []
for prompt in prompts_list:
- prompt_infos.append(
+ prompt_infos.append( # noqa: PERF401
PromptInfo(
key=prompt.key,
name=prompt.name or prompt.key,
@@ -156,7 +156,7 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
# Extract detailed resource information
resource_infos = []
for resource in resources_list:
- resource_infos.append(
+ resource_infos.append( # noqa: PERF401
ResourceInfo(
key=resource.key,
uri=str(resource.uri),
@@ -178,7 +178,7 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
# Extract detailed template information
template_infos = []
for template in templates_list:
- template_infos.append(
+ template_infos.append( # noqa: PERF401
TemplateInfo(
key=template.key,
uri_template=template.uri_template,
@@ -258,7 +258,7 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo:
# Extract detailed tool information from MCP Tool objects
tool_infos = []
for mcp_tool in mcp_tools:
- tool_infos.append(
+ tool_infos.append( # noqa: PERF401
ToolInfo(
key=mcp_tool.name,
name=mcp_tool.name,
@@ -301,7 +301,7 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo:
# Extract detailed resource information from MCP Resource objects
resource_infos = []
for mcp_resource in mcp_resources:
- resource_infos.append(
+ resource_infos.append( # noqa: PERF401
ResourceInfo(
key=str(mcp_resource.uri),
uri=str(mcp_resource.uri),
@@ -321,7 +321,7 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo:
# Extract detailed template information from MCP ResourceTemplate objects
template_infos = []
for mcp_template in mcp_templates:
- template_infos.append(
+ template_infos.append( # noqa: PERF401
TemplateInfo(
key=str(mcp_template.uriTemplate),
uri_template=str(mcp_template.uriTemplate),
diff --git a/tests/client/client/test_client.py b/tests/client/client/test_client.py
index b513b5367..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.
@@ -767,3 +833,34 @@ async def test_client_list_dict_return_type():
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/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/telemetry/test_client_list_tracing.py b/tests/client/telemetry/test_client_list_tracing.py
new file mode 100644
index 000000000..45038bbad
--- /dev/null
+++ b/tests/client/telemetry/test_client_list_tracing.py
@@ -0,0 +1,180 @@
+"""Tests for client OpenTelemetry tracing on list operations."""
+
+from __future__ import annotations
+
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
+from opentelemetry.trace import SpanKind
+
+from fastmcp import Client, FastMCP
+
+
+class TestClientListToolsTracing:
+ """Tests for client tools/list tracing."""
+
+ async def test_list_tools_creates_client_span(
+ self, trace_exporter: InMemorySpanExporter
+ ):
+ server = FastMCP("test-server")
+
+ @server.tool()
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ client = Client(server)
+ async with client:
+ tools = await client.list_tools()
+ assert len(tools) == 1
+
+ spans = trace_exporter.get_finished_spans()
+ client_spans = [
+ s
+ for s in spans
+ if s.name == "tools/list"
+ and s.attributes is not None
+ and "fastmcp.server.name" not in s.attributes
+ ]
+ assert len(client_spans) >= 1
+
+ span = client_spans[0]
+ assert span.kind == SpanKind.CLIENT
+ assert span.attributes is not None
+ assert span.attributes["mcp.method.name"] == "tools/list"
+
+ async def test_list_tools_creates_both_client_and_server_spans(
+ self, trace_exporter: InMemorySpanExporter
+ ):
+ server = FastMCP("test-server")
+
+ @server.tool()
+ def add(a: int, b: int) -> int:
+ return a + b
+
+ client = Client(server)
+ async with client:
+ await client.list_tools()
+
+ spans = trace_exporter.get_finished_spans()
+ tools_list_spans = [s for s in spans if s.name == "tools/list"]
+ assert len(tools_list_spans) >= 2
+
+ client_span = next(
+ (
+ s
+ for s in tools_list_spans
+ if s.attributes is not None
+ and "fastmcp.server.name" not in s.attributes
+ ),
+ None,
+ )
+ server_span = next(
+ (
+ s
+ for s in tools_list_spans
+ if s.attributes is not None and "fastmcp.server.name" in s.attributes
+ ),
+ None,
+ )
+
+ assert client_span is not None, "Client should create a span"
+ assert server_span is not None, "Server should create a span"
+ assert client_span.kind == SpanKind.CLIENT
+ assert server_span.kind == SpanKind.SERVER
+
+
+class TestClientListResourcesTracing:
+ """Tests for client resources/list tracing."""
+
+ async def test_list_resources_creates_client_span(
+ self, trace_exporter: InMemorySpanExporter
+ ):
+ server = FastMCP("test-server")
+
+ @server.resource("data://config")
+ def get_config() -> str:
+ return "config"
+
+ client = Client(server)
+ async with client:
+ resources = await client.list_resources()
+ assert len(resources) >= 1
+
+ spans = trace_exporter.get_finished_spans()
+ client_spans = [
+ s
+ for s in spans
+ if s.name == "resources/list"
+ and s.attributes is not None
+ and "fastmcp.server.name" not in s.attributes
+ ]
+ assert len(client_spans) >= 1
+
+ span = client_spans[0]
+ assert span.kind == SpanKind.CLIENT
+ assert span.attributes is not None
+ assert span.attributes["mcp.method.name"] == "resources/list"
+
+
+class TestClientListResourceTemplatesTracing:
+ """Tests for client resources/templates/list tracing."""
+
+ async def test_list_resource_templates_creates_client_span(
+ self, trace_exporter: InMemorySpanExporter
+ ):
+ server = FastMCP("test-server")
+
+ @server.resource("users://{user_id}/profile")
+ def get_profile(user_id: str) -> str:
+ return f"profile {user_id}"
+
+ client = Client(server)
+ async with client:
+ templates = await client.list_resource_templates()
+ assert len(templates) >= 1
+
+ spans = trace_exporter.get_finished_spans()
+ client_spans = [
+ s
+ for s in spans
+ if s.name == "resources/templates/list"
+ and s.attributes is not None
+ and "fastmcp.server.name" not in s.attributes
+ ]
+ assert len(client_spans) >= 1
+
+ span = client_spans[0]
+ assert span.kind == SpanKind.CLIENT
+ assert span.attributes is not None
+ assert span.attributes["mcp.method.name"] == "resources/templates/list"
+
+
+class TestClientListPromptsTracing:
+ """Tests for client prompts/list tracing."""
+
+ async def test_list_prompts_creates_client_span(
+ self, trace_exporter: InMemorySpanExporter
+ ):
+ server = FastMCP("test-server")
+
+ @server.prompt()
+ def greeting() -> str:
+ return "Hello!"
+
+ client = Client(server)
+ async with client:
+ prompts = await client.list_prompts()
+ assert len(prompts) == 1
+
+ spans = trace_exporter.get_finished_spans()
+ client_spans = [
+ s
+ for s in spans
+ if s.name == "prompts/list"
+ and s.attributes is not None
+ and "fastmcp.server.name" not in s.attributes
+ ]
+ assert len(client_spans) >= 1
+
+ span = client_spans[0]
+ assert span.kind == SpanKind.CLIENT
+ assert span.attributes is not None
+ assert span.attributes["mcp.method.name"] == "prompts/list"
diff --git a/tests/client/telemetry/test_client_tracing.py b/tests/client/telemetry/test_client_tracing.py
index 309ec3af1..54c4c08f3 100644
--- a/tests/client/telemetry/test_client_tracing.py
+++ b/tests/client/telemetry/test_client_tracing.py
@@ -63,12 +63,50 @@ class TestClientToolTracing:
assert client_span.attributes is not None
# Standard MCP semantic conventions
assert client_span.attributes["mcp.method.name"] == "tools/call"
- # Standard RPC semantic conventions
- assert client_span.attributes["rpc.system"] == "mcp"
- assert client_span.attributes["rpc.method"] == "tools/call"
+ # gen_ai semantic conventions
+ assert client_span.attributes["gen_ai.tool.name"] == "add"
+ # RPC attributes must NOT be present
+ assert "rpc.system" not in client_span.attributes
+ assert "rpc.method" not in client_span.attributes
# FastMCP-specific attributes
assert client_span.attributes["fastmcp.component.key"] == "add"
+ async def test_call_tool_error_caught_by_client_span(
+ self, trace_exporter: InMemorySpanExporter
+ ):
+ """Tool error should be reflected on the client span via isError check."""
+ server = FastMCP("test-server")
+
+ @server.tool()
+ def failing_tool() -> str:
+ raise ValueError("boom")
+
+ client = Client(server)
+ async with client:
+ with pytest.raises(ToolError):
+ await client.call_tool("failing_tool", {})
+
+ spans = trace_exporter.get_finished_spans()
+
+ # Find the client span (from call_tool_mcp)
+ client_spans = [
+ s
+ for s in spans
+ if s.name == "tools/call failing_tool"
+ and s.attributes is not None
+ and "fastmcp.server.name" not in s.attributes
+ ]
+
+ # Exactly one client span should exist (no duplicate from call_tool)
+ assert len(client_spans) == 1, (
+ "There should be exactly one client span for call_tool"
+ )
+
+ error_span = client_spans[0]
+ assert error_span.status.status_code == StatusCode.ERROR
+ assert error_span.attributes is not None
+ assert error_span.attributes["error.type"] == "tool_error"
+
class TestClientResourceTracing:
"""Tests for client resource read tracing."""
@@ -90,8 +128,8 @@ class TestClientResourceTracing:
spans = trace_exporter.get_finished_spans()
span_names = [s.name for s in spans]
- # Client should create "resources/read data://config" span
- assert "resources/read data://config" in span_names
+ # Client should create "resources/read" span (URI in attributes, not name)
+ assert "resources/read" in span_names
async def test_read_resource_span_attributes(
self, trace_exporter: InMemorySpanExporter
@@ -113,7 +151,7 @@ class TestClientResourceTracing:
(
s
for s in spans
- if s.name.startswith("resources/read data://")
+ if s.name == "resources/read"
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
@@ -124,9 +162,9 @@ class TestClientResourceTracing:
# Standard MCP semantic conventions
assert client_span.attributes["mcp.method.name"] == "resources/read"
assert "data://" in str(client_span.attributes["mcp.resource.uri"])
- # Standard RPC semantic conventions
- assert client_span.attributes["rpc.system"] == "mcp"
- assert client_span.attributes["rpc.method"] == "resources/read"
+ # RPC attributes must NOT be present
+ assert "rpc.system" not in client_span.attributes
+ assert "rpc.method" not in client_span.attributes
# FastMCP-specific attributes
# The URI may be normalized with trailing slash
assert "data://" in str(client_span.attributes["fastmcp.component.key"])
@@ -183,9 +221,11 @@ class TestClientPromptTracing:
assert client_span.attributes is not None
# Standard MCP semantic conventions
assert client_span.attributes["mcp.method.name"] == "prompts/get"
- # Standard RPC semantic conventions
- assert client_span.attributes["rpc.system"] == "mcp"
- assert client_span.attributes["rpc.method"] == "prompts/get"
+ # gen_ai semantic conventions
+ assert client_span.attributes["gen_ai.prompt.name"] == "welcome"
+ # RPC attributes must NOT be present
+ assert "rpc.system" not in client_span.attributes
+ assert "rpc.method" not in client_span.attributes
# FastMCP-specific attributes
assert client_span.attributes["fastmcp.component.key"] == "welcome"
@@ -242,7 +282,7 @@ class TestClientServerSpanHierarchy:
assert server_span.kind == SpanKind.SERVER, "Server span should be SERVER kind"
# Verify the spans have different characteristics
- assert client_span.attributes["rpc.method"] == "tools/call"
+ assert client_span.attributes["mcp.method.name"] == "tools/call"
assert server_span.attributes["fastmcp.server.name"] == "test-server"
async def test_trace_context_propagation(
@@ -380,7 +420,7 @@ class TestClientErrorTracing:
(
s
for s in spans
- if s.name.startswith("resources/read data://fail")
+ if s.name == "resources/read"
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
@@ -391,7 +431,7 @@ class TestClientErrorTracing:
(
s
for s in spans
- if s.name.startswith("resources/read data://fail")
+ if s.name == "resources/read"
and s.attributes is not None
and "fastmcp.server.name" in s.attributes
),
diff --git a/tests/client/test_elicitation.py b/tests/client/test_elicitation.py
index 7b8179f82..b5d16bf4d 100644
--- a/tests/client/test_elicitation.py
+++ b/tests/client/test_elicitation.py
@@ -117,6 +117,131 @@ async def test_elicitation_handler_parameters():
assert captured_params["ctx"] is not None
+async def test_elicitation_response_title_and_description_on_scalar():
+ """response_title and response_description customize the wrapped `value` field."""
+ mcp = FastMCP("TestServer")
+ captured_schema: dict[str, Any] = {}
+
+ @mcp.tool
+ async def confirm_purchase(context: Context) -> str:
+ result = await context.elicit(
+ message="Buy 1x Baguette?",
+ response_type=bool,
+ response_title="Confirm purchase",
+ response_description="Approve this transaction?",
+ )
+ if isinstance(result, AcceptedElicitation):
+ return "confirmed" if result.data else "rejected"
+ return "no answer"
+
+ async def elicitation_handler(message, response_type, params, ctx):
+ captured_schema.update(params.requestedSchema)
+ return ElicitResult(action="accept", content={"value": True})
+
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ await client.call_tool("confirm_purchase", {})
+
+ assert captured_schema["properties"]["value"]["title"] == "Confirm purchase"
+ assert (
+ captured_schema["properties"]["value"]["description"]
+ == "Approve this transaction?"
+ )
+ assert captured_schema["properties"]["value"]["type"] == "boolean"
+
+
+async def test_elicitation_response_title_on_dict_shorthand():
+ """response_title applies to the `value` property for dict shorthand."""
+ mcp = FastMCP("TestServer")
+ captured_schema: dict[str, Any] = {}
+
+ @mcp.tool
+ async def pick_priority(context: Context) -> str:
+ result = await context.elicit(
+ message="Priority?",
+ response_type={"low": {"title": "Low"}, "high": {"title": "High"}},
+ response_title="Priority level",
+ )
+ return "ok" if isinstance(result, AcceptedElicitation) else "none"
+
+ async def elicitation_handler(message, response_type, params, ctx):
+ captured_schema.update(params.requestedSchema)
+ return ElicitResult(action="accept", content={"value": "low"})
+
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ await client.call_tool("pick_priority", {})
+
+ assert captured_schema["properties"]["value"]["title"] == "Priority level"
+
+
+async def test_elicitation_response_title_on_list_shorthand():
+ """response_title applies to the `value` property for list shorthand."""
+ mcp = FastMCP("TestServer")
+ captured_schema: dict[str, Any] = {}
+
+ @mcp.tool
+ async def pick_color(context: Context) -> str:
+ result = await context.elicit(
+ message="Color?",
+ response_type=["red", "green", "blue"],
+ response_title="Favorite color",
+ )
+ return "ok" if isinstance(result, AcceptedElicitation) else "none"
+
+ async def elicitation_handler(message, response_type, params, ctx):
+ captured_schema.update(params.requestedSchema)
+ return ElicitResult(action="accept", content={"value": "red"})
+
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ await client.call_tool("pick_color", {})
+
+ assert captured_schema["properties"]["value"]["title"] == "Favorite color"
+
+
+async def test_elicitation_response_title_rejected_for_basemodel():
+ """response_title raises TypeError when response_type is a BaseModel."""
+ mcp = FastMCP("TestServer")
+
+ class Person(BaseModel):
+ name: str
+
+ @mcp.tool
+ async def ask(context: Context) -> str:
+ await context.elicit(
+ message="Name?",
+ response_type=Person,
+ response_title="Not allowed",
+ )
+ return "done"
+
+ async def elicitation_handler(message, response_type, params, ctx):
+ return ElicitResult(action="accept", content={"name": "x"})
+
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ with pytest.raises(ToolError, match="response_title"):
+ await client.call_tool("ask", {})
+
+
+async def test_elicitation_response_title_rejected_for_none():
+ """response_title raises TypeError when response_type is None."""
+ mcp = FastMCP("TestServer")
+
+ @mcp.tool
+ async def ask(context: Context) -> str:
+ await context.elicit(
+ message="Confirm?",
+ response_type=None,
+ response_title="Not allowed",
+ )
+ return "done"
+
+ async def elicitation_handler(message, response_type, params, ctx):
+ return ElicitResult(action="accept", content={})
+
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ with pytest.raises(ToolError, match="response_title"):
+ await client.call_tool("ask", {})
+
+
async def test_elicitation_cancel_action():
"""Test user canceling elicitation request."""
mcp = FastMCP("TestServer")
@@ -144,6 +269,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/deprecated/test_elicitation.py b/tests/deprecated/test_elicitation.py
new file mode 100644
index 000000000..d86e549d8
--- /dev/null
+++ b/tests/deprecated/test_elicitation.py
@@ -0,0 +1,29 @@
+"""Tests for deprecated elicitation behavior."""
+
+from typing import Any, cast
+
+import pytest
+
+from fastmcp import Context, FastMCP
+from fastmcp.client.client import Client
+from fastmcp.client.elicitation import ElicitResult
+from fastmcp.exceptions import FastMCPDeprecationWarning
+from fastmcp.server.elicitation import AcceptedElicitation
+
+
+async def test_elicitation_none_response_type_warns_deprecation():
+ """Passing response_type=None is deprecated — warn at call time."""
+ mcp = FastMCP("TestServer")
+
+ @mcp.tool
+ async def my_tool(context: Context) -> dict[str, Any]:
+ with pytest.warns(FastMCPDeprecationWarning, match="response_type"):
+ result = await context.elicit(message="", response_type=None)
+ assert isinstance(result, AcceptedElicitation)
+ return cast(dict[str, Any], result.data)
+
+ async def elicitation_handler(message, response_type, params, ctx):
+ return ElicitResult(action="accept", content={})
+
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ await client.call_tool("my_tool", {})
diff --git a/tests/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 0c7590fea..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."""
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_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/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_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/middleware/test_error_handling.py b/tests/server/middleware/test_error_handling.py
index 3bac578d7..dfc551fbf 100644
--- a/tests/server/middleware/test_error_handling.py
+++ b/tests/server/middleware/test_error_handling.py
@@ -6,6 +6,8 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from mcp import McpError
+from fastmcp import FastMCP
+from fastmcp.client import Client
from fastmcp.exceptions import NotFoundError, ToolError
from fastmcp.server.middleware.error_handling import (
ErrorHandlingMiddleware,
@@ -295,6 +297,32 @@ class TestRetryMiddleware:
assert middleware._should_retry(ValueError()) is False
assert middleware._should_retry(RuntimeError()) is False
+ def test_should_retry_checks_cause_chain(self):
+ """Retry should match on __cause__ since FastMCP wraps tool errors.
+
+ When a tool raises ConnectionError, FastMCP catches it and raises
+ ToolError(...) from ConnectionError. The middleware must check
+ __cause__ to detect the retryable original exception.
+ """
+ middleware = RetryMiddleware(retry_exceptions=(ConnectionError,))
+
+ # Direct ConnectionError — should retry
+ assert middleware._should_retry(ConnectionError()) is True
+
+ # ToolError wrapping ConnectionError — should also retry
+ wrapped = ToolError("Error calling tool")
+ wrapped.__cause__ = ConnectionError("conn refused")
+ assert middleware._should_retry(wrapped) is True
+
+ # ToolError wrapping ValueError — should NOT retry
+ wrong_cause = ToolError("Error calling tool")
+ wrong_cause.__cause__ = ValueError("bad input")
+ assert middleware._should_retry(wrong_cause) is False
+
+ # ToolError with no cause — should NOT retry
+ no_cause = ToolError("Error calling tool")
+ assert middleware._should_retry(no_cause) is False
+
def test_calculate_delay(self):
"""Test delay calculation."""
middleware = RetryMiddleware(
@@ -364,8 +392,6 @@ class TestRetryMiddleware:
@pytest.fixture
def error_handling_server():
"""Create a FastMCP server specifically for error handling middleware tests."""
- from fastmcp import FastMCP
-
mcp = FastMCP("ErrorHandlingTestServer")
@mcp.tool
@@ -417,8 +443,6 @@ class TestErrorHandlingMiddlewareIntegration:
self, error_handling_server, caplog
):
"""Test that error handling middleware logs real errors from tools."""
- from fastmcp.client import Client
-
error_handling_server.add_middleware(ErrorHandlingMiddleware())
with caplog.at_level(logging.ERROR):
@@ -442,8 +466,6 @@ class TestErrorHandlingMiddlewareIntegration:
self, error_handling_server
):
"""Test that error handling middleware accurately tracks error statistics."""
- from fastmcp.client import Client
-
error_middleware = ErrorHandlingMiddleware()
error_handling_server.add_middleware(error_middleware)
@@ -475,8 +497,6 @@ class TestErrorHandlingMiddlewareIntegration:
self, error_handling_server, caplog
):
"""Test error handling middleware with mix of successful and failed operations."""
- from fastmcp.client import Client
-
error_handling_server.add_middleware(ErrorHandlingMiddleware())
with caplog.at_level(logging.ERROR):
@@ -501,8 +521,6 @@ class TestErrorHandlingMiddlewareIntegration:
self, error_handling_server
):
"""Test error handling middleware with custom error callback."""
- from fastmcp.client import Client
-
captured_errors = []
def error_callback(error, context):
@@ -536,8 +554,6 @@ class TestErrorHandlingMiddlewareIntegration:
self, error_handling_server
):
"""Test error transformation functionality."""
- from fastmcp.client import Client
-
error_handling_server.add_middleware(
ErrorHandlingMiddleware(transform_errors=True)
)
@@ -554,55 +570,60 @@ class TestErrorHandlingMiddlewareIntegration:
class TestRetryMiddlewareIntegration:
"""Integration tests for retry middleware with real FastMCP server."""
- async def test_retry_middleware_with_transient_failures(
- self, error_handling_server, caplog
- ):
- """Test retry middleware with operations that have transient failures."""
- from fastmcp.client import Client
+ async def test_retry_actually_retries_through_server_pipeline(self):
+ """Retry middleware should retry tool calls that raise retryable errors.
- # Configure retry middleware to retry connection errors
- error_handling_server.add_middleware(
+ FastMCP wraps tool exceptions as ToolError(...) from ,
+ so the middleware must check __cause__ to detect retryable errors.
+ This test verifies the full pipeline works by counting call attempts.
+ """
+ call_count = 0
+ server = FastMCP("RetryTest")
+ server.add_middleware(
RetryMiddleware(
max_retries=3,
- base_delay=0.01, # Very short delay for testing
+ base_delay=0.01,
retry_exceptions=(ConnectionError,),
)
)
- with caplog.at_level(logging.WARNING):
- async with Client(error_handling_server) as client:
- # This operation fails intermittently - try several times
- success_count = 0
- for _ in range(5):
- try:
- await client.call_tool(
- "intermittent_operation", {"fail_rate": 0.7}
- )
- success_count += 1
- except Exception:
- pass # Some failures expected even with retries
+ @server.tool
+ def fails_then_succeeds() -> str:
+ nonlocal call_count
+ call_count += 1
+ if call_count < 3:
+ raise ConnectionError("transient failure")
+ return "success"
- # Should have some retry log messages
- # Note: Retry logs might not appear if the underlying errors are wrapped by FastMCP
- # The key is that some operations should succeed due to retries
+ async with Client(server) as client:
+ result = await client.call_tool("fails_then_succeeds")
+ assert result.data == "success"
- async def test_retry_middleware_with_permanent_failures(
- self, error_handling_server
- ):
- """Test that retry middleware doesn't retry non-retryable errors."""
- from fastmcp.client import Client
+ # Tool should have been called 3 times: 2 failures + 1 success
+ assert call_count == 3
- # Configure retry middleware for connection errors only
- error_handling_server.add_middleware(
+ async def test_retry_middleware_with_permanent_failures(self):
+ """A tool error whose cause is not in ``retry_exceptions`` should
+ fail on the first attempt — no retries."""
+ call_count = 0
+ server = FastMCP("RetryPermanentFailuresTest")
+ server.add_middleware(
RetryMiddleware(
max_retries=3, base_delay=0.01, retry_exceptions=(ConnectionError,)
)
)
- async with Client(error_handling_server) as client:
- # Value errors should not be retried
+ @server.tool
+ def always_fails() -> str:
+ nonlocal call_count
+ call_count += 1
+ raise ValueError("permanent failure")
+
+ async with Client(server) as client:
with pytest.raises(Exception):
- await client.call_tool("failing_operation", {"error_type": "value"})
+ await client.call_tool("always_fails", {})
+
+ assert call_count == 1
# Should fail immediately without retries
@@ -610,8 +631,6 @@ class TestRetryMiddlewareIntegration:
self, error_handling_server, caplog
):
"""Test error handling and retry middleware working together."""
- from fastmcp.client import Client
-
# Add both middleware
error_handling_server.add_middleware(ErrorHandlingMiddleware())
error_handling_server.add_middleware(
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/tasks/test_context_background_task.py b/tests/server/tasks/test_context_background_task.py
index ad472410f..4b7f66583 100644
--- a/tests/server/tasks/test_context_background_task.py
+++ b/tests/server/tasks/test_context_background_task.py
@@ -23,18 +23,22 @@ from fastmcp.client.elicitation import ElicitResult
from fastmcp.dependencies import CurrentDocket
from fastmcp.server.auth import AccessToken
from fastmcp.server.context import Context
-from fastmcp.server.dependencies import (
- TaskContextInfo,
- TaskContextSnapshot,
- _set_cached_snapshot,
- get_access_token,
-)
+from fastmcp.server.dependencies import get_access_token
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)
@@ -262,7 +266,8 @@ class TestBackgroundTaskIntegration:
assert origin != ""
# Verify the snapshot in Redis contains the same value
- key = docket.key(f"fastmcp:task:{ctx.session_id}:{ctx.task_id}:snapshot")
+ 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)
@@ -361,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,
@@ -429,9 +434,9 @@ class TestAccessTokenInBackgroundTasks:
"test-task",
TaskContextSnapshot(access_token_json=expired.model_dump_json()),
)
- fake_ctx = TaskContextInfo(task_id="test-task", session_id="s")
+ fake_ctx = TaskContextInfo(task_id="test-task", task_scope="s")
with patch(
- "fastmcp.server.dependencies.get_task_context", return_value=fake_ctx
+ "fastmcp.server.tasks.context.get_task_context", return_value=fake_ctx
):
assert get_access_token() is None
@@ -447,9 +452,9 @@ class TestAccessTokenInBackgroundTasks:
"test-task",
TaskContextSnapshot(access_token_json=valid.model_dump_json()),
)
- fake_ctx = TaskContextInfo(task_id="test-task", session_id="s")
+ fake_ctx = TaskContextInfo(task_id="test-task", task_scope="s")
with patch(
- "fastmcp.server.dependencies.get_task_context", return_value=fake_ctx
+ "fastmcp.server.tasks.context.get_task_context", return_value=fake_ctx
):
result = get_access_token()
assert result is not None
@@ -466,9 +471,9 @@ class TestAccessTokenInBackgroundTasks:
"test-task",
TaskContextSnapshot(access_token_json=no_expiry.model_dump_json()),
)
- fake_ctx = TaskContextInfo(task_id="test-task", session_id="s")
+ fake_ctx = TaskContextInfo(task_id="test-task", task_scope="s")
with patch(
- "fastmcp.server.dependencies.get_task_context", return_value=fake_ctx
+ "fastmcp.server.tasks.context.get_task_context", return_value=fake_ctx
):
result = get_access_token()
assert result is not None
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_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/telemetry/test_delegate_method.py b/tests/server/telemetry/test_delegate_method.py
new file mode 100644
index 000000000..87227750b
--- /dev/null
+++ b/tests/server/telemetry/test_delegate_method.py
@@ -0,0 +1,80 @@
+"""Tests for mcp.method.name attribute on delegate spans."""
+
+from __future__ import annotations
+
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
+
+from fastmcp import FastMCP
+
+
+class TestDelegateSpanMethod:
+ """Tests that delegate spans include mcp.method.name."""
+
+ async def test_mounted_tool_delegate_has_method(
+ self, trace_exporter: InMemorySpanExporter
+ ):
+ child = FastMCP("child-server")
+
+ @child.tool()
+ def child_tool() -> str:
+ return "result"
+
+ parent = FastMCP("parent-server")
+ parent.mount(child, namespace="child")
+
+ await parent.call_tool("child_child_tool", {})
+
+ spans = trace_exporter.get_finished_spans()
+ delegate_span = next(
+ (s for s in spans if s.name == "delegate child_tool"), None
+ )
+ assert delegate_span is not None
+ assert delegate_span.attributes is not None
+ assert delegate_span.attributes["mcp.method.name"] == "tools/call"
+
+ async def test_mounted_resource_delegate_has_method(
+ self, trace_exporter: InMemorySpanExporter
+ ):
+ child = FastMCP("child-server")
+
+ @child.resource("data://config")
+ def child_config() -> str:
+ return "config data"
+
+ parent = FastMCP("parent-server")
+ parent.mount(child, namespace="child")
+
+ await parent.read_resource("data://child/config")
+
+ spans = trace_exporter.get_finished_spans()
+ delegate_spans = [
+ s
+ for s in spans
+ if s.name.startswith("delegate") and "data://config" in s.name
+ ]
+ assert len(delegate_spans) >= 1
+ span = delegate_spans[0]
+ assert span.attributes is not None
+ assert span.attributes["mcp.method.name"] == "resources/read"
+
+ async def test_mounted_prompt_delegate_has_method(
+ self, trace_exporter: InMemorySpanExporter
+ ):
+ child = FastMCP("child-server")
+
+ @child.prompt()
+ def child_prompt() -> str:
+ return "Hello from child!"
+
+ parent = FastMCP("parent-server")
+ parent.mount(child, namespace="child")
+
+ await parent.render_prompt("child_child_prompt", {})
+
+ spans = trace_exporter.get_finished_spans()
+ delegate_span = next(
+ (s for s in spans if s.name == "delegate child_prompt"), None
+ )
+ assert delegate_span is not None
+ assert delegate_span.attributes is not None
+ assert delegate_span.attributes["mcp.method.name"] == "prompts/get"
diff --git a/tests/server/telemetry/test_list_tracing.py b/tests/server/telemetry/test_list_tracing.py
new file mode 100644
index 000000000..92ff4f7fb
--- /dev/null
+++ b/tests/server/telemetry/test_list_tracing.py
@@ -0,0 +1,130 @@
+"""Tests for server-level OpenTelemetry tracing on list operations."""
+
+from __future__ import annotations
+
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
+from opentelemetry.trace import SpanKind
+
+from fastmcp import FastMCP
+
+
+class TestListToolsTracing:
+ async def test_list_tools_creates_span(self, trace_exporter: InMemorySpanExporter):
+ mcp = FastMCP("test-server")
+
+ @mcp.tool()
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ tools = await mcp.list_tools()
+ assert len(tools) == 1
+
+ spans = trace_exporter.get_finished_spans()
+ list_spans = [s for s in spans if s.name == "tools/list"]
+ assert len(list_spans) >= 1
+
+ span = list_spans[0]
+ assert span.kind == SpanKind.SERVER
+ assert span.attributes is not None
+ assert span.attributes["mcp.method.name"] == "tools/list"
+ assert span.attributes["fastmcp.server.name"] == "test-server"
+ assert span.attributes["fastmcp.component.type"] == "tool"
+
+ async def test_list_tools_empty_creates_span(
+ self, trace_exporter: InMemorySpanExporter
+ ):
+ mcp = FastMCP("test-server")
+
+ tools = await mcp.list_tools()
+ assert len(tools) == 0
+
+ spans = trace_exporter.get_finished_spans()
+ list_spans = [s for s in spans if s.name == "tools/list"]
+ assert len(list_spans) >= 1
+
+
+class TestListResourcesTracing:
+ async def test_list_resources_creates_span(
+ self, trace_exporter: InMemorySpanExporter
+ ):
+ mcp = FastMCP("test-server")
+
+ @mcp.resource("config://app")
+ def get_config() -> str:
+ return "config"
+
+ resources = await mcp.list_resources()
+ assert len(resources) >= 1
+
+ spans = trace_exporter.get_finished_spans()
+ list_spans = [s for s in spans if s.name == "resources/list"]
+ assert len(list_spans) >= 1
+
+ span = list_spans[0]
+ assert span.kind == SpanKind.SERVER
+ assert span.attributes is not None
+ assert span.attributes["mcp.method.name"] == "resources/list"
+ assert span.attributes["fastmcp.server.name"] == "test-server"
+ assert span.attributes["fastmcp.component.type"] == "resource"
+
+
+class TestListResourceTemplatesTracing:
+ async def test_list_resource_templates_creates_span(
+ self, trace_exporter: InMemorySpanExporter
+ ):
+ mcp = FastMCP("test-server")
+
+ @mcp.resource("users://{user_id}/profile")
+ def get_profile(user_id: str) -> str:
+ return f"profile {user_id}"
+
+ templates = await mcp.list_resource_templates()
+ assert len(templates) >= 1
+
+ spans = trace_exporter.get_finished_spans()
+ list_spans = [s for s in spans if s.name == "resources/templates/list"]
+ assert len(list_spans) >= 1
+
+ span = list_spans[0]
+ assert span.kind == SpanKind.SERVER
+ assert span.attributes is not None
+ assert span.attributes["mcp.method.name"] == "resources/templates/list"
+ assert span.attributes["fastmcp.server.name"] == "test-server"
+ assert span.attributes["fastmcp.component.type"] == "resource_template"
+
+
+class TestListPromptsTracing:
+ async def test_list_prompts_creates_span(
+ self, trace_exporter: InMemorySpanExporter
+ ):
+ mcp = FastMCP("test-server")
+
+ @mcp.prompt()
+ def greeting(name: str) -> str:
+ return f"Hello, {name}!"
+
+ prompts = await mcp.list_prompts()
+ assert len(prompts) == 1
+
+ spans = trace_exporter.get_finished_spans()
+ list_spans = [s for s in spans if s.name == "prompts/list"]
+ assert len(list_spans) >= 1
+
+ span = list_spans[0]
+ assert span.kind == SpanKind.SERVER
+ assert span.attributes is not None
+ assert span.attributes["mcp.method.name"] == "prompts/list"
+ assert span.attributes["fastmcp.server.name"] == "test-server"
+ assert span.attributes["fastmcp.component.type"] == "prompt"
+
+ async def test_list_prompts_empty_creates_span(
+ self, trace_exporter: InMemorySpanExporter
+ ):
+ mcp = FastMCP("test-server")
+
+ prompts = await mcp.list_prompts()
+ assert len(prompts) == 0
+
+ spans = trace_exporter.get_finished_spans()
+ list_spans = [s for s in spans if s.name == "prompts/list"]
+ assert len(list_spans) >= 1
diff --git a/tests/server/telemetry/test_server_tracing.py b/tests/server/telemetry/test_server_tracing.py
index 1d110effe..2b98ae6cc 100644
--- a/tests/server/telemetry/test_server_tracing.py
+++ b/tests/server/telemetry/test_server_tracing.py
@@ -33,10 +33,12 @@ class TestToolTracing:
assert span.attributes is not None
# Standard MCP semantic conventions
assert span.attributes["mcp.method.name"] == "tools/call"
- # Standard RPC semantic conventions
- assert span.attributes["rpc.system"] == "mcp"
- assert span.attributes["rpc.service"] == "test-server"
- assert span.attributes["rpc.method"] == "tools/call"
+ # gen_ai semantic conventions
+ assert span.attributes["gen_ai.tool.name"] == "greet"
+ # RPC attributes must NOT be present
+ assert "rpc.system" not in span.attributes
+ assert "rpc.service" not in span.attributes
+ assert "rpc.method" not in span.attributes
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "tool"
@@ -60,6 +62,10 @@ class TestToolTracing:
span = spans[0]
assert span.name == "tools/call failing_tool"
assert span.status.status_code == StatusCode.ERROR
+ assert span.status.description is not None
+ assert "Something went wrong" in span.status.description
+ assert span.attributes is not None
+ assert span.attributes["error.type"] == "tool_error"
assert len(span.events) > 0 # Exception recorded
async def test_call_nonexistent_tool_sets_error(
@@ -76,6 +82,9 @@ class TestToolTracing:
span = spans[0]
assert span.name == "tools/call nonexistent"
assert span.status.status_code == StatusCode.ERROR
+ assert span.attributes is not None
+ # NotFoundError is not a ToolError, so uses class name as fallback
+ assert span.attributes["error.type"] == "NotFoundError"
class TestResourceTracing:
@@ -95,16 +104,16 @@ class TestResourceTracing:
assert len(spans) == 1
span = spans[0]
- assert span.name == "resources/read config://app"
+ assert span.name == "resources/read"
assert span.kind == SpanKind.SERVER
assert span.attributes is not None
# Standard MCP semantic conventions
assert span.attributes["mcp.method.name"] == "resources/read"
assert span.attributes["mcp.resource.uri"] == "config://app"
- # Standard RPC semantic conventions
- assert span.attributes["rpc.system"] == "mcp"
- assert span.attributes["rpc.service"] == "test-server"
- assert span.attributes["rpc.method"] == "resources/read"
+ # RPC attributes must NOT be present
+ assert "rpc.system" not in span.attributes
+ assert "rpc.service" not in span.attributes
+ assert "rpc.method" not in span.attributes
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "resource"
@@ -126,14 +135,15 @@ class TestResourceTracing:
assert len(spans) == 1
span = spans[0]
- assert span.name == "resources/read users://123/profile"
+ assert span.name == "resources/read"
assert span.kind == SpanKind.SERVER
assert span.attributes is not None
# Standard MCP semantic conventions
assert span.attributes["mcp.method.name"] == "resources/read"
assert span.attributes["mcp.resource.uri"] == "users://123/profile"
- # Standard RPC semantic conventions
- assert span.attributes["rpc.method"] == "resources/read"
+ # RPC attributes must NOT be present
+ assert "rpc.system" not in span.attributes
+ assert "rpc.method" not in span.attributes
# Template component type is set by get_span_attributes
assert span.attributes["fastmcp.component.type"] == "resource_template"
assert (
@@ -153,7 +163,7 @@ class TestResourceTracing:
assert len(spans) == 1
span = spans[0]
- assert span.name == "resources/read nonexistent://resource"
+ assert span.name == "resources/read"
assert span.status.status_code == StatusCode.ERROR
@@ -179,10 +189,12 @@ class TestPromptTracing:
assert span.attributes is not None
# Standard MCP semantic conventions
assert span.attributes["mcp.method.name"] == "prompts/get"
- # Standard RPC semantic conventions
- assert span.attributes["rpc.system"] == "mcp"
- assert span.attributes["rpc.service"] == "test-server"
- assert span.attributes["rpc.method"] == "prompts/get"
+ # gen_ai semantic conventions
+ assert span.attributes["gen_ai.prompt.name"] == "greeting"
+ # RPC attributes must NOT be present
+ assert "rpc.system" not in span.attributes
+ assert "rpc.service" not in span.attributes
+ assert "rpc.method" not in span.attributes
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "prompt"
diff --git a/uv.lock b/uv.lock
index 3bddc574e..79922ca9c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "pytest"
-version = "9.0.2"
+version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -2433,9 +2433,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]