mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Merge main into declarative elicitation
This commit is contained in:
commit
32e3eaf96a
121 changed files with 3064 additions and 970 deletions
7
.github/actions/run-claude/action.yml
vendored
7
.github/actions/run-claude/action.yml
vendored
|
|
@ -37,6 +37,11 @@ inputs:
|
|||
required: false
|
||||
default: ""
|
||||
|
||||
extra-allowed-tools:
|
||||
description: "Additional comma-separated tools to append to allowed-tools"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
model:
|
||||
description: "Model to use for Claude"
|
||||
required: false
|
||||
|
|
@ -88,7 +93,7 @@ runs:
|
|||
track_progress: ${{ inputs.track-progress }}
|
||||
prompt: ${{ inputs.prompt }}
|
||||
claude_args: |
|
||||
${{ (inputs.allowed-tools != '' || inputs.extra-allowed-tools != '') && format('--allowedTools {0}{1}', inputs.allowed-tools, inputs.extra-allowed-tools != '' && format(',{0}', inputs.extra-allowed-tools) || '') || '' }}
|
||||
${{ (inputs.allowed-tools != '' || inputs.extra-allowed-tools != '') && format('--allowedTools ''{0}{1}''', inputs.allowed-tools, inputs.extra-allowed-tools != '' && format(',{0}', inputs.extra-allowed-tools) || '') || '' }}
|
||||
${{ inputs.mcp-servers != '' && format('--mcp-config ''{0}''', inputs.mcp-servers) || '' }}
|
||||
--model ${{ inputs.model }}
|
||||
settings: |
|
||||
|
|
|
|||
10
.github/actions/run-pytest/action.yml
vendored
10
.github/actions/run-pytest/action.yml
vendored
|
|
@ -46,6 +46,16 @@ runs:
|
|||
PARALLEL_FLAGS="--numprocesses auto --maxprocesses $MAX_PROCS --dist worksteal"
|
||||
fi
|
||||
|
||||
# pytest-timeout has no signal-based method on Windows, so it falls back
|
||||
# to the thread method, which dumps stacks and os._exit()s the process.
|
||||
# Under a contended runner that turns a single slow test into a dead
|
||||
# xdist worker, failing whichever unrelated test that worker happened to
|
||||
# be running. Give parallel Windows runs more headroom so ordinary
|
||||
# scheduling jitter does not take a worker down.
|
||||
if [ "$RUNNER_OS" == "Windows" ] && [ "$MAX_PROCS" != "0" ]; then
|
||||
TIMEOUT=$((TIMEOUT * 4))
|
||||
fi
|
||||
|
||||
uv run --no-sync pytest \
|
||||
--inline-snapshot=disable \
|
||||
--timeout=$TIMEOUT \
|
||||
|
|
|
|||
38
.github/workflows/publish-fastmcp.yml
vendored
38
.github/workflows/publish-fastmcp.yml
vendored
|
|
@ -178,19 +178,27 @@ jobs:
|
|||
run: uv publish -v dist/fastmcp-*.tar.gz dist/fastmcp-*.whl
|
||||
|
||||
update-published-docs:
|
||||
name: Update published-docs branch
|
||||
name: Open published-docs PR
|
||||
runs-on: ubuntu-latest
|
||||
needs: pypi-publish
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.event == 'release' && needs['pypi-publish'].outputs.is_prerelease != 'true'
|
||||
timeout-minutes: 2
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Generate Marvin App token
|
||||
id: marvin-token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.MARVIN_APP_ID }}
|
||||
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ steps.marvin-token.outputs.token }}
|
||||
|
||||
- name: Check release line
|
||||
id: release_line
|
||||
|
|
@ -205,6 +213,26 @@ jobs:
|
|||
echo "Release commit is not on ${DEFAULT_BRANCH}; skipping published-docs update."
|
||||
fi
|
||||
|
||||
- name: Point published-docs at published release
|
||||
- name: Prepare published docs tree
|
||||
if: steps.release_line.outputs.update_published_docs == 'true'
|
||||
run: git push --force origin "HEAD:published-docs"
|
||||
env:
|
||||
RELEASE_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
git fetch origin published-docs
|
||||
git switch --force-create published-docs-sync origin/published-docs
|
||||
git read-tree --reset -u "$RELEASE_SHA"
|
||||
test "$(git write-tree)" = "$(git rev-parse "${RELEASE_SHA}^{tree}")"
|
||||
|
||||
- name: Open published docs PR
|
||||
if: steps.release_line.outputs.update_published_docs == 'true'
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ steps.marvin-token.outputs.token }}
|
||||
base: published-docs
|
||||
branch: marvin/publish-docs-v${{ needs.pypi-publish.outputs.version }}
|
||||
commit-message: "Publish FastMCP v${{ needs.pypi-publish.outputs.version }} docs"
|
||||
title: "Publish FastMCP v${{ needs.pypi-publish.outputs.version }} docs"
|
||||
body: "Updates `published-docs` to the exact release tree. Merging publishes the documentation to production."
|
||||
delete-branch: true
|
||||
author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>"
|
||||
committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>"
|
||||
|
|
|
|||
20
CLAUDE.md
20
CLAUDE.md
|
|
@ -56,6 +56,8 @@ When modifying MCP functionality, changes typically need to be applied across al
|
|||
|
||||
**Read `CONTRIBUTING.md` before opening issues or PRs.** It describes when PRs are appropriate, what we expect from enhancement proposals, and what we'll close without review.
|
||||
|
||||
**Review closed contributor PRs.** When reviewing an issue, inspect every associated non-maintainer PR, including closed PRs. External PRs may be closed as part of the issue-link and assignment workflow, so closure alone is not a negative signal. Read `CONTRIBUTING.md` and the PR timeline and comments to understand its status before evaluating it.
|
||||
|
||||
### Git & CI
|
||||
|
||||
- Prek hooks are required (run automatically on commits)
|
||||
|
|
@ -117,7 +119,9 @@ Set `target_commitish` to the same branch that will receive the release tag. For
|
|||
|
||||
**Patch releases** (3.1.1, 3.0.2) get 1-2 sentences explaining what broke and what the fix does. Keep it minimal — the auto-generated changelog has the details.
|
||||
|
||||
**Merge the docs changelog PR *before* cutting the release, not after.** The post-publish `update-published-docs` job force-pushes the `published-docs` branch (which gofastmcp.com serves) to the released commit for stable releases on the default branch, so the changelog entry only reaches the live site if it's already in the commit being tagged. Land the docs PR on the release target branch first, then cut the release from that branch. If you tag first and merge docs after, this release's changelog won't appear on the live site until the next default-branch stable release force-pushes `published-docs` forward. Maintenance releases from `release/3.x` or `release/2.x` publish packages and GitHub notes without repointing `published-docs`; add their changelog entries on the maintenance branch, slotted into the matching major-version section. Two hand-maintained files mirror the GitHub release and must get a new entry for every version, newest at the top (these are `.mdx` and are not covered by the prek Prettier hook, which only runs on `yaml`/`json5` — match the existing entries' style by hand):
|
||||
**Publish docs through a PR.** The `published-docs` branch serves gofastmcp.com, and repository rules reject direct pushes and force-pushes to it. Stable releases from `main` automatically open a publication PR after PyPI succeeds. For prereleases and later docs follow-ups, create the same PR manually: start a temporary branch from the current `published-docs`, make a single commit whose tree exactly matches the desired commit on `main`, and use `published-docs` as the PR base. Merging publishes to production. Never push directly to `published-docs`.
|
||||
|
||||
**Merge the docs changelog PR *before* cutting the release, not after.** The post-publish `update-published-docs` job opens a PR that syncs `published-docs` to the released commit for stable releases on the default branch, so the changelog entry only reaches the live site if it's already in the commit being tagged. Land the docs PR on the release target branch first, then cut the release from that branch. If you tag first and merge docs after, this release's publication PR will not include the changelog; publish `main` manually through the PR flow above or wait for the next default-branch stable release. Maintenance releases from `release/3.x` or `release/2.x` publish packages and GitHub notes without repointing `published-docs`; add their changelog entries on the maintenance branch, slotted into the matching major-version section. Two hand-maintained files mirror the GitHub release and must get a new entry for every version, newest at the top (these are `.mdx` and are not covered by the prek Prettier hook, which only runs on `yaml`/`json5` — match the existing entries' style by hand):
|
||||
|
||||
- `docs/changelog.mdx` is the full mirror. Add an `<Update label="v<version>" description="YYYY-MM-DD">` block with: a bold linked title (`**[v<version>: <pun>](<release-url>)**`), a condensed 1-paragraph intro (one sentence for patches), the full categorized PR list reformatted from the `--generate-notes` output (`* <title> by [@user](https://github.com/user) in [#NNNN](<pull-url>)`), a `## New Contributors` list (plain `@user`, linked PR), and a `**Full Changelog**: [vA...vB](<compare-url>)` line.
|
||||
- `docs/updates.mdx` is the skimmable card feed. Add an `<Update label="FastMCP <version>" description="Month DD, YYYY" tags={["Releases"]}>` wrapping a `<Card>` that links to the GitHub release, with a 1-2 sentence summary and (for point releases) a handful of emoji-bulleted highlights.
|
||||
|
|
@ -184,6 +188,20 @@ Because the docs land *before* the tag exists, derive the entry from the maintai
|
|||
- **Style:** Prose over code comments for important information
|
||||
- **Docstrings:** FastMCP docstrings are automatically compiled into MDX documents. Use markdown (single backticks, fenced code blocks), not RST (no double backticks). Bare `{}` in examples will be interpreted as JSX — wrap in backticks instead.
|
||||
|
||||
## Code Review Rules
|
||||
|
||||
### Framework regressions and root causes
|
||||
|
||||
- Review changes carefully for regressions in supported framework behavior, including interactions beyond the immediate diff. Trace relevant callers, shared abstractions, protocol and public API contracts, and all affected MCP component types. Determine whether a change fixes the causal code path or merely compensates for the symptom; side channels and special cases that leave the root cause intact should be treated as suspect.
|
||||
|
||||
### Comprehensive first pass
|
||||
|
||||
- Review the entire pull request diff against the merge base, not only the latest commits. Inspect every changed file and the relevant surrounding code, collect all independent, substantiated consequential findings before submitting the review, and report the complete set in one review whenever possible. Do not stop after finding the first few issues or defer other already-visible findings to later review cycles.
|
||||
|
||||
### Prior discussion and proportionality
|
||||
|
||||
- When prior review threads and author or maintainer replies are available, read them before commenting. Evaluate responses on their merits and do not repeat a resolved or convincingly rebutted finding without new evidence. Avoid fixating on speculative edge cases: report an edge case only when it is reachable under supported usage or a credible threat model and has meaningful impact; otherwise omit it or clearly treat it as non-blocking.
|
||||
|
||||
## Critical Patterns
|
||||
|
||||
- Never use bare `except` - be specific with exception types
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -17,6 +17,7 @@
|
|||
[](https://gofastmcp.com)
|
||||
[](https://discord.gg/uu8dJCgttd)
|
||||
[](https://pypi.org/project/fastmcp)
|
||||
[](https://github.com/PrefectHQ/fastmcp-ts)
|
||||
[](https://github.com/PrefectHQ/fastmcp/actions/workflows/run-tests.yml)
|
||||
[](https://github.com/PrefectHQ/fastmcp/blob/main/LICENSE)
|
||||
|
||||
|
|
@ -25,7 +26,7 @@
|
|||
|
||||
---
|
||||
|
||||
The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP gives you everything you need to go from prototype to production:
|
||||
The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP is a full MCP application framework for servers, clients, and interactive apps. A server starts with ordinary Python:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
|
@ -77,13 +78,15 @@ FastMCP has three pillars:
|
|||
|
||||
**[Servers](https://gofastmcp.com/servers/server)** wrap your Python functions into MCP-compliant tools, resources, and prompts. **[Clients](https://gofastmcp.com/clients/client)** connect to any server with full protocol support. And **[Apps](https://gofastmcp.com/apps/overview)** give your tools interactive UIs rendered directly in the conversation.
|
||||
|
||||
**Building in TypeScript?** [FastMCP for TypeScript](https://github.com/PrefectHQ/fastmcp-ts) is the official counterpart, built and maintained by the same team. Same pillars, same ideas, `npm install @prefecthq/fastmcp-ts`.
|
||||
|
||||
Ready to build? Start with the [installation guide](https://gofastmcp.com/getting-started/installation) or jump straight to the [quickstart](https://gofastmcp.com/getting-started/quickstart).
|
||||
|
||||
## Run FastMCP in production with Horizon
|
||||
## Scale MCP with Horizon
|
||||
|
||||
FastMCP is the standard way to build MCP servers. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=github&utm_medium=readme&utm_campaign=readme_horizon&utm_content=readme_body)** is the enterprise MCP gateway for running them safely.
|
||||
FastMCP handles the MCP application layer. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=github&utm_medium=readme&utm_campaign=readme_horizon&utm_content=readme_body)** is the enterprise MCP gateway for scaling servers and tools across teams, with centralized governance over how they are deployed, discovered, secured, and used.
|
||||
|
||||
Built by the FastMCP team, Horizon packages the best practices we've learned shipping the world's most popular MCP framework.
|
||||
FastMCP and Horizon are built by the same team at [Prefect](https://www.prefect.io/).
|
||||
|
||||
Deploy FastMCP servers from GitHub with branch previews and instant rollback. Create a private registry of every MCP your company uses. Secure access with SSO and tool-level RBAC. Get audit logs, observability, and governance across your MCP stack. Remix approved tools into purpose-built endpoints for teams and agents.
|
||||
|
||||
|
|
@ -91,10 +94,10 @@ Start with FastMCP. [Scale with Horizon →](https://www.prefect.io/horizon?utm_
|
|||
|
||||
## Installation
|
||||
|
||||
We recommend installing FastMCP with [uv](https://docs.astral.sh/uv/):
|
||||
We recommend adding FastMCP to your project with [uv](https://docs.astral.sh/uv/):
|
||||
|
||||
```bash
|
||||
uv pip install fastmcp
|
||||
uv add fastmcp
|
||||
```
|
||||
|
||||
For full installation instructions, including verification and upgrading, see the [**Installation Guide**](https://gofastmcp.com/getting-started/installation).
|
||||
|
|
@ -105,9 +108,6 @@ For full installation instructions, including verification and upgrading, see th
|
|||
- [Upgrading from MCP SDK v1](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v2)
|
||||
- [Upgrading from the low-level SDK v1](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v2)
|
||||
|
||||
> [!NOTE]
|
||||
> If `import fastmcp` fails right after a `pip` upgrade from FastMCP 3.2 or earlier, run `pip install --force-reinstall fastmcp`. See [Troubleshooting](https://gofastmcp.com/getting-started/installation#troubleshooting) for why this happens (`uv` is unaffected).
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
FastMCP's complete documentation is available at **[gofastmcp.com](https://gofastmcp.com)**, including detailed guides, API references, and advanced patterns.
|
||||
|
|
|
|||
|
|
@ -59,16 +59,24 @@ This works with **stdio**, **SSE**, and **stateful HTTP** transports, where sess
|
|||
In **stateless HTTP** mode, each request creates a new session object with a new ID. Files stored during one request (e.g. the UI upload) will be invisible to the next request (e.g. the LLM calling `list_files`). You **must** override `_get_scope_key` to use a stable identifier like a user ID from your auth token.
|
||||
</Warning>
|
||||
|
||||
For stateless deployments, override `_get_scope_key` to return a stable identifier. For example, to scope files by authenticated user:
|
||||
For stateless deployments, override `_get_scope_key` to return a stable identifier. To scope files by authenticated user, read the caller from `get_access_token()`.
|
||||
|
||||
Reject the request when there is no subject to key on. `get_access_token()` returns `None` on an unauthenticated request, and `subject` is optional even on a valid token, since not every verifier populates it. Returning a fallback in either case would put every such caller in one shared bucket, so they would see each other's uploads.
|
||||
|
||||
```python
|
||||
from fastmcp.apps.file_upload import FileUpload
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
|
||||
class UserScopedUpload(FileUpload):
|
||||
def _get_scope_key(self, ctx):
|
||||
return ctx.access_token["sub"]
|
||||
token = get_access_token()
|
||||
if token is None or not token.subject:
|
||||
raise ValueError("File scoping requires an authenticated user with a subject")
|
||||
return token.subject
|
||||
```
|
||||
|
||||
If your provider carries the user identity in a different claim, read it from `token.claims` and validate it the same way.
|
||||
|
||||
For process-wide shared storage (all users see all files):
|
||||
|
||||
```python
|
||||
|
|
@ -85,10 +93,17 @@ The default implementation stores files in memory for the lifetime of the server
|
|||
import base64
|
||||
|
||||
from fastmcp.apps.file_upload import FileUpload
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
|
||||
class S3Upload(FileUpload):
|
||||
def _get_scope_key(self, ctx):
|
||||
token = get_access_token()
|
||||
if token is None or not token.subject:
|
||||
raise ValueError("File scoping requires an authenticated user with a subject")
|
||||
return token.subject
|
||||
|
||||
def on_store(self, files, ctx):
|
||||
user_id = ctx.access_token["sub"]
|
||||
user_id = self._get_scope_key(ctx)
|
||||
for f in files:
|
||||
s3.put_object(
|
||||
Bucket="uploads",
|
||||
|
|
@ -98,7 +113,7 @@ class S3Upload(FileUpload):
|
|||
return self.on_list(ctx)
|
||||
|
||||
def on_list(self, ctx):
|
||||
user_id = ctx.access_token["sub"]
|
||||
user_id = self._get_scope_key(ctx)
|
||||
objects = s3.list_objects(Bucket="uploads", Prefix=f"{user_id}/")
|
||||
return [
|
||||
{
|
||||
|
|
@ -112,7 +127,7 @@ class S3Upload(FileUpload):
|
|||
]
|
||||
|
||||
def on_read(self, name, ctx):
|
||||
user_id = ctx.access_token["sub"]
|
||||
user_id = self._get_scope_key(ctx)
|
||||
obj = s3.get_object(Bucket="uploads", Key=f"{user_id}/{name}")
|
||||
content = obj["Body"].read()
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,198 @@ rss: true
|
|||
tag: NEW
|
||||
---
|
||||
|
||||
<Update label="v3.4.6" description="2026-08-05">
|
||||
|
||||
**[v3.4.6: Trust, but Proxy](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.6)**
|
||||
|
||||
FastMCP 3.4.6 backports trusted-proxy support for SSRF-protected OAuth metadata and JWKS fetches. Deployments can now route these requests through a mandated corporate proxy while preserving custom CA certificates; FastMCP refuses the fetch when no proxy is configured instead of risking an unprotected direct request.
|
||||
|
||||
### Fixes 🐞
|
||||
* Backport #4412 to 3.x: support trusted SSRF proxies by [@jlowin](https://github.com/jlowin) in [#4755](https://github.com/PrefectHQ/fastmcp/pull/4755)
|
||||
|
||||
### Docs 📚
|
||||
* Docs: add v3.4.6 changelog entries by [@jlowin](https://github.com/jlowin) in [#4761](https://github.com/PrefectHQ/fastmcp/pull/4761)
|
||||
|
||||
**Full Changelog**: [v3.4.5...v3.4.6](https://github.com/PrefectHQ/fastmcp/compare/v3.4.5...v3.4.6)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v4.0.0b1" description="2026-07-28">
|
||||
|
||||
**[v4.0.0b1: Fourgone Conclusion](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b1)**
|
||||
|
||||
FastMCP 4 makes stateful MCP applications work on the sessionless `2026-07-28` protocol while one deployment continues serving handshake-era clients. Tools can ask follow-up questions across requests, preserve authenticated user state, and move long-running work into background tasks without sticky sessions. Protocol extensions and enterprise identity become first-class surfaces, and most FastMCP 3 servers upgrade unchanged even though MCP Python SDK v2 rewrote the engine underneath them. Server-initiated sampling and roots are removed from the server API; the [upgrade guide](/getting-started/upgrading/from-fastmcp-3) covers their replacements.
|
||||
|
||||
### New Features 🎉
|
||||
* Migrate to MCP Python SDK v2 by [@jlowin](https://github.com/jlowin) in [#4437](https://github.com/PrefectHQ/fastmcp/pull/4437)
|
||||
* Teach fastmcp.Client the modern protocol: mode negotiation, MRTR driver, response cache by [@jlowin](https://github.com/jlowin) in [#4450](https://github.com/PrefectHQ/fastmcp/pull/4450)
|
||||
* Forward-port Hugging Face auth provider by [@jlowin](https://github.com/jlowin) in [#4475](https://github.com/PrefectHQ/fastmcp/pull/4475)
|
||||
* Add server-side identity assertion (SEP-990 ID-JAG) by [@jlowin](https://github.com/jlowin) in [#4483](https://github.com/PrefectHQ/fastmcp/pull/4483)
|
||||
* Add guard-mode multi-round-trip tools (SEP-2322) by [@jlowin](https://github.com/jlowin) in [#4544](https://github.com/PrefectHQ/fastmcp/pull/4544)
|
||||
* Add FastMCP-native server extension API (SEP-2133) by [@jlowin](https://github.com/jlowin) in [#4602](https://github.com/PrefectHQ/fastmcp/pull/4602)
|
||||
* Add stateless session state (UserSession / SessionId) by [@jlowin](https://github.com/jlowin) in [#4604](https://github.com/PrefectHQ/fastmcp/pull/4604)
|
||||
* Add background tasks via the io.modelcontextprotocol/tasks extension (SEP-2663) by [@jlowin](https://github.com/jlowin) in [#4603](https://github.com/PrefectHQ/fastmcp/pull/4603)
|
||||
### Breaking Changes ⚠️
|
||||
* Emit one SERVER span per request and adopt spec-correct error codes by [@jlowin](https://github.com/jlowin) in [#4445](https://github.com/PrefectHQ/fastmcp/pull/4445)
|
||||
* Remove 3.x deprecated module shims and dead parameters by [@jlowin](https://github.com/jlowin) in [#4447](https://github.com/PrefectHQ/fastmcp/pull/4447)
|
||||
* Remove 3.0-deprecated FastMCP server methods by [@jlowin](https://github.com/jlowin) in [#4451](https://github.com/PrefectHQ/fastmcp/pull/4451)
|
||||
* Remove 3.x deprecated parameters and object-mode decorators by [@jlowin](https://github.com/jlowin) in [#4453](https://github.com/PrefectHQ/fastmcp/pull/4453)
|
||||
* Migrate to MCP SDK v2.0.0b2 (httpx2) by [@jlowin](https://github.com/jlowin) in [#4503](https://github.com/PrefectHQ/fastmcp/pull/4503)
|
||||
* Fix typos by [@szepeviktor](https://github.com/szepeviktor) in [#4498](https://github.com/PrefectHQ/fastmcp/pull/4498)
|
||||
* Stop proxies from validating backend results or mutating shared transports by [@jlowin](https://github.com/jlowin) in [#4552](https://github.com/PrefectHQ/fastmcp/pull/4552)
|
||||
* Surface resource, prompt, and proxy errors on the modern protocol by [@jlowin](https://github.com/jlowin) in [#4579](https://github.com/PrefectHQ/fastmcp/pull/4579)
|
||||
* Negotiate the best mutual protocol era by default by [@jlowin](https://github.com/jlowin) in [#4572](https://github.com/PrefectHQ/fastmcp/pull/4572)
|
||||
* Remove server-initiated sampling and roots from the server API by [@jlowin](https://github.com/jlowin) in [#4648](https://github.com/PrefectHQ/fastmcp/pull/4648)
|
||||
* Remove 3.x-era compatibility shims by [@jlowin](https://github.com/jlowin) in [#4661](https://github.com/PrefectHQ/fastmcp/pull/4661)
|
||||
### Enhancements ✨
|
||||
* Deprecate ctx.sample and add clear errors for push features on 2026 connections by [@jlowin](https://github.com/jlowin) in [#4448](https://github.com/PrefectHQ/fastmcp/pull/4448)
|
||||
* Add server-level cache hints (SEP-2549) by [@jlowin](https://github.com/jlowin) in [#4464](https://github.com/PrefectHQ/fastmcp/pull/4464)
|
||||
* Add KeyValueResponseCacheStore for distributed client response caching by [@jlowin](https://github.com/jlowin) in [#4479](https://github.com/PrefectHQ/fastmcp/pull/4479)
|
||||
* Test lifespan fires once per process over HTTP by [@jlowin](https://github.com/jlowin) in [#4480](https://github.com/PrefectHQ/fastmcp/pull/4480)
|
||||
* Add telemetry off-switch and mcp.protocol.version span attribute by [@jlowin](https://github.com/jlowin) in [#4481](https://github.com/PrefectHQ/fastmcp/pull/4481)
|
||||
* Trace client task management requests by [@jlowin](https://github.com/jlowin) in [#4525](https://github.com/PrefectHQ/fastmcp/pull/4525)
|
||||
* Stabilize upgraded ty checks by [@jlowin](https://github.com/jlowin) in [#4526](https://github.com/PrefectHQ/fastmcp/pull/4526)
|
||||
* Improve DescopeProvider scope discovery and well-known URL support by [@gaokevin1](https://github.com/gaokevin1) in [#4489](https://github.com/PrefectHQ/fastmcp/pull/4489)
|
||||
* Add examples/ to the ty static-analysis gate by [@jlowin](https://github.com/jlowin) in [#4466](https://github.com/PrefectHQ/fastmcp/pull/4466)
|
||||
* Expose telemetry attributes on span start by [@zzstoatzz](https://github.com/zzstoatzz) in [#4487](https://github.com/PrefectHQ/fastmcp/pull/4487)
|
||||
* Fix-issue-4284 : Add Auth0MCPProvider for Auth0 Auth for MCP by [@vijaydeepsinha](https://github.com/vijaydeepsinha) in [#4411](https://github.com/PrefectHQ/fastmcp/pull/4411)
|
||||
* Run FastMCP middleware for every inbound message by [@jlowin](https://github.com/jlowin) in [#4553](https://github.com/PrefectHQ/fastmcp/pull/4553)
|
||||
* Add 'prs welcome' label to waive the PR assignment gate by [@jlowin](https://github.com/jlowin) in [#4557](https://github.com/PrefectHQ/fastmcp/pull/4557)
|
||||
* Rename martian workflows to marvin by [@jlowin](https://github.com/jlowin) in [#4558](https://github.com/PrefectHQ/fastmcp/pull/4558)
|
||||
* Bump pinned Claude models to current versions by [@jlowin](https://github.com/jlowin) in [#4561](https://github.com/PrefectHQ/fastmcp/pull/4561)
|
||||
* Make the unit suite fast: in-process HTTP tests, no real sleeps, parallel Windows CI by [@jlowin](https://github.com/jlowin) in [#4554](https://github.com/PrefectHQ/fastmcp/pull/4554)
|
||||
* Mirror the frontend's protocol era on a proxy's backend connection by [@jlowin](https://github.com/jlowin) in [#4573](https://github.com/PrefectHQ/fastmcp/pull/4573)
|
||||
* Drop forked client protocol helpers in favor of the SDK's by [@jlowin](https://github.com/jlowin) in [#4574](https://github.com/PrefectHQ/fastmcp/pull/4574)
|
||||
* Bring the v4 developer notes up to date with what shipped by [@jlowin](https://github.com/jlowin) in [#4581](https://github.com/PrefectHQ/fastmcp/pull/4581)
|
||||
* Trim fastmcp.types to FastMCP-unique types by [@jlowin](https://github.com/jlowin) in [#4584](https://github.com/PrefectHQ/fastmcp/pull/4584)
|
||||
* Let a server answer argument-completion requests by [@jlowin](https://github.com/jlowin) in [#4582](https://github.com/PrefectHQ/fastmcp/pull/4582)
|
||||
* Add machine-to-machine client authentication by [@jlowin](https://github.com/jlowin) in [#4583](https://github.com/PrefectHQ/fastmcp/pull/4583)
|
||||
* Expose era-neutral client server metadata by [@zzstoatzz](https://github.com/zzstoatzz) in [#4599](https://github.com/PrefectHQ/fastmcp/pull/4599)
|
||||
* Support routable transport headers for gateways (SEP-2243) by [@jlowin](https://github.com/jlowin) in [#4622](https://github.com/PrefectHQ/fastmcp/pull/4622)
|
||||
* Emit scope step-up challenges for incremental authorization (SEP-2350) by [@jlowin](https://github.com/jlowin) in [#4623](https://github.com/PrefectHQ/fastmcp/pull/4623)
|
||||
* Honor OAuth application_type in DCR (SEP-837) by [@jlowin](https://github.com/jlowin) in [#4621](https://github.com/PrefectHQ/fastmcp/pull/4621)
|
||||
* Drop stale label-noting instructions from CLAUDE.md by [@jlowin](https://github.com/jlowin) in [#4654](https://github.com/PrefectHQ/fastmcp/pull/4654)
|
||||
* Add require_roles auth check by [@jlowin](https://github.com/jlowin) in [#4656](https://github.com/PrefectHQ/fastmcp/pull/4656)
|
||||
* Add `valid_scopes` parameter to OIDC proxy valid scopes by [@Educg550](https://github.com/Educg550) in [#4660](https://github.com/PrefectHQ/fastmcp/pull/4660)
|
||||
* feat: Add telemetry interop mode for FastMCP by [@strawgate](https://github.com/strawgate) in [#4046](https://github.com/PrefectHQ/fastmcp/pull/4046)
|
||||
* Note that review comment threads should get an acknowledgement by [@jlowin](https://github.com/jlowin) in [#4678](https://github.com/PrefectHQ/fastmcp/pull/4678)
|
||||
* Soften the review-comment reply guidance by [@jlowin](https://github.com/jlowin) in [#4683](https://github.com/PrefectHQ/fastmcp/pull/4683)
|
||||
* Resolve review threads on fix, reply on decline by [@jlowin](https://github.com/jlowin) in [#4685](https://github.com/PrefectHQ/fastmcp/pull/4685)
|
||||
* Move to the stable MCP Python SDK 2.0.0 by [@jlowin](https://github.com/jlowin) in [#4655](https://github.com/PrefectHQ/fastmcp/pull/4655)
|
||||
### Security 🔒
|
||||
* Drive the FastMCP lifespan through the SDK session manager by [@jlowin](https://github.com/jlowin) in [#4446](https://github.com/PrefectHQ/fastmcp/pull/4446)
|
||||
* Route skill file access through SDK path-security primitives by [@jlowin](https://github.com/jlowin) in [#4449](https://github.com/PrefectHQ/fastmcp/pull/4449)
|
||||
* Screen templated resource parameters for path traversal by default by [@jlowin](https://github.com/jlowin) in [#4482](https://github.com/PrefectHQ/fastmcp/pull/4482)
|
||||
* [codex] Add OAuthProxy RFC 9207 issuer responses by [@jlowin](https://github.com/jlowin) in [#4438](https://github.com/PrefectHQ/fastmcp/pull/4438)
|
||||
* Apply app visibility where no host can by [@jlowin](https://github.com/jlowin) in [#4692](https://github.com/PrefectHQ/fastmcp/pull/4692)
|
||||
### Fixes 🐞
|
||||
* Capture SharedContext for task-enabled Docket servers by [@jlowin](https://github.com/jlowin) in [#4443](https://github.com/PrefectHQ/fastmcp/pull/4443)
|
||||
* Fix stale mcp.types imports in examples by [@jlowin](https://github.com/jlowin) in [#4452](https://github.com/PrefectHQ/fastmcp/pull/4452)
|
||||
* Forward-port HTTP host guard compatibility by [@jlowin](https://github.com/jlowin) in [#4474](https://github.com/PrefectHQ/fastmcp/pull/4474)
|
||||
* Fix Azure scope fallback by [@zzstoatzz](https://github.com/zzstoatzz) in [#4469](https://github.com/PrefectHQ/fastmcp/pull/4469)
|
||||
* fix(server): omit ScalarElicitationType wrapper title from elicitation schemas by [@syf2211](https://github.com/syf2211) in [#4502](https://github.com/PrefectHQ/fastmcp/pull/4502)
|
||||
* Skip unsupported JWKS keys instead of failing the whole key set (#4515) by [@earfman](https://github.com/earfman) in [#4517](https://github.com/PrefectHQ/fastmcp/pull/4517)
|
||||
* Don't mutate the caller's schema in compress_schema by [@winklemad](https://github.com/winklemad) in [#4492](https://github.com/PrefectHQ/fastmcp/pull/4492)
|
||||
* Forward upstream instructions through create_proxy by [@verdie-g](https://github.com/verdie-g) in [#4512](https://github.com/PrefectHQ/fastmcp/pull/4512)
|
||||
* Serialize deep object query parameters by [@jlowin](https://github.com/jlowin) in [#4523](https://github.com/PrefectHQ/fastmcp/pull/4523)
|
||||
* Reject positional-only tool parameters by [@jlowin](https://github.com/jlowin) in [#4524](https://github.com/PrefectHQ/fastmcp/pull/4524)
|
||||
* Clarify PR-reopen flow and fix label-race that broke auto-reopen by [@jlowin](https://github.com/jlowin) in [#4518](https://github.com/PrefectHQ/fastmcp/pull/4518)
|
||||
* Clean up disconnected task sessions by [@jlowin](https://github.com/jlowin) in [#4519](https://github.com/PrefectHQ/fastmcp/pull/4519)
|
||||
* Handle expired OAuth client registrations by [@jlowin](https://github.com/jlowin) in [#4520](https://github.com/PrefectHQ/fastmcp/pull/4520)
|
||||
* Fix OAuth request annotation after httpx2 migration by [@jlowin](https://github.com/jlowin) in [#4534](https://github.com/PrefectHQ/fastmcp/pull/4534)
|
||||
* Fix docs banner contrast by [@jlowin](https://github.com/jlowin) in [#4522](https://github.com/PrefectHQ/fastmcp/pull/4522)
|
||||
* Preserve component metadata in response cache by [@jlowin](https://github.com/jlowin) in [#4521](https://github.com/PrefectHQ/fastmcp/pull/4521)
|
||||
* Clean up task sessions on connection exit by [@jlowin](https://github.com/jlowin) in [#4535](https://github.com/PrefectHQ/fastmcp/pull/4535)
|
||||
* Include scopes in auth challenges by [@jlowin](https://github.com/jlowin) in [#4527](https://github.com/PrefectHQ/fastmcp/pull/4527)
|
||||
* Make examples/ actually trigger the ty gate by [@jlowin](https://github.com/jlowin) in [#4541](https://github.com/PrefectHQ/fastmcp/pull/4541)
|
||||
* Add subject field to AccessToken initialization by [@piaudonn](https://github.com/piaudonn) in [#4267](https://github.com/PrefectHQ/fastmcp/pull/4267)
|
||||
* Restore Mintlify's fixed banner positioning by [@jlowin](https://github.com/jlowin) in [#4542](https://github.com/PrefectHQ/fastmcp/pull/4542)
|
||||
* Fix #4292: SSRF guard breaks OAuth/JWKS fetches behind a corporate HTTP proxy by [@endofcake](https://github.com/endofcake) in [#4412](https://github.com/PrefectHQ/fastmcp/pull/4412)
|
||||
* Preserve telemetry attributes when a sampler does not forward them by [@jlowin](https://github.com/jlowin) in [#4539](https://github.com/PrefectHQ/fastmcp/pull/4539)
|
||||
* Speed up the unit test suite, and fix the task-notification race it surfaced by [@jlowin](https://github.com/jlowin) in [#4550](https://github.com/PrefectHQ/fastmcp/pull/4550)
|
||||
* Fix label triage applying no labels, and make blocked tool calls fail by [@jlowin](https://github.com/jlowin) in [#4555](https://github.com/PrefectHQ/fastmcp/pull/4555)
|
||||
* Fix AI workflow allowlists being destroyed by tokenization by [@jlowin](https://github.com/jlowin) in [#4560](https://github.com/PrefectHQ/fastmcp/pull/4560)
|
||||
* Make transformed tool `required` order deterministic by [@Kludex](https://github.com/Kludex) in [#4564](https://github.com/PrefectHQ/fastmcp/pull/4564)
|
||||
* Stop gather() from creating coroutines it may never schedule by [@jlowin](https://github.com/jlowin) in [#4559](https://github.com/PrefectHQ/fastmcp/pull/4559)
|
||||
* Restore upgraded dependency checks by [@zzstoatzz](https://github.com/zzstoatzz) in [#4576](https://github.com/PrefectHQ/fastmcp/pull/4576)
|
||||
* Fix skill frontmatter parsing with UTF-8 BOM by [@hxaxd](https://github.com/hxaxd) in [#4533](https://github.com/PrefectHQ/fastmcp/pull/4533)
|
||||
* Fix File helper extension handling by [@VectorPeak](https://github.com/VectorPeak) in [#4531](https://github.com/PrefectHQ/fastmcp/pull/4531)
|
||||
* Fix percent-encoded skill file names unreadable in resources mode by [@jlowin](https://github.com/jlowin) in [#4590](https://github.com/PrefectHQ/fastmcp/pull/4590)
|
||||
* Fix flaky stdio crash-recovery tests by [@jlowin](https://github.com/jlowin) in [#4594](https://github.com/PrefectHQ/fastmcp/pull/4594)
|
||||
* Bridge camelCase ToolAnnotations reads by [@zzstoatzz](https://github.com/zzstoatzz) in [#4597](https://github.com/PrefectHQ/fastmcp/pull/4597)
|
||||
* Preserve raw CallToolResult tool returns by [@LarryHu0217](https://github.com/LarryHu0217) in [#4587](https://github.com/PrefectHQ/fastmcp/pull/4587)
|
||||
* Advertise only supported token endpoint auth methods in OAuthProxy metadata by [@jlowin](https://github.com/jlowin) in [#4608](https://github.com/PrefectHQ/fastmcp/pull/4608)
|
||||
* Fix OAuth proxy override typing by [@zzstoatzz](https://github.com/zzstoatzz) in [#4612](https://github.com/PrefectHQ/fastmcp/pull/4612)
|
||||
* Pin burner-redis below the Windows-crashing 0.1.7 release by [@jlowin](https://github.com/jlowin) in [#4618](https://github.com/PrefectHQ/fastmcp/pull/4618)
|
||||
* fix : canonical mime type mapping from formats to remove inconsistency #4627 by [@Aman071106](https://github.com/Aman071106) in [#4628](https://github.com/PrefectHQ/fastmcp/pull/4628)
|
||||
* fix: accept callable roots handlers by [@ShuyingZhang](https://github.com/ShuyingZhang) in [#4639](https://github.com/PrefectHQ/fastmcp/pull/4639)
|
||||
* Pass the MCP conformance suite's draft and pending scenarios by [@jlowin](https://github.com/jlowin) in [#4650](https://github.com/PrefectHQ/fastmcp/pull/4650)
|
||||
* Use issuer_url for OAuth issuer identity by [@jlowin](https://github.com/jlowin) in [#4652](https://github.com/PrefectHQ/fastmcp/pull/4652)
|
||||
* Fix the ty failure blocking upgrade checks on main by [@jlowin](https://github.com/jlowin) in [#4657](https://github.com/PrefectHQ/fastmcp/pull/4657)
|
||||
* Bind CIMD assertion audience to the advertised token endpoint by [@jlowin](https://github.com/jlowin) in [#4659](https://github.com/PrefectHQ/fastmcp/pull/4659)
|
||||
* Record effective scopes on the OAuth transaction by [@jlowin](https://github.com/jlowin) in [#4670](https://github.com/PrefectHQ/fastmcp/pull/4670)
|
||||
* Copy schemas iteratively so deep nesting still compresses by [@jlowin](https://github.com/jlowin) in [#4671](https://github.com/PrefectHQ/fastmcp/pull/4671)
|
||||
* Fix OpenAPI allOf reference fields by [@hxaxd](https://github.com/hxaxd) in [#4653](https://github.com/PrefectHQ/fastmcp/pull/4653)
|
||||
* Flatten OpenAPI discriminator subtypes into request bodies by [@jlowin](https://github.com/jlowin) in [#4677](https://github.com/PrefectHQ/fastmcp/pull/4677)
|
||||
* Let maintenance releases publish without fastmcp-tasks by [@jlowin](https://github.com/jlowin) in [#4676](https://github.com/PrefectHQ/fastmcp/pull/4676)
|
||||
* Read CLI-scanned MCP config files as UTF-8 explicitly by [@jlowin](https://github.com/jlowin) in [#4690](https://github.com/PrefectHQ/fastmcp/pull/4690)
|
||||
* Late-bind app tool names so UIs survive composition by [@jlowin](https://github.com/jlowin) in [#4682](https://github.com/PrefectHQ/fastmcp/pull/4682)
|
||||
### Docs 📚
|
||||
* Docs: forward-port v3.4.4 changelog entries by [@jlowin](https://github.com/jlowin) in [#4476](https://github.com/PrefectHQ/fastmcp/pull/4476)
|
||||
* Document icon theme support by [@jlowin](https://github.com/jlowin) in [#4537](https://github.com/PrefectHQ/fastmcp/pull/4537)
|
||||
* Add missing 4.0.0 version badge to Path Security docs by [@jlowin](https://github.com/jlowin) in [#4540](https://github.com/PrefectHQ/fastmcp/pull/4540)
|
||||
* Align server component docs by [@strawgate](https://github.com/strawgate) in [#4260](https://github.com/PrefectHQ/fastmcp/pull/4260)
|
||||
* Align CLI, deployment, and config docs by [@strawgate](https://github.com/strawgate) in [#4259](https://github.com/PrefectHQ/fastmcp/pull/4259)
|
||||
* Align client, Apps, and integration docs by [@strawgate](https://github.com/strawgate) in [#4261](https://github.com/PrefectHQ/fastmcp/pull/4261)
|
||||
* Fix stale MRTR/elicitation framing in client and upgrade docs by [@jlowin](https://github.com/jlowin) in [#4551](https://github.com/PrefectHQ/fastmcp/pull/4551)
|
||||
* docs: quote pip extras install examples by [@RachGranville](https://github.com/RachGranville) in [#4568](https://github.com/PrefectHQ/fastmcp/pull/4568)
|
||||
* Document Windows CI parallelism and the subprocess_heavy marker by [@jlowin](https://github.com/jlowin) in [#4575](https://github.com/PrefectHQ/fastmcp/pull/4575)
|
||||
* Document v3->v4 removals and add upgrade-reality tests by [@jlowin](https://github.com/jlowin) in [#4585](https://github.com/PrefectHQ/fastmcp/pull/4585)
|
||||
* Archive v3 docs and publish v4 as the primary version by [@jlowin](https://github.com/jlowin) in [#4613](https://github.com/PrefectHQ/fastmcp/pull/4613)
|
||||
* Document targeted v4 prerelease installation by [@zzstoatzz](https://github.com/zzstoatzz) in [#4598](https://github.com/PrefectHQ/fastmcp/pull/4598)
|
||||
* Fix stale Mac/Windows-vs-Linux OAuth key/storage docs by [@jlowin](https://github.com/jlowin) in [#4617](https://github.com/PrefectHQ/fastmcp/pull/4617)
|
||||
* v4 docs quality pass: stale task/era claims, broken links, polish by [@jlowin](https://github.com/jlowin) in [#4619](https://github.com/PrefectHQ/fastmcp/pull/4619)
|
||||
* whats-new: add the argument completion capability by [@jlowin](https://github.com/jlowin) in [#4620](https://github.com/PrefectHQ/fastmcp/pull/4620)
|
||||
* docs: fix ProxyProvider docstring example calling nonexistent with_namespace() by [@andrew-stelmach-fleet](https://github.com/andrew-stelmach-fleet) in [#4633](https://github.com/PrefectHQ/fastmcp/pull/4633)
|
||||
* Unpublish v4 development notes; prep docs for beta 1 by [@jlowin](https://github.com/jlowin) in [#4644](https://github.com/PrefectHQ/fastmcp/pull/4644)
|
||||
* Expand the FAQ for the v4 transition by [@jlowin](https://github.com/jlowin) in [#4649](https://github.com/PrefectHQ/fastmcp/pull/4649)
|
||||
* Document the issuer_url identity change for upgraders by [@jlowin](https://github.com/jlowin) in [#4658](https://github.com/PrefectHQ/fastmcp/pull/4658)
|
||||
* Cover require_roles in the v4 highlights by [@jlowin](https://github.com/jlowin) in [#4666](https://github.com/PrefectHQ/fastmcp/pull/4666)
|
||||
* Fix FAQ: sampling/roots/elicitation legacy-mode advice, SessionProvider registration by [@jlowin](https://github.com/jlowin) in [#4672](https://github.com/PrefectHQ/fastmcp/pull/4672)
|
||||
* Audit v4 docs: fix missing version badges, fill whats-new gaps by [@jlowin](https://github.com/jlowin) in [#4668](https://github.com/PrefectHQ/fastmcp/pull/4668)
|
||||
* Docs: add v3.4.5 changelog entries to main by [@jlowin](https://github.com/jlowin) in [#4674](https://github.com/PrefectHQ/fastmcp/pull/4674)
|
||||
* Split the SDK upgrade guides by SDK version by [@jlowin](https://github.com/jlowin) in [#4684](https://github.com/PrefectHQ/fastmcp/pull/4684)
|
||||
### Dependencies 📦
|
||||
* chore(deps): bump mcp from 1.26.0 to 1.27.2 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4514](https://github.com/PrefectHQ/fastmcp/pull/4514)
|
||||
* chore(deps): bump actions/setup-node from 6 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4546](https://github.com/PrefectHQ/fastmcp/pull/4546)
|
||||
* Bump actions/upload-artifact from 4 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4640](https://github.com/PrefectHQ/fastmcp/pull/4640)
|
||||
* Bump actions/setup-python from 6 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4641](https://github.com/PrefectHQ/fastmcp/pull/4641)
|
||||
* chore(deps): bump mcp from 1.27.2 to 1.28.1 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4614](https://github.com/PrefectHQ/fastmcp/pull/4614)
|
||||
### Other Changes 🦾
|
||||
* Test: HTTP lifespan fires once per process across sessions by [@jlowin](https://github.com/jlowin) in [#4470](https://github.com/PrefectHQ/fastmcp/pull/4470)
|
||||
## New Contributors
|
||||
* @syf2211 made their first contribution in [#4502](https://github.com/PrefectHQ/fastmcp/pull/4502)
|
||||
* @earfman made their first contribution in [#4517](https://github.com/PrefectHQ/fastmcp/pull/4517)
|
||||
* @winklemad made their first contribution in [#4492](https://github.com/PrefectHQ/fastmcp/pull/4492)
|
||||
* @verdie-g made their first contribution in [#4512](https://github.com/PrefectHQ/fastmcp/pull/4512)
|
||||
* @vijaydeepsinha made their first contribution in [#4411](https://github.com/PrefectHQ/fastmcp/pull/4411)
|
||||
* @piaudonn made their first contribution in [#4267](https://github.com/PrefectHQ/fastmcp/pull/4267)
|
||||
* @szepeviktor made their first contribution in [#4498](https://github.com/PrefectHQ/fastmcp/pull/4498)
|
||||
* @endofcake made their first contribution in [#4412](https://github.com/PrefectHQ/fastmcp/pull/4412)
|
||||
* @Kludex made their first contribution in [#4564](https://github.com/PrefectHQ/fastmcp/pull/4564)
|
||||
* @RachGranville made their first contribution in [#4568](https://github.com/PrefectHQ/fastmcp/pull/4568)
|
||||
* @hxaxd made their first contribution in [#4533](https://github.com/PrefectHQ/fastmcp/pull/4533)
|
||||
* @VectorPeak made their first contribution in [#4531](https://github.com/PrefectHQ/fastmcp/pull/4531)
|
||||
* @LarryHu0217 made their first contribution in [#4587](https://github.com/PrefectHQ/fastmcp/pull/4587)
|
||||
* @andrew-stelmach-fleet made their first contribution in [#4633](https://github.com/PrefectHQ/fastmcp/pull/4633)
|
||||
* @Aman071106 made their first contribution in [#4628](https://github.com/PrefectHQ/fastmcp/pull/4628)
|
||||
* @ShuyingZhang made their first contribution in [#4639](https://github.com/PrefectHQ/fastmcp/pull/4639)
|
||||
* @Educg550 made their first contribution in [#4660](https://github.com/PrefectHQ/fastmcp/pull/4660)
|
||||
|
||||
**Full Changelog**: [v3.4.5...v4.0.0b1](https://github.com/PrefectHQ/fastmcp/compare/v3.4.5...v4.0.0b1)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.4.5" description="2026-07-27">
|
||||
|
||||
**[v3.4.5: Key Change](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.5)**
|
||||
|
|
|
|||
|
|
@ -89,10 +89,10 @@ To skip authentication entirely — useful for local development servers — pas
|
|||
fastmcp call http://localhost:8000/mcp my_tool --auth none
|
||||
```
|
||||
|
||||
You can also pass a bearer token directly:
|
||||
You can also pass a bearer token directly. Give the token value on its own; FastMCP adds the `Bearer` prefix when it builds the `Authorization` header.
|
||||
|
||||
```bash
|
||||
fastmcp list http://localhost:8000/mcp --auth "Bearer sk-..."
|
||||
fastmcp list http://localhost:8000/mcp --auth "sk-..."
|
||||
```
|
||||
|
||||
## Transport Override
|
||||
|
|
|
|||
|
|
@ -144,12 +144,12 @@ async with Client(mcp) as client:
|
|||
print(f"Capabilities: {client.server_capabilities.tools}")
|
||||
```
|
||||
|
||||
For advanced scenarios where you need precise control over when initialization happens, disable automatic initialization and call `initialize()` manually:
|
||||
For advanced scenarios where you need precise control over when initialization happens, disable automatic initialization and call `initialize()` manually. `initialize()` is a handshake-era operation, so pin the connection with `mode="legacy"`: the modern protocol has no `initialize` round trip, and calling it on a modern connection raises.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
client = Client("my_mcp_server.py", auto_initialize=False)
|
||||
client = Client("my_mcp_server.py", auto_initialize=False, mode="legacy")
|
||||
|
||||
async with client:
|
||||
# Connection established, but not initialized yet
|
||||
|
|
@ -219,7 +219,7 @@ The SSE transport is legacy-only — it cannot carry the sessionless modern era
|
|||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
The client can cache the results of `list_tools`, `list_resources`, `list_prompts`, and `read_resource` so that repeated calls avoid a network round-trip. Caching is opt-in and honors the server's own cache hints, so it only takes effect against modern-era servers that advertise them — a cache is inert on a legacy connection.
|
||||
The client can cache the results of `list_tools`, `list_resources`, and `list_prompts` so that repeated calls avoid a network round-trip. Caching is opt-in and honors the server's own cache hints, so it only takes effect against modern-era servers that advertise them — a cache is inert on a legacy connection.
|
||||
|
||||
Enable the default in-memory cache by passing `cache=True`. It respects the `ttlMs` and `cacheScope` hints the server attaches to each response.
|
||||
|
||||
|
|
@ -243,7 +243,7 @@ config = CacheConfig(target_id="weather-api", default_ttl_ms=60_000)
|
|||
client = Client("https://example.com/mcp", mode="auto", cache=config)
|
||||
```
|
||||
|
||||
The high-level `list_tools`, `list_resources`, `list_prompts`, and `read_resource` methods always use the cache when one is configured. To override the behavior for a single call, use the lower-level `*_mcp` variants, which accept a `cache_mode` argument: `"use"` (the default) serves and stores, `"refresh"` stores a fresh result without serving a cached one, and `"bypass"` skips the cache entirely.
|
||||
The high-level `list_tools`, `list_resources`, and `list_prompts` methods always use the cache when one is configured. To override the behavior for a single call, use the lower-level `list_tools_mcp`, `list_resources_mcp`, `list_resource_templates_mcp`, and `list_prompts_mcp` variants, which accept a `cache_mode` argument: `"use"` (the default) serves and stores, `"refresh"` stores a fresh result without serving a cached one, and `"bypass"` skips the cache entirely.
|
||||
|
||||
```python
|
||||
async with client:
|
||||
|
|
|
|||
|
|
@ -28,7 +28,16 @@ logging.basicConfig(
|
|||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
LOGGING_LEVEL_MAP = logging.getLevelNamesMapping()
|
||||
LOGGING_LEVEL_MAP = {
|
||||
"DEBUG": logging.DEBUG,
|
||||
"INFO": logging.INFO,
|
||||
"NOTICE": logging.INFO,
|
||||
"WARNING": logging.WARNING,
|
||||
"ERROR": logging.ERROR,
|
||||
"CRITICAL": logging.CRITICAL,
|
||||
"ALERT": logging.CRITICAL,
|
||||
"EMERGENCY": logging.CRITICAL,
|
||||
}
|
||||
|
||||
async def log_handler(message: LogMessage):
|
||||
"""Forward MCP server logs to Python's logging system."""
|
||||
|
|
|
|||
|
|
@ -47,23 +47,23 @@ For fine-grained targeting, subclass `MessageHandler` to use specific hooks:
|
|||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.messages import MessageHandler
|
||||
import mcp.types as mcp_types
|
||||
import mcp.types
|
||||
|
||||
class MyMessageHandler(MessageHandler):
|
||||
async def on_tool_list_changed(
|
||||
self, notification: mcp_types.ToolListChangedNotification
|
||||
self, notification: mcp.types.ToolListChangedNotification
|
||||
) -> None:
|
||||
"""Handle tool list changes."""
|
||||
print("Tool list changed - refreshing available tools")
|
||||
|
||||
async def on_resource_list_changed(
|
||||
self, notification: mcp_types.ResourceListChangedNotification
|
||||
self, notification: mcp.types.ResourceListChangedNotification
|
||||
) -> None:
|
||||
"""Handle resource list changes."""
|
||||
print("Resource list changed")
|
||||
|
||||
async def on_prompt_list_changed(
|
||||
self, notification: mcp_types.PromptListChangedNotification
|
||||
self, notification: mcp.types.PromptListChangedNotification
|
||||
) -> None:
|
||||
"""Handle prompt list changes."""
|
||||
print("Prompt list changed")
|
||||
|
|
@ -78,7 +78,7 @@ client = Client(
|
|||
|
||||
```python
|
||||
from fastmcp.client.messages import MessageHandler
|
||||
import mcp.types as mcp_types
|
||||
import mcp.types
|
||||
|
||||
class MyMessageHandler(MessageHandler):
|
||||
async def on_message(self, message) -> None:
|
||||
|
|
@ -86,49 +86,49 @@ class MyMessageHandler(MessageHandler):
|
|||
pass
|
||||
|
||||
async def on_notification(
|
||||
self, notification: mcp_types.ServerNotification
|
||||
self, notification: mcp.types.ServerNotification
|
||||
) -> None:
|
||||
"""Called for notifications (fire-and-forget)."""
|
||||
pass
|
||||
|
||||
async def on_tool_list_changed(
|
||||
self, notification: mcp_types.ToolListChangedNotification
|
||||
self, notification: mcp.types.ToolListChangedNotification
|
||||
) -> None:
|
||||
"""Called when the server's tool list changes."""
|
||||
pass
|
||||
|
||||
async def on_resource_list_changed(
|
||||
self, notification: mcp_types.ResourceListChangedNotification
|
||||
self, notification: mcp.types.ResourceListChangedNotification
|
||||
) -> None:
|
||||
"""Called when the server's resource list changes."""
|
||||
pass
|
||||
|
||||
async def on_prompt_list_changed(
|
||||
self, notification: mcp_types.PromptListChangedNotification
|
||||
self, notification: mcp.types.PromptListChangedNotification
|
||||
) -> None:
|
||||
"""Called when the server's prompt list changes."""
|
||||
pass
|
||||
|
||||
async def on_progress(
|
||||
self, notification: mcp_types.ProgressNotification
|
||||
self, notification: mcp.types.ProgressNotification
|
||||
) -> None:
|
||||
"""Called for progress updates during long-running operations."""
|
||||
pass
|
||||
|
||||
async def on_resource_updated(
|
||||
self, notification: mcp_types.ResourceUpdatedNotification
|
||||
self, notification: mcp.types.ResourceUpdatedNotification
|
||||
) -> None:
|
||||
"""Called when a specific resource changes."""
|
||||
pass
|
||||
|
||||
async def on_cancelled(
|
||||
self, notification: mcp_types.CancelledNotification
|
||||
self, notification: mcp.types.CancelledNotification
|
||||
) -> None:
|
||||
"""Called when a request is cancelled."""
|
||||
pass
|
||||
|
||||
async def on_logging_message(
|
||||
self, notification: mcp_types.LoggingMessageNotification
|
||||
self, notification: mcp.types.LoggingMessageNotification
|
||||
) -> None:
|
||||
"""Called for log messages from the server."""
|
||||
pass
|
||||
|
|
@ -141,14 +141,14 @@ A practical example of maintaining a tool cache that refreshes when tools change
|
|||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.messages import MessageHandler
|
||||
import mcp.types as mcp_types
|
||||
import mcp.types
|
||||
|
||||
class ToolCacheHandler(MessageHandler):
|
||||
def __init__(self):
|
||||
self.cached_tools = []
|
||||
|
||||
async def on_tool_list_changed(
|
||||
self, notification: mcp_types.ToolListChangedNotification
|
||||
self, notification: mcp.types.ToolListChangedNotification
|
||||
) -> None:
|
||||
"""Clear tool cache when tools change."""
|
||||
print("Tools changed - clearing cache")
|
||||
|
|
|
|||
|
|
@ -58,18 +58,25 @@ async with client:
|
|||
|
||||
Binary resources include images, PDFs, and other non-text data:
|
||||
|
||||
Binary resources arrive as `BlobResourceContents`, whose `blob` field is a base64 **string**, so decode it before writing bytes to disk:
|
||||
|
||||
```python
|
||||
import base64
|
||||
|
||||
from mcp_types import BlobResourceContents
|
||||
|
||||
async with client:
|
||||
content = await client.read_resource("resource://images/logo.png")
|
||||
|
||||
for item in content:
|
||||
if hasattr(item, 'blob'):
|
||||
print(f"Binary content: {len(item.blob)} bytes")
|
||||
if isinstance(item, BlobResourceContents):
|
||||
data = base64.b64decode(item.blob)
|
||||
print(f"Binary content: {len(data)} bytes")
|
||||
print(f"MIME type: {item.mime_type}")
|
||||
|
||||
# Save to file
|
||||
with open("downloaded_logo.png", "wb") as f:
|
||||
f.write(item.blob)
|
||||
f.write(data)
|
||||
```
|
||||
|
||||
## Multi-Server Clients
|
||||
|
|
|
|||
57
docs/css/language-dropdown.css
Normal file
57
docs/css/language-dropdown.css
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/* Language dropdown: injected by language-dropdown.js into the sidebar
|
||||
footer, to the right of Mintlify's theme selector. Mirrors the almond
|
||||
theme pill's exact metrics (lg:h-7 desktop / 2.375rem mobile, rounded-full,
|
||||
border-gray-200/70, dark:border-white/[0.07]) so the two controls read as
|
||||
one family. */
|
||||
#language-switch {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#language-switch select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: transparent;
|
||||
border: 1px solid rgb(229 231 235 / 0.7);
|
||||
border-radius: 9999px;
|
||||
color: rgb(107 114 128);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
height: 2.375rem;
|
||||
padding: 0 1.375rem 0 0.75rem;
|
||||
/* Chevron, drawn in the same gray as the label text. */
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
background-size: 0.7rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
#language-switch select {
|
||||
height: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
#language-switch select:hover {
|
||||
color: rgb(75 85 99);
|
||||
border-color: rgb(229 231 235);
|
||||
}
|
||||
|
||||
#language-switch select:focus-visible {
|
||||
outline: 2px solid rgb(45 0 247 / 0.4);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.dark #language-switch select {
|
||||
border-color: rgb(255 255 255 / 0.07);
|
||||
color: rgb(156 163 175);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.dark #language-switch select:hover {
|
||||
color: rgb(209 213 219);
|
||||
border-color: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ If you're mounting an authenticated server under a path prefix, see [Mounting Au
|
|||
|
||||
### Host and Origin Protection
|
||||
|
||||
FastMCP can validate `Host` and browser `Origin` headers for Streamable HTTP requests before they reach MCP session handling. This request guard protects localhost-bound servers from DNS rebinding attacks, and it remains opt-in in FastMCP 3.x to preserve compatibility with existing ASGI, serverless, and reverse-proxy deployments.
|
||||
FastMCP can validate `Host` and browser `Origin` headers for Streamable HTTP requests before they reach MCP session handling. This request guard protects localhost-bound servers from DNS rebinding attacks, and it stays opt-in to preserve compatibility with existing ASGI, serverless, and reverse-proxy deployments.
|
||||
|
||||
Think of this as a request guard rather than CORS middleware. It decides whether a request can reach MCP session handling. CORS remains a separate browser response-header policy; configure CORS middleware separately when browser JavaScript must read cross-origin responses.
|
||||
|
||||
|
|
@ -188,7 +188,7 @@ def query_tenant(
|
|||
A gateway can now route on `Mcp-Param-Tenant` — for example, pinning each tenant to a dedicated backend — without inspecting the request body. The annotation is only permitted on `string`, `integer`, and `boolean` parameters. These headers advertise routing intent; treat them as untrusted hints, since the server still validates the request body as the source of truth.
|
||||
|
||||
<Tip>
|
||||
When you put a FastMCP [proxy](/servers/proxy) in front of another server, the proxy re-advertises each backend tool's `x-mcp-header` annotation, so routing headers work across the proxy hop as well. The headers themselves are regenerated per hop rather than forwarded verbatim, since each describes a single HTTP request.
|
||||
When you put a FastMCP [proxy](/servers/providers/proxy) in front of another server, the proxy re-advertises each backend tool's `x-mcp-header` annotation, so routing headers work across the proxy hop as well. The headers themselves are regenerated per hop rather than forwarded verbatim, since each describes a single HTTP request.
|
||||
</Tip>
|
||||
|
||||
### Health Checks
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ description: The MCP platform from the FastMCP team
|
|||
icon: cloud
|
||||
---
|
||||
|
||||
[Prefect Horizon](https://www.prefect.io/horizon) is a platform for deploying and managing MCP servers. Built by the FastMCP team at [Prefect](https://www.prefect.io), Horizon provides managed hosting, authentication, access control, and a registry of MCP capabilities.
|
||||
[Prefect Horizon](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_horizon&utm_content=guide_intro) is a platform for deploying and managing MCP servers. Built by the FastMCP team at [Prefect](https://www.prefect.io), Horizon provides managed hosting, authentication, access control, and a registry of MCP capabilities.
|
||||
|
||||
Horizon includes a **free personal tier for FastMCP users**, making it the fastest way to get a secure, production-ready server URL with built-in OAuth authentication.
|
||||
|
||||
|
|
|
|||
|
|
@ -39,30 +39,33 @@ The `fastmcp.json` configuration answers three fundamental questions about your
|
|||
|
||||
This conceptual model helps you understand the purpose of each configuration section and organize your settings effectively. The configuration file maps directly to these three concerns:
|
||||
|
||||
`source` is the *where*, `environment` the *what*, and `deployment` the *how*:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json",
|
||||
"source": {
|
||||
// WHERE: Location of your server code
|
||||
"type": "filesystem", // Optional, defaults to "filesystem"
|
||||
"type": "filesystem",
|
||||
"path": "server.py",
|
||||
"entrypoint": "mcp"
|
||||
},
|
||||
"environment": {
|
||||
// WHAT: Environment setup and dependencies
|
||||
"type": "uv", // Optional, defaults to "uv"
|
||||
"type": "uv",
|
||||
"python": ">=3.10",
|
||||
"dependencies": ["pandas", "numpy"]
|
||||
},
|
||||
"deployment": {
|
||||
// HOW: Runtime configuration
|
||||
"transport": "stdio",
|
||||
"log_level": "INFO"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only the `source` field is required. The `environment` and `deployment` sections are optional and provide additional configuration when needed.
|
||||
Only the `source` field is required. The `environment` and `deployment` sections are optional and provide additional configuration when needed. Both `type` fields shown above are optional too, defaulting to `"filesystem"` and `"uv"` respectively.
|
||||
|
||||
<Warning>
|
||||
`fastmcp.json` is parsed as strict JSON, so it accepts no comments or trailing commas.
|
||||
</Warning>
|
||||
|
||||
### JSON Schema Support
|
||||
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@ We expect this exemption to last through at least the 2.12.x and 2.13.x release
|
|||
|
||||
Pin to exact versions:
|
||||
```
|
||||
fastmcp==2.11.0 # Good
|
||||
fastmcp>=2.11.0 # Bad - will install breaking changes
|
||||
fastmcp==4.0.0 # Good
|
||||
fastmcp>=4.0.0 # Bad - will install breaking changes
|
||||
```
|
||||
|
||||
## Creating Releases
|
||||
|
|
@ -65,7 +65,7 @@ Our release process is intentionally simple:
|
|||
2. Generate release notes automatically, and curate or add additional editorial information as needed
|
||||
3. GitHub releases automatically trigger PyPI deployments
|
||||
|
||||
Current-major releases target `main`. Maintenance releases target their release branch, such as `release/3.x` for 3.x patches and `release/2.x` for 2.x patches. Stable releases from `main` update the `published-docs` branch after PyPI publishing succeeds; maintenance releases publish packages and GitHub release notes without repointing the live docs branch.
|
||||
Current-major releases target `main`. Maintenance releases target their release branch, such as `release/3.x` for 3.x patches and `release/2.x` for 2.x patches. Stable releases from `main` open a PR that syncs the release commit to `published-docs` after PyPI publishing succeeds; merging that PR publishes the live docs. Prereleases skip the automatic PR and use the same PR-based sync when their docs are ready to publish. Maintenance releases publish packages and GitHub release notes without repointing the live docs branch.
|
||||
|
||||
This automation lets maintainers focus on code quality rather than release mechanics.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
"dark": "#475569",
|
||||
"light": "#1e3a5f"
|
||||
},
|
||||
"content": "FastMCP 4 is in beta — you're reading the v4 docs. [What's new](/getting-started/whats-new) · [FastMCP 3 docs](/v3/getting-started/welcome)"
|
||||
"content": "FastMCP 4 is in beta — build stateful applications on sessionless MCP. [See what's new](/getting-started/whats-new)."
|
||||
},
|
||||
"colors": {
|
||||
"dark": "#f72585",
|
||||
|
|
@ -67,7 +67,7 @@
|
|||
"label": ""
|
||||
},
|
||||
{
|
||||
"href": "https://prefect.io/horizon",
|
||||
"href": "https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_horizon&utm_content=header",
|
||||
"icon": "cloud",
|
||||
"label": "Prefect Horizon"
|
||||
}
|
||||
|
|
@ -162,6 +162,7 @@
|
|||
"servers/lifespan",
|
||||
"servers/storage-backends",
|
||||
"servers/sessions",
|
||||
"servers/extensions",
|
||||
"servers/tasks",
|
||||
"servers/versioning"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,15 +7,19 @@ icon: arrow-down-to-line
|
|||
|
||||
We recommend using [uv](https://docs.astral.sh/uv/getting-started/installation/) to install and manage FastMCP.
|
||||
|
||||
```bash
|
||||
uv add fastmcp
|
||||
```
|
||||
|
||||
Or with pip:
|
||||
|
||||
```bash
|
||||
pip install fastmcp
|
||||
```
|
||||
|
||||
Or with uv:
|
||||
|
||||
```bash
|
||||
uv add fastmcp
|
||||
```
|
||||
<Note>
|
||||
**FastMCP 4 is in prerelease.** The commands above install the latest stable release, which is still 3.x. To get v4, pin the beta explicitly with `pip install "fastmcp==4.0.0b1"`, or see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for the uv constraint you'll need.
|
||||
</Note>
|
||||
|
||||
### Optional Dependencies
|
||||
|
||||
|
|
@ -40,8 +44,8 @@ You should see output like the following:
|
|||
```bash
|
||||
$ fastmcp version
|
||||
|
||||
FastMCP version: 3.0.0
|
||||
MCP version: 1.25.0
|
||||
FastMCP version: 4.0.0b1
|
||||
MCP version: 2.0.0
|
||||
Python version: 3.12.2
|
||||
Platform: macOS-15.3.1-arm64-arm-64bit
|
||||
FastMCP root path: ~/Developer/fastmcp
|
||||
|
|
@ -62,6 +66,10 @@ Alternatively, wait for the stable v5 release. See [this issue](https://github.c
|
|||
</Info>
|
||||
## Upgrading
|
||||
|
||||
### From FastMCP 3.0
|
||||
|
||||
Most FastMCP 3 servers run on 4 without changes. See [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) for the breaks that do exist, and [What's New](/getting-started/whats-new) for what the new version adds.
|
||||
|
||||
### From FastMCP 2.0
|
||||
|
||||
See the [Upgrade Guide](/getting-started/upgrading/from-fastmcp-2) for a complete list of breaking changes and migration steps.
|
||||
|
|
@ -107,16 +115,12 @@ FastMCP follows semantic versioning with pragmatic adaptations for the rapidly e
|
|||
|
||||
For production use, always pin to exact versions:
|
||||
```
|
||||
fastmcp==3.0.0 # Good
|
||||
fastmcp>=3.0.0 # Bad - may install breaking changes
|
||||
fastmcp==4.0.0b1 # Good - an exact version
|
||||
fastmcp>=4.0.0 # Bad - may install breaking changes
|
||||
```
|
||||
|
||||
See the full [versioning and release policy](/development/releases#versioning-policy) for details on our public API, deprecation practices, and breaking change philosophy.
|
||||
|
||||
## Contributing to FastMCP
|
||||
|
||||
Interested in contributing to FastMCP? See the [Contributing Guide](/development/contributing) for details on:
|
||||
- Setting up your development environment
|
||||
- Running tests and pre-commit hooks
|
||||
- Submitting issues and pull requests
|
||||
- Code standards and review process
|
||||
The [Contributing Guide](/development/contributing) covers setting up a development environment, running the test suite and pre-commit hooks, and the standards we hold contributed code to.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ title: Quickstart
|
|||
icon: rocket-launch
|
||||
---
|
||||
|
||||
Welcome! This guide will help you quickly set up FastMCP, run your first MCP server, give it a visual UI, and deploy it to Prefect Horizon.
|
||||
This guide builds a working MCP server from scratch: a tool, a way to run it, a client that calls it, and a visual UI for the result. It ends with the server deployed and reachable over the internet.
|
||||
|
||||
If you haven't already installed FastMCP, follow the [installation instructions](/getting-started/installation).
|
||||
|
||||
|
|
@ -112,10 +112,7 @@ async def call_tool(name: str):
|
|||
asyncio.run(call_tool("Ford"))
|
||||
```
|
||||
|
||||
Note that:
|
||||
- FastMCP clients are asynchronous, so we need to use `asyncio.run` to run the client
|
||||
- We must enter a client context (`async with client:`) before using the client
|
||||
- You can make multiple client calls within the same context
|
||||
FastMCP clients are asynchronous, so the call goes through `asyncio.run`. Entering the client context with `async with client:` is what opens the connection, and it stays open for as many calls as you want to make inside the block.
|
||||
|
||||
## Give Your Tool a UI
|
||||
|
||||
|
|
@ -145,9 +142,11 @@ def greet(name: str) -> PrefabApp:
|
|||
|
||||
You can preview app tools locally with `fastmcp dev apps my_server.py` — no MCP host required. See the [Apps overview](/apps/overview) for the full guide, including state management, forms, charts, and server-connected interactivity.
|
||||
|
||||
## Deploy to Prefect Horizon
|
||||
## Deploy Your Server
|
||||
|
||||
[Prefect Horizon](https://horizon.prefect.io?utm_source=gofastmcp&utm_medium=docs) is the enterprise MCP platform built by the FastMCP team at [Prefect](https://www.prefect.io). It provides managed hosting, authentication, access control, and observability for MCP servers.
|
||||
FastMCP HTTP servers run anywhere you can host a Python application. The [HTTP deployment guide](/deployment/http) covers the transport settings and security boundaries for self-managed infrastructure.
|
||||
|
||||
For a managed deployment, [Prefect Horizon](https://horizon.prefect.io?utm_source=gofastmcp&utm_medium=docs) is the enterprise MCP platform built by the FastMCP team at [Prefect](https://www.prefect.io). It provides hosting, authentication, access control, and observability for MCP servers.
|
||||
|
||||
<Info>
|
||||
Horizon is **free for personal projects** and offers enterprise governance for teams.
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ pip install --upgrade fastmcp
|
|||
uv add --upgrade fastmcp
|
||||
```
|
||||
|
||||
If you pin versions in a requirements file or `pyproject.toml`, update your pin to `fastmcp>=3.0.0,<4`.
|
||||
If you pin versions in a requirements file or `pyproject.toml`, update your pin to `fastmcp>=3.0.0,<4`. Going on to FastMCP 4 is a second hop: finish this page, then work through [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) and move the pin to `fastmcp>=4.0.0` at the end of it.
|
||||
|
||||
<Info>
|
||||
**New repository home.** As part of the v3 release, FastMCP's GitHub repository has moved from `jlowin/fastmcp` to [`PrefectHQ/fastmcp`](https://github.com/PrefectHQ/fastmcp) under [Prefect](https://prefect.io)'s stewardship. GitHub automatically redirects existing clones and bookmarks, so nothing breaks — but you can update your local remote whenever convenient:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ description: What changes when you upgrade to FastMCP 4, which builds on the MCP
|
|||
icon: up
|
||||
---
|
||||
|
||||
FastMCP 4 builds on the MCP Python SDK v2, and that is the source of every change in this guide. The SDK v2 makes two sweeping changes to the protocol layer: it moves the protocol types into a standalone `mcp_types` package (still importable as `mcp.types`), and it renames every protocol field from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, `isError` → `is_error`, and so on).
|
||||
FastMCP 4 builds on the MCP Python SDK v2, and that is the source of every change in this guide. The SDK v2 makes two sweeping changes to the protocol layer: it moves the protocol types into a standalone `mcp_types` package (still importable as `mcp.types`), and it renames every model field from camelCase to snake_case in Python (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, `isError` → `is_error`, and so on). The wire format does not change: the models keep their camelCase aliases and serialize under them, so this renames the attributes your code reads, not the JSON on the connection.
|
||||
|
||||
FastMCP 4 absorbs almost all of this for you. Field access is bridged so your existing reads keep working, and the imports you were taught have a stable home in FastMCP itself. What the SDK cannot hide is the protocol's own direction: the new sessionless era removes the server's ability to call back into a client mid-request, and background tasks moved out of the core spec into an extension. Those two shape the changes a working server is most likely to feel.
|
||||
|
||||
|
|
@ -209,7 +209,7 @@ transport = StreamableHttpTransport(
|
|||
)
|
||||
```
|
||||
|
||||
The `client` you pass to `FastMCP.from_openapi(client=...)` (and `OpenAPIProvider(client=...)`) is now type-hinted `httpx2.AsyncClient`. FastMCP does not gate on the type, so an existing `httpx.AsyncClient` keeps working at runtime via duck-typing this release — but switching it to `httpx2.AsyncClient` clears the type hint and is the supported path going forward. HTTP made inside your own tools is entirely yours and is unaffected either way.
|
||||
The `client` you pass to `FastMCP.from_openapi(client=...)` (and `OpenAPIProvider(client=...)`) should now be an `httpx2.AsyncClient`. Existing `httpx.AsyncClient` instances remain temporarily accepted via duck typing, but emit a `FastMCPDeprecationWarning` and will be rejected in a future release. HTTP made inside your own tools is entirely yours and is unaffected.
|
||||
|
||||
**The subtlest break is exception handlers, and no type checker will catch it.** `httpx` very likely remains installed in your environment (the Anthropic, OpenAI, and Google SDKs all depend on it), so code that catches old-httpx exceptions around FastMCP calls still imports and still type-checks — it just never matches, because FastMCP now raises `httpx2` exceptions. The handler silently becomes dead code:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +1,11 @@
|
|||
---
|
||||
title: "Welcome to FastMCP"
|
||||
title: "FastMCP: The Framework for MCP"
|
||||
sidebarTitle: "Welcome!"
|
||||
description: The fast, Pythonic way to build MCP servers, clients, and applications.
|
||||
description: FastMCP is the standard framework for building Model Context Protocol (MCP) servers, clients, and interactive applications.
|
||||
icon: hand-wave
|
||||
mode: center
|
||||
---
|
||||
{/* <img
|
||||
src="/assets/brand/f-watercolor-waves-4.png"
|
||||
|
||||
alt="'F' logo on a watercolor background"
|
||||
noZoom
|
||||
className="rounded-2xl block dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/assets/brand/f-watercolor-waves-4-dark.png"
|
||||
alt="'F' logo on a watercolor background"
|
||||
noZoom
|
||||
className="rounded-2xl hidden dark:block"
|
||||
/>
|
||||
|
||||
|
||||
*/}
|
||||
<video
|
||||
autoPlay
|
||||
muted
|
||||
|
|
@ -38,97 +23,112 @@ mode: center
|
|||
src="/assets/brand/f-watercolor-waves-4-dark-animated.mp4"
|
||||
></video>
|
||||
|
||||
**FastMCP is a full framework for building [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) applications.** It gives you one coherent API for servers, clients, and interactive apps. Use it to expose Python functions as MCP tools, connect to local or remote MCP servers, and return interactive interfaces directly from your tools. FastMCP manages schema generation, validation, transport, authentication, and protocol compatibility around your application code.
|
||||
|
||||
**FastMCP is the standard framework for building MCP applications.** The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP gives you everything you need to go from prototype to production — build servers that expose capabilities, connect clients to any MCP service, and give your tools interactive UIs:
|
||||
A FastMCP server starts with ordinary Python:
|
||||
|
||||
```python {1}
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Demo 🚀")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two numbers"""
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
```
|
||||
|
||||
## Move fast and make things
|
||||
|
||||
## Move Fast and Make Things
|
||||
An effective MCP application needs more than a function registry. Models need accurate schemas, callers need validated results, clients need compatible transports, and production servers need authentication and predictable lifecycle management.
|
||||
|
||||
The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) lets you give agents access to your tools and data. But building an effective MCP application is harder than it looks.
|
||||
FastMCP treats those as framework responsibilities. Declare a Python function and FastMCP derives its schema, validates its inputs and outputs, and exposes it through MCP. Connect a client to a URL and FastMCP handles protocol negotiation, authentication, and connection lifecycle. Your application remains ordinary Python while FastMCP keeps the MCP boundary correct.
|
||||
|
||||
FastMCP handles all of it. Declare a tool with a Python function, and the schema, validation, and documentation are generated automatically. Connect to a server with a URL, and transport negotiation, authentication, and protocol lifecycle are managed for you. You focus on your logic, and the MCP part just works: **with FastMCP, best practices are built in.**
|
||||
**That's why FastMCP is the standard framework for working with MCP.** FastMCP created the high-level Python API incorporated into the official MCP Python SDK in 2024. The actively maintained standalone project is now downloaded more than a million times a day, and some version of FastMCP powers 70% of MCP servers across all languages.
|
||||
|
||||
**That's why FastMCP is the standard framework for working with MCP.** FastMCP 1.0 was incorporated into the official MCP Python SDK in 2024. Today, the actively maintained standalone project is downloaded a million times a day, and some version of FastMCP powers 70% of MCP servers across all languages.
|
||||
## Servers, clients, and apps
|
||||
|
||||
FastMCP has three pillars:
|
||||
FastMCP covers the full MCP application lifecycle through three complementary pillars:
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Servers" img="/assets/images/servers-card.png" href="/servers/server">
|
||||
Expose tools, resources, and prompts to LLMs.
|
||||
Expose Python functions, data, and instructions as MCP tools, resources, and prompts.
|
||||
</Card>
|
||||
<Card title="Apps" img="/assets/images/apps-card.png" href="/apps/overview">
|
||||
Give your tools interactive UIs rendered directly in the conversation.
|
||||
Give MCP tools interactive user interfaces rendered directly in the conversation.
|
||||
</Card>
|
||||
<Card title="Clients" img="/assets/images/clients-card.png" href="/clients/client">
|
||||
Connect to any MCP server — local or remote, programmatic or CLI.
|
||||
Connect to any MCP server through Python, the command line, or another MCP application.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
**[Servers](/servers/server)** wrap your Python functions into MCP-compliant tools, resources, and prompts. **[Clients](/clients/client)** connect to any server with full protocol support. And **[Apps](/apps/overview)** give your tools interactive UIs rendered directly in the conversation.
|
||||
**[Servers](/servers/server)** turn your application logic into MCP capabilities with generated schemas and validation. **[Clients](/clients/client)** connect to local or remote MCP servers with full protocol support. **[Apps](/apps/overview)** let tools return forms, tables, charts, and other interactive interfaces alongside ordinary MCP results.
|
||||
|
||||
Ready to build? Start with the [installation guide](/getting-started/installation) or jump straight to the [quickstart](/getting-started/quickstart).
|
||||
The three pillars share one model: FastMCP owns the protocol machinery while your code defines what the application does.
|
||||
|
||||
**Building in TypeScript?** [FastMCP for TypeScript](https://github.com/PrefectHQ/fastmcp-ts) is the official counterpart, built and maintained by the same team. Its servers, clients, and apps follow the same concepts, so what you learn here carries over.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Install FastMCP" icon="download" href="/getting-started/installation">
|
||||
Add FastMCP to your project with `uv add fastmcp`, verify the package, and find the right upgrade guide.
|
||||
</Card>
|
||||
<Card title="Build your first server" icon="rocket-launch" href="/getting-started/quickstart">
|
||||
Create a tool, run its server, call it from a client, and add an interactive UI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
FastMCP is made with 💙 by [Prefect](https://www.prefect.io/).
|
||||
|
||||
## Run FastMCP in production with Horizon
|
||||
<Tip>
|
||||
**This documentation reflects FastMCP's `main` branch**, so it may describe features that have not reached a stable release. Version badges identify when features were introduced.
|
||||
</Tip>
|
||||
|
||||
FastMCP is the standard way to build MCP servers. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_welcome&utm_content=welcome_body)** is the enterprise MCP gateway for running them safely.
|
||||
## Scale MCP with Horizon
|
||||
|
||||
Built by the FastMCP team, Horizon packages the best practices we've learned shipping the world's most popular MCP framework.
|
||||
FastMCP handles the MCP application layer. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_welcome&utm_content=welcome_body)** is the enterprise MCP gateway for scaling servers and tools across teams, with centralized governance over how they are deployed, discovered, secured, and used.
|
||||
|
||||
Deploy FastMCP servers from GitHub with branch previews and instant rollback. Create a private registry of every MCP your company uses. Secure access with SSO and tool-level RBAC. Get audit logs, observability, and governance across your MCP stack. Remix approved tools into purpose-built endpoints for teams and agents.
|
||||
Horizon applies the operational patterns developed while maintaining FastMCP: deploy servers from GitHub with branch previews and instant rollback, organize them in a private registry, protect access with SSO and tool-level RBAC, and observe activity through audit logs and telemetry.
|
||||
|
||||
Horizon can also combine approved tools into purpose-built MCP endpoints for different teams and agents, while keeping access policy and governance centralized.
|
||||
|
||||
Start with FastMCP. [Scale with Horizon →](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_welcome&utm_content=welcome_cta)
|
||||
|
||||
<Tip>
|
||||
**This documentation reflects FastMCP's `main` branch**, meaning it always reflects the latest development version. Features are generally marked with version badges (e.g. `New in version: 3.0.0`) to indicate when they were introduced. Note that this may include features that are not yet released.
|
||||
</Tip>
|
||||
## LLM-friendly docs
|
||||
|
||||
## LLM-Friendly Docs
|
||||
FastMCP documentation is designed for developers and coding agents. Every page is available as Markdown, the complete documentation is published in `llms.txt` formats, and the documentation itself is exposed through an MCP server.
|
||||
|
||||
The FastMCP documentation is available in multiple LLM-friendly formats:
|
||||
### MCP server
|
||||
|
||||
### MCP Server
|
||||
|
||||
The FastMCP docs are accessible via MCP! The server URL is `https://gofastmcp.com/mcp`.
|
||||
|
||||
In fact, you can use FastMCP to search the FastMCP docs:
|
||||
Point any MCP-compatible agent at `https://gofastmcp.com/mcp` to let it search the documentation as it works. You can also connect with FastMCP's Python client directly:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
from fastmcp import Client
|
||||
|
||||
async def main():
|
||||
|
||||
async def main() -> None:
|
||||
async with Client("https://gofastmcp.com/mcp") as client:
|
||||
result = await client.call_tool(
|
||||
name="search_fast_mcp",
|
||||
arguments={"query": "deploy a FastMCP server"}
|
||||
arguments={"query": "deploy a FastMCP server"},
|
||||
)
|
||||
print(result)
|
||||
print(result)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Text Formats
|
||||
### Markdown formats
|
||||
|
||||
The docs are also available in [llms.txt format](https://llmstxt.org/):
|
||||
- [llms.txt](https://gofastmcp.com/llms.txt) - A sitemap listing all documentation pages
|
||||
- [llms-full.txt](https://gofastmcp.com/llms-full.txt) - The entire documentation in one file (may exceed context windows)
|
||||
The documentation is also available in [`llms.txt`](https://llmstxt.org/) formats:
|
||||
|
||||
Any page can be accessed as markdown by appending `.md` to the URL. For example, this page becomes `https://gofastmcp.com/getting-started/welcome.md`.
|
||||
- [`llms.txt`](https://gofastmcp.com/llms.txt) lists every documentation page.
|
||||
- [`llms-full.txt`](https://gofastmcp.com/llms-full.txt) contains the complete documentation in one file and may exceed some context windows.
|
||||
|
||||
You can also copy any page as markdown by pressing "Cmd+C" (or "Ctrl+C" on Windows) on your keyboard.
|
||||
Append `.md` to any documentation URL to retrieve that page as Markdown. For example, this page is available at `https://gofastmcp.com/getting-started/welcome.md`. You can also copy the current page as Markdown by pressing `Cmd+C` or `Ctrl+C`.
|
||||
|
|
|
|||
|
|
@ -1,51 +1,168 @@
|
|||
---
|
||||
title: "What's New in FastMCP 4"
|
||||
sidebarTitle: "What's New"
|
||||
description: The capabilities that define FastMCP 4 — a rebuilt engine, a new protocol era, and a stateless protocol made practical.
|
||||
description: FastMCP 4 makes stateful MCP applications work on the sessionless protocol while one server serves every protocol era.
|
||||
icon: sparkles
|
||||
---
|
||||
|
||||
FastMCP 4 is a major version because its engine changed. The framework is now built on the MCP Python SDK v2, a ground-up rebuild of the protocol layer, and on that foundation it adds a new protocol era, first-class extensions, stateless state, enterprise identity, and more. Most FastMCP 3 servers run on it untouched — the major version signals how much moved underneath, and what that movement unlocks.
|
||||
FastMCP 4 makes stateful MCP applications work on MCP's sessionless protocol. Tools can ask follow-up questions across requests, preserve authenticated user state, and move long-running work into background tasks without sticky sessions or a continuously connected client.
|
||||
|
||||
The protocol changed completely underneath those APIs. Your application usually does not: one FastMCP server negotiates both protocol eras per connection, and most FastMCP 3 servers upgrade unchanged.
|
||||
|
||||
That is the theme of version 4: stateless transport without stateless application code. The release also makes protocol extensions a first-class surface, adds enterprise identity for agents acting on behalf of users, and strengthens production defaults across caching, routing, and security.
|
||||
|
||||
<Note>
|
||||
FastMCP 4 is in **beta**. Pin an exact version and expect sharp edges. See [Install the v4 prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
|
||||
</Note>
|
||||
|
||||
## Built on the MCP Python SDK v2
|
||||
## Protocol compatibility
|
||||
|
||||
The defining change in FastMCP 4 is the one you mostly can't see. The MCP Python SDK v2 rewrote the protocol layer end to end: it moved the protocol types into a standalone `mcp_types` package that stays importable as `mcp.types`, renamed every model field from camelCase to snake_case in Python, replaced the server's request-handling model, and made server-side middleware and multi-era serving first-class. FastMCP absorbs nearly all of it — your reads stay working through a compatibility bridge, and the handful of changes left in your code are mechanical.
|
||||
A protocol migration usually forces a choice between breaking clients that have not moved yet and holding the server back with them. FastMCP 4 serves both eras from one deployment, negotiating the best mutual version for each connection. Modern clients get the sessionless protocol while handshake-era clients continue working unchanged.
|
||||
|
||||
The major version is the signal. Even where your surface is unchanged, the behavior underneath is substantially different, and bumping to 4.0 is how we tell you that plainly rather than slipping a new engine in under a patch release.
|
||||
Statelessness changes how that deployment scales. Each modern request carries everything needed to answer it, so any replica behind an ordinary load balancer can serve any request and session affinity stops being a requirement.
|
||||
|
||||
The rebuild also pulls the protocol's recent evolution forward in a single step. A batch of accepted MCP proposals arrives with SDK v2, and FastMCP 4 surfaces each one: capability-negotiated extensions (SEP-2133), multi-round-trip elicitation for sessionless connections (SEP-2322), response cache hints (SEP-2549), spec-standard error codes (SEP-2164), the enterprise identity-assertion grant (SEP-990), and the sessionless `2026-07-28` protocol itself, which removes server-initiated requests (SEP-2577). The rest of this page is what those add up to.
|
||||
The client default follows the same rule. `Client(url)` probes for the modern protocol and falls back to the handshake when necessary. Pin `mode="legacy"` only when your application specifically needs the session back-channel.
|
||||
|
||||
## Every protocol era
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
A FastMCP 4 server answers clients across the protocol transition from one deployment. The MCP SDK negotiates the era per connection — the sessionless `2026-07-28` protocol for clients that have moved forward, the session-based handshake for everyone else — and any replica behind a plain load balancer can serve a modern request. This supersedes FastMCP's earlier "latest protocol only" stance: you adopt the new protocol without forking your deployment or gating clients by version.
|
||||
# Negotiate the best mutual protocol
|
||||
client = Client("https://example.com/mcp")
|
||||
|
||||
The same negotiation runs from the client, and its default flipped. A plain `Client(url)` now probes for the modern protocol and adopts it when the server offers it, falling back to the handshake otherwise — where every earlier FastMCP version pinned the handshake outright. That flip is what brings the modern capabilities within reach of ordinary client code: a task-enabled tool hands back a handle to poll, and multi-round-trip elicitation resolves across successive requests, neither requiring the caller to opt in. Set `mode="legacy"` to pin the handshake when you need the session-based back-channel or the classic `initialize` result. Once connected, `client.protocol_version`, `client.server_info`, `client.server_capabilities`, and `client.instructions` read the same regardless of which era you negotiated — code that inspects the connection no longer branches on how it got there. See [Protocol negotiation](/clients/client#protocol-negotiation).
|
||||
# Require the handshake-era protocol
|
||||
legacy = Client("https://example.com/mcp", mode="legacy")
|
||||
```
|
||||
|
||||
Once connected, `client.protocol_version`, `client.server_info`, `client.server_capabilities`, and `client.instructions` expose the same interface whichever era was negotiated. Application code that inspects a server does not need a protocol-version branch. See [Protocol negotiation](/clients/client#protocol-negotiation).
|
||||
|
||||
The modern protocol is sessionless, so it drops the server's ability to call back into the client mid-request (SEP-2577), and FastMCP 4's server API reflects that. `ctx.elicit` moves to a request-shaped pattern that works on modern connections: the tool returns a description of the input it needs, and the client answers with a fresh call. Better still, a tool can stop managing that exchange at all and simply declare which of its parameters come from the user — `Annotated[str, Elicit("Where would you like to fly?")]` is filled before the body runs, hidden from the tool's schema, and works unchanged on both protocol eras, because the framework rather than your code decides how the question travels. See [Elicitation](/servers/elicitation#declared-parameters). `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` are gone from the API, because each of them pushed a request down a live connection and a method that only works against old clients is a trap.
|
||||
|
||||
Both capabilities survive in the same request-shaped form. Asking for roots that way is the natural replacement, since one round trip buys the whole answer. Generation usually belongs in the server instead, because a loop of asking rounds spends the round-trip budget over and over — [call an LLM from your server](/servers/sampling). Logging is untouched: `ctx.info` and its siblings are notifications, and notifications ride the response stream on every era. Everything else about writing a server is unchanged.
|
||||
On modern connections, FastMCP also attaches the method, target name, and opted-in argument values as HTTP headers. Gateways and load balancers can route requests without parsing JSON-RPC bodies. See [Gateway routing headers](/deployment/http#gateway-routing-headers).
|
||||
|
||||
## State without a session
|
||||
## Stateful applications
|
||||
|
||||
A stateless protocol raises an obvious question: if every request is a fresh connection, where does a tool keep a shopping cart, a conversation, or a running total? FastMCP 4 follows the MCP working group's own decision to reject protocol-level sessions in favor of *explicit state handles* (SEP-2567) — the server hands out an identifier, and the client passes it back.
|
||||
The modern protocol removes transport-level sessions, but applications still need conversations, user state, and long-running work. FastMCP moves those concerns into explicit application primitives that survive fresh connections. Shared stores and request-state keys extend them across replicas and worker restarts.
|
||||
|
||||
Two shapes cover the cases. `UserSession` is injected like `Context` and keyed to the authenticated user, so a tool reads and writes one bucket of state with nothing to pass around. `SessionId` is an explicit handle a tool mints and the caller supplies as an argument, for when one user holds many independent states. Both store their data server-side in the storage backend, keyed to the authenticated user — so a handle is inert in anyone else's hands. See [Session State](/servers/sessions).
|
||||
### Interactive tools
|
||||
|
||||
## Background tasks
|
||||
Many useful tools need more than one exchange. A booking tool asks for a destination, then a date, then confirmation. A destructive operation asks the user to approve it before continuing.
|
||||
|
||||
Long-running work runs as a background task: the server accepts the call, returns a handle, and the client polls for the result while the work proceeds. Tasks left the core MCP spec during the SDK v2 rebuild and returned as the `io.modelcontextprotocol/tasks` extension (SEP-2663), which FastMCP implements end to end in the optional `fastmcp-tasks` package. The durable execution engine that made FastMCP 3's tasks reliable — [Docket](https://github.com/chrisguidry/docket) — carries straight over, and `@mcp.tool(task=True)` remains the authoring surface, so the wire protocol modernizing underneath costs you no code change. See [Background Tasks](/servers/tasks).
|
||||
On the modern protocol, the tool returns a description of the input it needs. That result completes the request normally. The client fulfils the request and calls the tool again with the answer attached; the tool runs from the top, reads `ctx.input_responses`, and either asks another question or returns its final result.
|
||||
|
||||
## Server extensions
|
||||
Each request completes while the user responds. Single-process servers use an automatic process-local key to protect the state carried between rounds; load-balanced deployments configure one shared key so any replica can validate and resume the next round:
|
||||
|
||||
Background tasks are the first capability built on a more general one: FastMCP 4 makes MCP extensions — capability-negotiated protocol features named by a reverse-DNS string (SEP-2133) — a first-class surface. `FastMCP.add_extension()` lets an extension advertise a capability, add request methods, intercept `tools/call`, and run a lifespan hook, all with full access to the component registry, `Context`, and auth. The same extensions flow through the client with `Client(extensions=...)`. A cross-cutting protocol feature stops being surgery on core and becomes a supported plugin.
|
||||
```python
|
||||
import os
|
||||
|
||||
## Argument completion
|
||||
from fastmcp import Context, FastMCP
|
||||
from mcp.server.request_state import RequestStateSecurity
|
||||
from mcp.types import ElicitRequest, ElicitRequestFormParams, InputRequiredResult
|
||||
|
||||
When a client offers autocomplete for a prompt argument or a resource-template parameter, it asks the server which values fit — narrowing the list as the user types. FastMCP 4 lets a server answer. A single `@mcp.completion` handler receives the reference being completed, the argument and its partial value, and the arguments the user has already supplied, and returns the candidates the client surfaces as suggestions. Because the handler sees the earlier arguments, completions can depend on them — a `repo` parameter suggesting only repositories under the `owner` already chosen.
|
||||
mcp = FastMCP(
|
||||
"Booking",
|
||||
request_state_security=RequestStateSecurity(
|
||||
keys=[os.environ["REQUEST_STATE_KEY"].encode()]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def book_flight(ctx: Context) -> str | InputRequiredResult:
|
||||
answers = ctx.input_responses
|
||||
if answers is None:
|
||||
params = ElicitRequestFormParams(
|
||||
message="Where would you like to fly?",
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {"destination": {"type": "string"}},
|
||||
"required": ["destination"],
|
||||
},
|
||||
)
|
||||
return InputRequiredResult(
|
||||
result_type="input_required",
|
||||
input_requests={
|
||||
"destination": ElicitRequest(
|
||||
method="elicitation/create",
|
||||
params=params,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
response = answers["destination"]
|
||||
if response.action != "accept" or response.content is None:
|
||||
return "Booking cancelled."
|
||||
|
||||
destination = response.content["destination"]
|
||||
return f"Booked a flight to {destination}."
|
||||
```
|
||||
|
||||
Every replica must receive the same `REQUEST_STATE_KEY`, containing at least 32 bytes of secret key material. A FastMCP client drives the loop through its existing elicitation handler, so client code receives the terminal result without managing the intermediate rounds. See [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol).
|
||||
|
||||
### Session state
|
||||
|
||||
Application state follows the same explicit model. FastMCP stores state server-side and binds it to the authenticated user, so a session handle is inert in another user's hands.
|
||||
|
||||
Most tools want one state bucket per user. Declare a `UserSession` parameter and FastMCP injects it like `Context`: it never appears in the tool schema, and the caller passes nothing because their authenticated identity selects the bucket.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.sessions import UserSession
|
||||
|
||||
mcp = FastMCP("Assistant")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def remember(fact: str, session: UserSession) -> str:
|
||||
facts = await session.get("facts", default=[])
|
||||
facts.append(fact)
|
||||
await session.set("facts", facts)
|
||||
return f"Remembered {len(facts)} facts."
|
||||
```
|
||||
|
||||
`UserSession` requires [authentication](/servers/auth/authentication), since an unauthenticated request has no user to key on. When one user needs several independent buckets, such as separate carts or conversations, `SessionId` exposes the handle as an explicit string argument.
|
||||
|
||||
The default in-memory state store is process-local. To preserve state across restarts or share it among replicas, pass a shared persistent `session_state_store`. See [Session state](/servers/sessions).
|
||||
|
||||
### Background work
|
||||
|
||||
Long-running tools create a different kind of state problem: holding a request open for several minutes invites timeouts and leaves the user unable to tell whether work is progressing. Background tasks accept the call and return a handle immediately, then let the client poll while work proceeds asynchronously.
|
||||
|
||||
FastMCP implements the `io.modelcontextprotocol/tasks` extension in the optional `fastmcp-tasks` package. The authoring API remains `@mcp.tool(task=True)`, backed by [Docket](https://github.com/chrisguidry/docket):
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp_tasks import TasksExtension
|
||||
|
||||
mcp = FastMCP("MyServer")
|
||||
mcp.add_extension(TasksExtension())
|
||||
|
||||
|
||||
@mcp.tool(task=True)
|
||||
async def slow_computation(duration: int) -> str:
|
||||
"""Run a long computation."""
|
||||
await asyncio.sleep(duration)
|
||||
return f"Completed in {duration} seconds"
|
||||
```
|
||||
|
||||
`fastmcp.Client` handles the task handle and polling cycle, so `client.call_tool(...)` returns the same way whether the tool ran inline or in the background. See [Background tasks](/servers/tasks).
|
||||
|
||||
`TasksExtension()` uses an in-memory, single-process backend by default. Configure a Redis or Valkey backend for durable work that survives restarts and runs across separate workers.
|
||||
|
||||
## Extensible protocol
|
||||
|
||||
Background tasks are built on a general extension surface. An MCP extension advertises a capability under a reverse-DNS identifier and can add behavior negotiated between a server and client.
|
||||
|
||||
### Server extensions
|
||||
|
||||
`FastMCP.add_extension()` lets an extension advertise capabilities, add request methods, intercept `tools/call`, and own lifespan behavior with access to the component registry, `Context`, and authentication. Client extensions use the matching `Client(extensions=...)` interface.
|
||||
|
||||
Cross-cutting protocol behavior can therefore live in a supported plugin instead of requiring changes to FastMCP core. `TasksExtension` is a complete example of the interface. See [Server extensions](/servers/extensions).
|
||||
|
||||
### Argument completion
|
||||
|
||||
FastMCP 4 also lets servers answer MCP argument-completion requests. A completion handler sees the prompt or resource-template argument, its partial value, and values already supplied, so suggestions can depend on earlier choices.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
|
@ -63,73 +180,40 @@ def write_poem(theme: str) -> str:
|
|||
def complete(ref, argument, context):
|
||||
if isinstance(ref, PromptReference) and argument.name == "theme":
|
||||
options = ["nature", "love", "adventure"]
|
||||
return [o for o in options if o.startswith(argument.value)]
|
||||
return [option for option in options if option.startswith(argument.value)]
|
||||
return None
|
||||
```
|
||||
|
||||
Registering a handler advertises the completions capability during negotiation, so a client only sends requests to a server that answers them — the same on both protocol eras. See [Argument Completion](/servers/completions).
|
||||
Registering the handler advertises the completion capability during negotiation, so clients only send requests to servers that support them. See [Argument completion](/servers/completions).
|
||||
|
||||
## Enterprise identity
|
||||
|
||||
FastMCP 4 ships a complete server-side implementation of identity assertion (SEP-990): enterprise "on-behalf-of" access, where a corporate identity provider issues a signed assertion, the user's agent presents it, and the server mints a short-lived token — no browser login and no per-user consent screen. Behind one parameter on the existing auth providers, FastMCP performs the full signature verification, binding checks, replay rejection, and scoped token issuance.
|
||||
Interactive OAuth authorization assumes a person can complete a browser flow. Internal agents often act for employees without a person waiting at a keyboard, while the server still needs the employee's identity for authorization and audit.
|
||||
|
||||
Identity assertion carries that identity through the agent. A corporate identity provider signs an assertion, the agent presents it, and the server exchanges it for a short-lived token without an interactive login or consent screen. FastMCP performs signature verification, binding checks, replay rejection, and scoped token issuance through the authentication providers you already use.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import IdentityAssertion, OAuthProxy
|
||||
|
||||
auth = OAuthProxy(
|
||||
# existing upstream configuration unchanged
|
||||
identity_assertion=IdentityAssertion(trusted_issuers=["https://login.acme-corp.com"]),
|
||||
# Existing upstream configuration
|
||||
identity_assertion=IdentityAssertion(
|
||||
trusted_issuers=["https://login.acme-corp.com"]
|
||||
),
|
||||
)
|
||||
mcp = FastMCP("Internal API", auth=auth)
|
||||
```
|
||||
|
||||
The asserted subject flows into the normal auth context, so tools read it through `get_access_token()` like any other identity. See [Identity Assertion](/servers/auth/oauth-proxy#identity-assertion-sep-990).
|
||||
The asserted subject enters the normal authentication context, so tools read it through `get_access_token()` like any other identity. See [Identity assertion](/servers/auth/oauth-proxy#identity-assertion-sep-990).
|
||||
|
||||
Authorizing a caller by role is a related, provider-agnostic need. Scopes are standardized, so `require_scopes` behaves the same everywhere, but roles and groups are not part of OIDC and every provider files them under a different claim. `require_roles` handles the comparison and takes an `extract` callable naming where to look, so Keycloak's `realm_access.roles`, Cognito's `cognito:groups`, and Auth0's per-tenant namespaced claims all work without FastMCP guessing.
|
||||
Authorization gained a provider-neutral role check as well. `require_roles` accepts an extraction function for providers that store roles and groups under different claims, while [scope step-up challenges](/servers/authorization#signaling-scope-shortfalls) tell a client exactly which scopes to request.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import require_roles
|
||||
For clients with no user behind them, such as backend services and scheduled jobs, `ClientCredentialsOAuthProvider` implements the OAuth 2.0 client-credentials grant with no browser or redirect. See [Machine-to-machine authentication](/clients/auth/client-credentials).
|
||||
|
||||
mcp = FastMCP("Internal API")
|
||||
## Production defaults
|
||||
|
||||
@mcp.tool(auth=require_roles("admin", extract=lambda c: c["realm_access"]["roles"]))
|
||||
def rotate_credentials() -> str:
|
||||
"""Only callable by a caller holding the 'admin' role."""
|
||||
return "Rotated"
|
||||
```
|
||||
|
||||
This illustrates the check in isolation — enforcing it for real needs an HTTP-transport server with a token-validating `auth` provider configured (a `JWTVerifier`, a `RemoteAuthProvider`, or a provider built on one, such as `KeycloakAuthProvider`, all expose claims directly), since STDIO has no OAuth concept and skips every check. See [Authorization](/servers/authorization#require_roles) for the full picture.
|
||||
|
||||
The client side of enterprise auth arrived too. Not every FastMCP client has a user behind it — a backend service, a scheduled job, one MCP server calling another — and `ClientCredentialsOAuthProvider` authenticates one of those to a protected server with the OAuth 2.0 client-credentials grant: no browser, no redirect, no consent screen.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.auth import ClientCredentialsOAuthProvider
|
||||
|
||||
auth = ClientCredentialsOAuthProvider(
|
||||
client_id="my-client-id",
|
||||
client_secret="my-client-secret",
|
||||
scopes=["read", "write"],
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
async with Client("https://example.com/mcp", auth=auth) as client:
|
||||
await client.list_tools()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
See [Machine-to-Machine Authentication](/clients/auth/client-credentials).
|
||||
|
||||
## Faster and safer
|
||||
|
||||
Two more capabilities arrive by default. Response caching (SEP-2549) lets a server stamp freshness hints on its results that a caching [client](/clients/client#response-caching) reuses without a round trip, and a distributed `KeyValueResponseCacheStore` backs that cache with Redis or any key-value store, so a fleet of clients or proxy replicas shares fills.
|
||||
A server can now attach freshness hints to its results, and a caching client can reuse those results without another round trip. Set a default time-to-live and scope on the server:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
|
@ -137,10 +221,18 @@ from fastmcp import FastMCP
|
|||
mcp = FastMCP("Weather", cache_ttl=300, cache_scope="public")
|
||||
```
|
||||
|
||||
Security tightened in the same release: every templated resource screens its parameters for path traversal, absolute paths, and null bytes before the handler runs — [path security](/servers/resources#path-security) on by default, covering mounted and proxied templates too.
|
||||
`KeyValueResponseCacheStore` can place the client cache in Redis or another key-value store so a fleet of clients or proxies shares fills. See [Response caching](/clients/client#response-caching).
|
||||
|
||||
The OAuth flow got more precise as well. Dynamic Client Registration now honors a client's declared `application_type` (SEP-837): the permissive loopback and app-scheme callbacks MCP clients rely on stay the default for `"native"`, while a client that registers as `"web"` is held to stricter browser-app redirect rules. And when `AuthMiddleware` denies a call specifically for a missing scope, it raises `InsufficientScopeError` naming exactly which scopes would fix it (SEP-2350), so a caller re-authorizes precisely instead of retrying blind. See [Application Type](/servers/auth/oauth-proxy#application-type-web-vs-native) and [Signaling Scope Shortfalls](/servers/authorization#signaling-scope-shortfalls).
|
||||
Resource templates now reject path traversal, absolute paths, and null bytes in their parameters before the handler runs. The protection is enabled by default and applies to mounted and proxied templates. See [Path security](/servers/resources#path-security).
|
||||
|
||||
A gateway or load balancer in front of your server can now route a request without parsing its JSON-RPC body: on a modern connection, FastMCP's client attaches the method, target name, and opted-in argument values as HTTP headers (SEP-2243), so an intermediary dispatches on headers alone. See [Gateway Routing Headers](/deployment/http#gateway-routing-headers).
|
||||
OAuth defaults also distinguish native clients from web applications during Dynamic Client Registration, and missing scopes now produce an `InsufficientScopeError` that names the scopes required to continue. See [Application type](/servers/auth/oauth-proxy#application-type-web-vs-native) and [scope shortfalls](/servers/authorization#signaling-scope-shortfalls).
|
||||
|
||||
When you're ready to move a server to v4, [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) walks through every change and what it looks like in practice.
|
||||
## Upgrade note
|
||||
|
||||
The sessionless protocol has no live connection for a server to call back into during execution. FastMCP 4 therefore removes `ctx.sample()`, `ctx.sample_step()`, and `ctx.list_roots()` from every protocol era so incompatible code fails immediately during an upgrade.
|
||||
|
||||
For generation, call an LLM directly from the server when your application owns the model. When borrowing the caller's model is the point, return an `InputRequiredResult` carrying a sampling request and read the answer on the next round. Roots use the same return-and-resume pattern. See [Sampling](/servers/sampling) and [the guard pattern](/servers/elicitation#sampling-and-roots).
|
||||
|
||||
`ctx.elicit()` remains available on handshake-era connections; modern connections use the multi-round pattern described above. Code that constructs MCP protocol models directly must also use snake_case Python field names with SDK v2.
|
||||
|
||||
[Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) covers these changes and every other compatibility break.
|
||||
|
|
|
|||
|
|
@ -69,9 +69,11 @@ You'll also need to authenticate with Anthropic. You can do this by setting the
|
|||
export ANTHROPIC_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
Here is an example of how to call your server from Python. Note that you'll need to replace `https://your-server-url.com` with the actual URL of your server. In addition, we use `/mcp/` as the endpoint because we deployed a streamable-HTTP server with the default path; you may need to use a different endpoint if you customized your server's deployment. **At this time you must also include the `extra_headers` parameter with the `anthropic-beta` header.**
|
||||
Here is an example of how to call your server from Python. Note that you'll need to replace `https://your-server-url.com` with the actual URL of your server. In addition, we use `/mcp/` as the endpoint because we deployed a streamable-HTTP server with the default path; you may need to use a different endpoint if you customized your server's deployment.
|
||||
|
||||
```python {5, 13-22}
|
||||
The connector is in beta, so the call goes through `client.beta.messages` with the `mcp-client-2025-11-20` flag. Each entry in `mcp_servers` also needs a matching `mcp_toolset` entry in `tools` that references it by name; declaring the server without the toolset is rejected as a validation error.
|
||||
|
||||
```python {5, 14-23}
|
||||
import anthropic
|
||||
from rich import print
|
||||
|
||||
|
|
@ -81,8 +83,9 @@ url = 'https://your-server-url.com'
|
|||
client = anthropic.Anthropic()
|
||||
|
||||
response = client.beta.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
model="claude-sonnet-5",
|
||||
max_tokens=1000,
|
||||
betas=["mcp-client-2025-11-20"],
|
||||
messages=[{"role": "user", "content": "Roll a few dice!"}],
|
||||
mcp_servers=[
|
||||
{
|
||||
|
|
@ -91,9 +94,7 @@ response = client.beta.messages.create(
|
|||
"name": "dice-server",
|
||||
}
|
||||
],
|
||||
extra_headers={
|
||||
"anthropic-beta": "mcp-client-2025-04-04"
|
||||
}
|
||||
tools=[{"type": "mcp_toolset", "mcp_server_name": "dice-server"}],
|
||||
)
|
||||
|
||||
print(response.content)
|
||||
|
|
@ -193,7 +194,7 @@ Error code: 400 - {
|
|||
|
||||
To authenticate the client, you can pass the token using the `authorization_token` parameter in your MCP server configuration:
|
||||
|
||||
```python {8, 21}
|
||||
```python {8, 22}
|
||||
import anthropic
|
||||
from rich import print
|
||||
|
||||
|
|
@ -206,8 +207,9 @@ access_token = 'your-access-token'
|
|||
client = anthropic.Anthropic()
|
||||
|
||||
response = client.beta.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
model="claude-sonnet-5",
|
||||
max_tokens=1000,
|
||||
betas=["mcp-client-2025-11-20"],
|
||||
messages=[{"role": "user", "content": "Roll a few dice!"}],
|
||||
mcp_servers=[
|
||||
{
|
||||
|
|
@ -217,9 +219,7 @@ response = client.beta.messages.create(
|
|||
"authorization_token": access_token
|
||||
}
|
||||
],
|
||||
extra_headers={
|
||||
"anthropic-beta": "mcp-client-2025-04-04"
|
||||
}
|
||||
tools=[{"type": "mcp_toolset", "mcp_server_name": "dice-server"}],
|
||||
)
|
||||
|
||||
print(response.content)
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ from fastmcp.server.auth.providers.github import GitHubProvider
|
|||
# The GitHubProvider handles GitHub's token format and validation
|
||||
auth_provider = GitHubProvider(
|
||||
client_id="Ov23liAbcDefGhiJkLmN", # Your GitHub OAuth App Client ID
|
||||
client_secret="github_pat_...", # Your GitHub OAuth App Client Secret
|
||||
client_secret="your-github-client-secret", # Your GitHub OAuth App Client Secret
|
||||
base_url="http://localhost:8000", # Must match your OAuth App configuration
|
||||
# redirect_path="/auth/callback" # Default value, customize if needed
|
||||
)
|
||||
|
|
@ -151,7 +151,7 @@ from cryptography.fernet import Fernet
|
|||
# Production setup with encrypted persistent token storage
|
||||
auth_provider = GitHubProvider(
|
||||
client_id="Ov23liAbcDefGhiJkLmN",
|
||||
client_secret="github_pat_...",
|
||||
client_secret="your-github-client-secret",
|
||||
base_url="https://your-production-domain.com",
|
||||
|
||||
# Production token management
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ An object containing environment variables to set when launching the server. All
|
|||
|
||||
This format is widely adopted across the MCP ecosystem:
|
||||
|
||||
- **Claude Desktop**: Uses `~/.claude/claude_desktop_config.json`
|
||||
- **Claude Desktop**: Uses `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows
|
||||
- **Cursor**: Uses `~/.cursor/mcp.json`
|
||||
- **VS Code**: Uses workspace `.vscode/mcp.json`
|
||||
- **Other clients**: Many MCP-compatible applications follow this standard
|
||||
|
|
@ -457,7 +457,7 @@ The generated configuration works with any MCP-compatible application:
|
|||
<Note>
|
||||
**Prefer [`fastmcp install claude-desktop`](/integrations/claude-desktop)** for automatic installation. Use MCP JSON for advanced configuration needs.
|
||||
</Note>
|
||||
Copy the `mcpServers` object into `~/.claude/claude_desktop_config.json`
|
||||
Copy the `mcpServers` object into Claude Desktop's config file (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows)
|
||||
|
||||
### Cursor
|
||||
<Note>
|
||||
|
|
|
|||
|
|
@ -300,10 +300,12 @@ For advanced configuration options and custom middleware extensions, see [Advanc
|
|||
See the [example server](https://github.com/permitio/permit-fastmcp/blob/main/permit_fastmcp/example_server/example.py) for a full implementation with JWT-based authentication. For additional examples and usage patterns, see [Example Server](https://github.com/permitio/permit-fastmcp/blob/main/permit_fastmcp/example_server/):
|
||||
|
||||
```python
|
||||
import os
|
||||
import datetime
|
||||
|
||||
import jwt
|
||||
from fastmcp import FastMCP, Context
|
||||
from permit_fastmcp.middleware.middleware import PermitMcpMiddleware
|
||||
import jwt
|
||||
import datetime
|
||||
|
||||
# Configure JWT identity extraction
|
||||
os.environ["PERMIT_MCP_IDENTITY_MODE"] = "jwt"
|
||||
|
|
|
|||
77
docs/language-dropdown.js
Normal file
77
docs/language-dropdown.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// Language dropdown: a small Python/TypeScript switcher injected into the
|
||||
// sidebar footer, next to Mintlify's theme selector. Selecting the other
|
||||
// language navigates to that project's docs site; selecting the current
|
||||
// language is a no-op. Styling lives in css/language-dropdown.css.
|
||||
(function () {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
var CURRENT_LANGUAGE = "python";
|
||||
|
||||
// TODO: fastmcp-ts has no public docs site URL discoverable in either repo
|
||||
// yet. Until it exists, point at the repo README (the same cross-link the
|
||||
// welcome page uses), then replace with the real docs URL.
|
||||
var TYPESCRIPT_DOCS_URL = "https://github.com/PrefectHQ/fastmcp-ts";
|
||||
var PYTHON_DOCS_URL = "https://gofastmcp.com";
|
||||
|
||||
var URLS = { python: PYTHON_DOCS_URL, typescript: TYPESCRIPT_DOCS_URL };
|
||||
|
||||
function findThemeSelector() {
|
||||
// Mintlify's sidebar-footer DOM is not a stable public API, so probe a
|
||||
// few markers (almond theme first) and give up quietly if none match.
|
||||
return (
|
||||
document.querySelector("[data-theme-preference-switch]") ||
|
||||
document.querySelector('[role="group"][aria-label="Theme preference"]')
|
||||
);
|
||||
}
|
||||
|
||||
function buildDropdown() {
|
||||
var label = document.createElement("label");
|
||||
label.id = "language-switch";
|
||||
|
||||
var select = document.createElement("select");
|
||||
select.setAttribute("aria-label", "Switch documentation language");
|
||||
|
||||
[
|
||||
["python", "Python"],
|
||||
["typescript", "TypeScript"],
|
||||
].forEach(function (entry) {
|
||||
var option = document.createElement("option");
|
||||
option.value = entry[0];
|
||||
option.textContent = entry[1];
|
||||
if (entry[0] === CURRENT_LANGUAGE) option.selected = true;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
select.addEventListener("change", function () {
|
||||
if (select.value === CURRENT_LANGUAGE) return;
|
||||
window.location.href = URLS[select.value];
|
||||
});
|
||||
|
||||
label.appendChild(select);
|
||||
return label;
|
||||
}
|
||||
|
||||
function addDropdown() {
|
||||
if (document.getElementById("language-switch")) return;
|
||||
var theme = findThemeSelector();
|
||||
if (!theme || !theme.parentElement) return;
|
||||
// Insert after the theme pill; margin-left:auto floats it right.
|
||||
theme.parentElement.insertBefore(buildDropdown(), theme.nextSibling);
|
||||
}
|
||||
|
||||
function run() {
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", addDropdown);
|
||||
} else {
|
||||
addDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
// Mintlify re-renders the sidebar on client-side navigation; re-inject when
|
||||
// the dropdown disappears.
|
||||
new MutationObserver(function () {
|
||||
if (!document.getElementById("language-switch")) addDropdown();
|
||||
}).observe(document.body, { subtree: true, childList: true });
|
||||
})();
|
||||
|
|
@ -22,16 +22,24 @@ The client probes `server/discover` and adopts the modern protocol when the serv
|
|||
|
||||
## What are the two protocol eras, and which one does my server speak?
|
||||
|
||||
Both. A FastMCP server serves every era from one deployment and one URL, and the SDK negotiates per connection — the client picks, not the server.
|
||||
Both. A FastMCP 4 server supports the handshake revisions `2024-11-05`, `2025-03-26`, `2025-06-18`, and `2025-11-25`, plus the modern `2026-07-28` protocol. It serves all of them from one deployment and one URL, and the SDK negotiates per connection — the client picks, not the server.
|
||||
|
||||
The *handshake* era (`2025-11-25` and earlier) opens each connection with `initialize` and holds a session, which gives the server a back-channel it can push requests down. The *modern* era (`2026-07-28`) is sessionless: the client learns what the server offers through `server/discover`, every request stands alone, and there is no back-channel. Inside a tool, `ctx.request_context.protocol_version` tells you which era the current call arrived on; on the client, `client.protocol_version` reports it after connecting.
|
||||
|
||||
A protocol version establishes the wire format, while capabilities describe which optional operations a particular server provides. The capabilities returned by `server/discover` or `initialize` are therefore the authoritative way for a client to determine what is available.
|
||||
|
||||
## Can FastMCP 4 talk to older clients and servers?
|
||||
|
||||
Yes, in both directions, with no configuration. A FastMCP 4 server answers a handshake-era client and a modern one from the same process: the old client sends `initialize` and gets a session id, the modern client discovers and stays stateless.
|
||||
|
||||
A FastMCP 4 client is equally happy against an old server, because `mode="auto"` falls back to the handshake when discovery finds no modern peer. The client-side handlers for server-initiated capabilities are all still there too — passing `sampling_handler=` or `roots=` answers a legacy server's requests exactly as before, which is what a modern client needs in order to interoperate. See [client sampling](/clients/sampling) and [client roots](/clients/roots).
|
||||
|
||||
## How does FastMCP verify protocol conformance?
|
||||
|
||||
FastMCP runs the [official MCP conformance suite](https://github.com/modelcontextprotocol/conformance) in CI against a pinned suite release. A failing scenario for a released capability that FastMCP advertises as supported is treated as a regression.
|
||||
|
||||
The suite's `all` mode also exercises draft, pending, retired, and deliberately unsupported capabilities, so its raw pass count is broader than FastMCP's support contract. Known exceptions are recorded in [`expected-failures.yml`](https://github.com/PrefectHQ/fastmcp/blob/main/tests/conformance/expected-failures.yml) with their rationale, and new upstream scenarios arrive through deliberate suite-version updates rather than silently changing CI.
|
||||
|
||||
## When should I pin `mode="legacy"`?
|
||||
|
||||
Pin it when your code depends on the session the handshake creates: `client.ping()` and `transport.get_session_id()` have no modern equivalent, since a sessionless connection has neither a live back-channel to ping nor an id to hold. It is also the escape hatch when a server misbehaves under discovery or you need the classic `initialize` result object.
|
||||
|
|
@ -58,7 +66,7 @@ Take the paths you need as ordinary tool arguments. The agent already knows whic
|
|||
|
||||
Because logging is a *notification* and sampling was a *request*. A notification is fire-and-forget: your server emits it down the response stream the caller already opened, and nothing has to be held open on the server's behalf. A request needs an answer to come back the other way, which requires a live connection the server can reach into.
|
||||
|
||||
The modern protocol kept every server notification — `notifications/message`, `notifications/progress`, and the list-changed family — and removed the server-to-client request direction entirely. So `ctx.info()`, `ctx.debug()`, and `ctx.report_progress()` reach the client mid-call on every era, while sampling and roots have no era-agnostic form and were dropped. [Sampling](/servers/sampling#requests-and-notifications) works through the distinction in full.
|
||||
The modern protocol kept every server notification — `notifications/message`, `notifications/progress`, and the list-changed family — and removed the server-to-client request direction entirely. So `ctx.info()`, `ctx.debug()`, and `ctx.report_progress()` reach the client mid-call on every era, while sampling and roots have no era-agnostic form and were dropped. [Sampling](/servers/sampling#the-removed-methods) works through the distinction in full.
|
||||
|
||||
You may see an `MCPDeprecationWarning` from the SDK about the logging capability being deprecated as of `2026-07-28`. It refers to the capability declaration, not to the notification, and delivery is unaffected.
|
||||
|
||||
|
|
|
|||
|
|
@ -283,10 +283,11 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
|
|||
<ParamField body="jwt_signing_key" type="str | bytes | None">
|
||||
|
||||
<VersionBadge version="2.13.0" />
|
||||
Secret used to sign FastMCP JWT tokens issued to clients. Accepts any string or bytes - will be derived into a proper 32-byte cryptographic key using HKDF.
|
||||
Secret used to sign FastMCP JWT tokens issued to clients. How the key is derived depends on what you pass:
|
||||
|
||||
**Default behavior (`None`):**
|
||||
Derives a 32-byte key using PBKDF2 from the upstream client secret.
|
||||
- **`bytes`** are used as-is, with no stretching, so supply at least 32 bytes of high-entropy key material. With the default file-backed client storage, the bytes must also decode as UTF-8; use `secrets.token_urlsafe(32).encode()` instead of raw `secrets.token_bytes()`, or configure `client_storage` explicitly.
|
||||
- **A string** is stretched into a 32-byte key with PBKDF2 (1,000,000 iterations), since a supplied string may be low-entropy. Strings shorter than 12 characters also log a warning.
|
||||
- **`None`** (the default) derives a 32-byte key from the upstream client secret using HKDF.
|
||||
|
||||
**For production:**
|
||||
Provide an explicit secret (e.g., from environment variable) to use a fixed key instead of the key derived from the upstream client secret. This allows you to manage keys securely in cloud environments, allows keys to work across multiple instances, and allows you to rotate keys without losing client registrations.
|
||||
|
|
@ -314,8 +315,10 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
|
|||
**`"remember"` — silent consent on return:**
|
||||
Users see the consent screen on first authorization; subsequent flows from the same browser for the same `(client_id, redirect_uri)` are silently approved via a signed cookie. Cross-site navigations (detected via `Sec-Fetch-Site`) fall back to the prompt. `Sec-Fetch-Site` is a browser-level heuristic rather than a protocol guarantee: an attacker who finds a way to initiate a non-cross-site navigation (XSS on a sibling origin, a same-site redirect chain, etc.) can reach the silent-consent path. `True` does not depend on this signal. See [Confused Deputy Attacks](#confused-deputy-attacks) for the underlying attack class.
|
||||
|
||||
**`"external"` — delegate to upstream:**
|
||||
Skip the built-in consent page; consent is collected by the upstream IdP or a custom login page referenced via `upstream_authorization_endpoint`. No security warning is logged.
|
||||
**`"external"` — externally managed:**
|
||||
Follows the same authorization path as `False`: FastMCP skips its consent page and associated browser-binding protections, then redirects directly to the upstream provider. The difference is logging. `False` emits a security warning, while `"external"` suppresses that warning as an explicit acknowledgment that the operator is enforcing equivalent consent and transaction-binding protections elsewhere. FastMCP does not provide or verify those external protections.
|
||||
|
||||
Ordinary upstream OAuth consent is generally not equivalent. It typically authorizes FastMCP's shared upstream application without identifying the downstream MCP client or binding approval to that client's transaction. Use `"external"` only when your surrounding authorization system supplies those protections.
|
||||
|
||||
**`False` — disable entirely:**
|
||||
Authorization proceeds directly to the upstream provider without any consent UI. Logs a security warning. Only for local development or testing.
|
||||
|
|
@ -335,7 +338,7 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
|
|||
```
|
||||
|
||||
<Warning>
|
||||
Disabling consent removes an important security layer. Only disable for local development or testing environments where you fully control all connecting clients.
|
||||
Both `False` and `"external"` disable FastMCP's consent and browser-binding protections. `False` warns about this configuration; `"external"` suppresses the warning because it is an operator acknowledgment that equivalent protections exist elsewhere. Prefer the default `True` unless you own that external authorization flow.
|
||||
</Warning>
|
||||
</ParamField>
|
||||
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ Set this if your provider requires a specific authentication method and the defa
|
|||
<ParamField body="jwt_signing_key" type="str | bytes | None">
|
||||
|
||||
<VersionBadge version="2.13.0" />
|
||||
Secret used to sign FastMCP JWT tokens issued to clients. Accepts any string or bytes - will be derived into a proper 32-byte cryptographic key using HKDF.
|
||||
Secret used to sign FastMCP JWT tokens issued to clients. **`bytes`** are used as-is, with no stretching, so supply at least 32 bytes of high-entropy key material. With the default file-backed client storage, the bytes must also decode as UTF-8; use `secrets.token_urlsafe(32).encode()` instead of raw `secrets.token_bytes()`, or configure `client_storage` explicitly. **A string** is stretched into a 32-byte key with PBKDF2 (1,000,000 iterations), since a supplied string may be low-entropy.
|
||||
|
||||
**Default behavior (`None`):**
|
||||
The key is deterministically derived from `client_secret` using HKDF, on every platform. Because the derivation is deterministic, the same key is produced across restarts as long as `client_secret` doesn't change, so tokens remain valid without any extra configuration. This convenience makes it **only** suitable for development and local testing.
|
||||
|
|
@ -206,7 +206,7 @@ auth = OIDCProxy(
|
|||
</ParamField>
|
||||
|
||||
<ParamField body="require_authorization_consent" type='bool | Literal["remember", "external"]' default="True">
|
||||
Consent screen behavior for authorization requests. Accepts `True` (default; always prompt — strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (consent handled by upstream IdP or custom page), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
|
||||
Consent screen behavior for authorization requests. Accepts `True` (default; always prompt — strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (same authorization path as `False`, but the warning is suppressed because the operator asserts that equivalent protections are enforced externally), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="consent_csp_policy" type="str | None" default="None">
|
||||
|
|
|
|||
|
|
@ -116,8 +116,6 @@ auth = RemoteAuthProvider(
|
|||
token_verifier=token_verifier,
|
||||
authorization_servers=[AnyHttpUrl("https://auth.yourcompany.com")],
|
||||
base_url="https://api.yourcompany.com", # Your server base URL
|
||||
# Optional: restrict allowed client redirect URIs
|
||||
allowed_client_redirect_uris=["http://localhost:*", "http://127.0.0.1:*"]
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Company API", auth=auth)
|
||||
|
|
@ -216,13 +214,7 @@ WorkOS's support for Dynamic Client Registration makes it particularly well-suit
|
|||
## Client Redirect URI Security
|
||||
|
||||
<Note>
|
||||
`RemoteAuthProvider` also supports the `allowed_client_redirect_uris` parameter for controlling which redirect URIs are accepted from MCP clients during DCR:
|
||||
|
||||
- `None` (default): Broad DCR-compatible redirect support, while rejecting unsafe browser schemes such as `javascript:`, `data:`, `file:`, and `vbscript:`
|
||||
- Custom list: Specify allowed patterns with wildcard support
|
||||
- Empty list `[]`: No redirect URIs allowed
|
||||
|
||||
This provides defense-in-depth even though DCR providers typically validate redirect URIs themselves.
|
||||
Redirect URIs are validated by the DCR provider itself, since it owns the registration flow. To constrain them from the FastMCP side, use [`OAuthProxy`](/servers/auth/oauth-proxy), whose `allowed_client_redirect_uris` parameter accepts a list of allowed patterns with wildcard support.
|
||||
</Note>
|
||||
|
||||
## Implementation Considerations
|
||||
|
|
|
|||
|
|
@ -80,6 +80,19 @@ This configuration creates a server that validates JWTs issued by `auth.yourcomp
|
|||
|
||||
The `issuer` parameter ensures tokens come from your trusted authentication system, while `audience` validation prevents tokens intended for other services from being accepted by your MCP server.
|
||||
|
||||
`JWTVerifier` accepts RSA (`RS*` and `PS*`), ECDSA (`ES*`), and Edwards-curve (`Ed25519` and `Ed448`) signatures from JWKS endpoints. Set `algorithm` when your issuer does not use the default `RS256`:
|
||||
|
||||
```python
|
||||
verifier = JWTVerifier(
|
||||
jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json",
|
||||
issuer="https://auth.yourcompany.com",
|
||||
audience="mcp-production-api",
|
||||
algorithm="Ed25519",
|
||||
)
|
||||
```
|
||||
|
||||
The legacy `EdDSA` identifier is also accepted for compatibility with identity providers that have not yet adopted the fully specified identifiers from RFC 9864.
|
||||
|
||||
### Symmetric Key Verification (HMAC)
|
||||
|
||||
Symmetric key verification uses a shared secret for both signing and validation, making it ideal for internal microservices and trusted environments where the same secret can be securely distributed to both token issuers and validators.
|
||||
|
|
@ -121,7 +134,7 @@ The parameter is named `public_key` for backwards compatibility, but when using
|
|||
|
||||
### Static Public Key Verification
|
||||
|
||||
Static public key verification works when you have a fixed RSA or ECDSA signing key and don't need automatic key rotation. This approach is primarily useful for development environments or controlled deployments where JWKS endpoints aren't available.
|
||||
Static public key verification works when you have a fixed RSA, ECDSA, or EdDSA signing key and don't need automatic key rotation. This approach is primarily useful for development environments or controlled deployments where JWKS endpoints aren't available.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
|
@ -141,7 +154,7 @@ verifier = JWTVerifier(
|
|||
mcp = FastMCP(name="Protected API", auth=verifier)
|
||||
```
|
||||
|
||||
This configuration validates tokens using a specific RSA or ECDSA public key. The key must correspond to the private key used by your token issuer. While less flexible than JWKS endpoints, this approach can be useful in development environments or when testing with fixed keys.
|
||||
This configuration validates tokens using a specific RSA, ECDSA, or EdDSA public key. The key must correspond to the private key used by your token issuer. While less flexible than JWKS endpoints, this approach can be useful in development environments or when testing with fixed keys.
|
||||
## Opaque Token Verification
|
||||
|
||||
Many authorization servers issue opaque tokens rather than self-contained JWTs. Opaque tokens are random strings that carry no information themselves - the authorization server maintains their state and validation requires querying the server. FastMCP supports opaque token validation through OAuth 2.0 Token Introspection (RFC 7662).
|
||||
|
|
@ -425,4 +438,3 @@ mcp = FastMCP(name="Production API", auth=verifier)
|
|||
This keeps configuration out of your codebase while maintaining explicit setup.
|
||||
|
||||
This approach enables the same codebase to run across development, staging, and production environments with different authentication requirements. Development might use static tokens while production uses JWT verification, all controlled through environment configuration.
|
||||
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ content = resource_result.contents[0].content
|
|||
```
|
||||
|
||||
**Method signatures:**
|
||||
- **`ctx.list_resources() -> list[mcp_types.Resource]`**: <VersionBadge version="2.13.0" /> Returns list of all available resources
|
||||
- **`ctx.list_resources() -> list[mcp.types.Resource]`**: <VersionBadge version="2.13.0" /> Returns list of all available resources
|
||||
- **`ctx.read_resource(uri: str | AnyUrl) -> ResourceResult`**: Returns a `ResourceResult` whose `.contents` list contains the resource content parts
|
||||
|
||||
### Prompt Access
|
||||
|
|
@ -284,14 +284,18 @@ Tools can customize which components are visible to their current session using
|
|||
FastMCP automatically sends list change notifications when components (such as tools, resources, or prompts) are added, removed, enabled, or disabled. In rare cases where you need to manually trigger these notifications, you can use the context's notification methods:
|
||||
|
||||
```python
|
||||
import mcp.types as mcp_types
|
||||
from mcp.types import (
|
||||
PromptListChangedNotification,
|
||||
ResourceListChangedNotification,
|
||||
ToolListChangedNotification,
|
||||
)
|
||||
|
||||
@mcp.tool
|
||||
async def custom_tool_management(ctx: Context) -> str:
|
||||
"""Example of manual notification after custom tool changes."""
|
||||
await ctx.send_notification(mcp_types.ToolListChangedNotification())
|
||||
await ctx.send_notification(mcp_types.ResourceListChangedNotification())
|
||||
await ctx.send_notification(mcp_types.PromptListChangedNotification())
|
||||
await ctx.send_notification(ToolListChangedNotification())
|
||||
await ctx.send_notification(ResourceListChangedNotification())
|
||||
await ctx.send_notification(PromptListChangedNotification())
|
||||
return "Notifications sent"
|
||||
```
|
||||
|
||||
|
|
|
|||
166
docs/servers/extensions.mdx
Normal file
166
docs/servers/extensions.mdx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
---
|
||||
title: Server Extensions
|
||||
sidebarTitle: Extensions
|
||||
description: Add negotiated protocol features to a server without forking the framework.
|
||||
icon: plug
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
An MCP extension is a protocol feature that lives outside the core spec, named by a reverse-DNS identifier and negotiated as a capability. A server advertises the extensions it implements, and a client advertises the ones it understands. That negotiation is per request: a client repeats its extension capabilities in every request's `_meta`, so a handler can always tell whether the caller opted in to this particular call.
|
||||
|
||||
Honoring that opt-in is the extension's job, not the framework's. FastMCP advertises your capability and routes your methods, but it does not filter callers for you, so an extension that changes behavior must check before it acts. The [tool-call interceptor](#intercepting-tool-calls) below shows the check.
|
||||
|
||||
FastMCP 4 makes extensions a first-class surface. `FastMCP.add_extension()` takes an object that can advertise a capability, serve new request methods, wrap every `tools/call`, and own resources for the life of the server. [Background tasks](/servers/tasks) are built this way, on the same public interface available to you, so a cross-cutting protocol feature becomes a plugin rather than a change to FastMCP itself.
|
||||
|
||||
## Writing an extension
|
||||
|
||||
Subclass `ServerExtension` and set an `identifier`. The identifier must carry a reverse-DNS prefix in `vendor-prefix/name` form, which FastMCP validates when the class is defined, so a malformed one fails immediately rather than at connection time. Everything else is optional: each contribution method has a working default, and a useful extension often overrides just one.
|
||||
|
||||
Registering the extension binds it to the server and advertises its capability. The capability is advertised only while the extension is registered, and registering two extensions with the same identifier is an error.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.extensions import ServerExtension
|
||||
|
||||
|
||||
class CallCounterExtension(ServerExtension):
|
||||
identifier = "com.example/call-counter"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.count = 0
|
||||
|
||||
|
||||
mcp = FastMCP("Demo")
|
||||
mcp.add_extension(CallCounterExtension())
|
||||
```
|
||||
|
||||
Register extensions before the server starts. Adding one after the lifespan is running raises, because the extension's own lifespan could no longer run and it would end up silently half-active.
|
||||
|
||||
An extension reaches the rest of the server through `self.server`, which is the `FastMCP` instance it was registered on. That is how handlers and interceptors get at the component registry, the request [`Context`](/servers/context), and the authenticated caller.
|
||||
|
||||
## Advertising settings
|
||||
|
||||
Some extensions need to tell the client how they are configured: a size limit, a supported mode, a flag. Override `settings()` to return a JSON-serializable dict, and it appears on the wire under `capabilities.extensions[identifier]`. The default is an empty dict, which advertises the extension with no settings attached.
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.extensions import ServerExtension
|
||||
|
||||
|
||||
class UploadExtension(ServerExtension):
|
||||
identifier = "com.example/uploads"
|
||||
|
||||
def settings(self) -> dict[str, Any]:
|
||||
return {"maxBytes": 10_000_000, "resumable": True}
|
||||
|
||||
|
||||
mcp = FastMCP("Demo")
|
||||
mcp.add_extension(UploadExtension())
|
||||
```
|
||||
|
||||
A client reads these alongside the capability itself, so it can adapt before making a single call.
|
||||
|
||||
## Adding request methods
|
||||
|
||||
An extension can serve request methods the core spec does not define. Return a `MethodBinding` from `methods()` naming the wire method, the Pydantic model its params validate against, and the handler to run.
|
||||
|
||||
Extension methods are strictly additive. Binding a spec-defined method like `tools/call` raises at construction, because doing so would silently shadow the server's own handler. To change how a core method behaves, use [middleware](/servers/middleware) or the tool-call interceptor below.
|
||||
|
||||
The params model should subclass `RequestParams` so `_meta` parses uniformly, and the handler receives the request context and the validated params.
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
|
||||
from mcp.types import RequestParams
|
||||
from fastmcp.server.extensions import MethodBinding, ServerExtension
|
||||
|
||||
|
||||
class GetCallCountParams(RequestParams):
|
||||
pass
|
||||
|
||||
|
||||
class CallCounterExtension(ServerExtension):
|
||||
identifier = "com.example/call-counter"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.count = 0
|
||||
|
||||
def methods(self) -> list[MethodBinding]:
|
||||
return [
|
||||
MethodBinding(
|
||||
method="callCounter/get",
|
||||
params_type=GetCallCountParams,
|
||||
handler=self.get_count,
|
||||
)
|
||||
]
|
||||
|
||||
async def get_count(self, ctx, params: GetCallCountParams) -> dict[str, Any]:
|
||||
return {"count": self.count}
|
||||
```
|
||||
|
||||
Setting `protocol_versions` on a binding restricts the method to specific wire versions, and a request at any other version is rejected as `METHOD_NOT_FOUND`. Leaving it unset, the default, serves the method on every version.
|
||||
|
||||
## Intercepting tool calls
|
||||
|
||||
Override `intercept_tool_call()` to wrap every `tools/call` the server handles. The interceptor runs after the FastMCP middleware chain and immediately before the tool body, making it the last gate before execution. Await `call_next()` to let the call proceed, or return a result without awaiting it to short-circuit.
|
||||
|
||||
Every registered interceptor runs on every tool call, including calls from clients that never advertised your extension. FastMCP does not gate this for you, so an interceptor that changes what the caller gets back must first confirm the caller opted in. `context.client_extension_settings(identifier)` returns the settings the client declared for this request, or `None` when it declared nothing.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.extensions import ServerExtension
|
||||
|
||||
|
||||
class CallCounterExtension(ServerExtension):
|
||||
identifier = "com.example/call-counter"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.count = 0
|
||||
|
||||
async def intercept_tool_call(self, params, context, call_next):
|
||||
if context.client_extension_settings(self.identifier) is None:
|
||||
return await call_next()
|
||||
self.count += 1
|
||||
return await call_next()
|
||||
|
||||
|
||||
mcp = FastMCP("Demo")
|
||||
mcp.add_extension(CallCounterExtension())
|
||||
```
|
||||
|
||||
Counting is harmless either way, so this example passes unaware callers straight through. The check becomes essential the moment an interceptor short-circuits: returning an extension-specific result to a client that never negotiated the extension hands it a shape it has no way to understand. Request methods have the same requirement, and `self.client_settings(ctx)` is the equivalent inside a handler.
|
||||
|
||||
`params` holds the validated `tools/call` params, and `context` is the FastMCP `Context`, so the tool being invoked is reachable as `context.fastmcp.get_tool(params.name)` along with auth scope and the server itself. When several extensions intercept, they nest with the first-registered outermost.
|
||||
|
||||
Reach for middleware when you want to observe or modify requests generally; reach for an interceptor when the behavior belongs to a negotiated capability and should exist only while that extension is registered.
|
||||
|
||||
## Owning resources
|
||||
|
||||
An extension that owns something with a lifecycle, such as a connection pool or a background worker, overrides `lifespan()` to return an async context manager. FastMCP enters it with the server's own [lifespan](/servers/lifespan) and exits it on shutdown, so setup and teardown stay with the extension that needs them rather than leaking into the application's startup code.
|
||||
|
||||
The lifespan is entered once per runtime tree, at the root. This matters when you compose servers: extensions are served by the server they are registered on, and a mounted child's extensions do not propagate upward. The root server owns the wire, so only root-registered extensions advertise capabilities and answer methods. Register extensions on the server you actually run.
|
||||
|
||||
## Client extensions
|
||||
|
||||
The client half of an extension is what makes negotiation two-sided. Pass `ClientExtension` instances to `Client(extensions=...)` and each contributes its capability advertisement, its result claims, and its notification bindings to the underlying session. A claimed `call_tool` result is then resolved transparently through the extension that owns it.
|
||||
|
||||
When a client needs only to say it understands an extension, without implementing behavior for it, `advertise()` produces an advertise-only entry.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from mcp.client import advertise
|
||||
|
||||
client = Client(
|
||||
"https://example.com/mcp",
|
||||
extensions=[advertise("com.example/uploads", {"maxBytes": 10_000_000})],
|
||||
)
|
||||
```
|
||||
|
||||
Advertise only what you genuinely support: the advertisement asserts wire compatibility, and claiming an extension you have not implemented invites the server to use a feature you cannot answer. For anything behavioral, construct the real extension instead.
|
||||
|
||||
Claimed result shapes are a modern-protocol feature and stay inert on a legacy connection, so an extension-aware client is still safe to point at an older server.
|
||||
|
|
@ -378,7 +378,7 @@ mcp.add_middleware(LoggingMiddleware(
|
|||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `include_payloads` | `bool` | `False` | Log request/response content |
|
||||
| `max_payload_length` | `int` | `500` | Truncate payloads beyond this length |
|
||||
| `max_payload_length` | `int` | `1000` | Truncate payloads beyond this length |
|
||||
| `logger` | `Logger` | module logger | Custom logger instance |
|
||||
|
||||
### Timing
|
||||
|
|
@ -533,7 +533,7 @@ mcp.add_middleware(ErrorHandlingMiddleware(
|
|||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `include_traceback` | `bool` | `False` | Include stack traces in logs |
|
||||
| `transform_errors` | `bool` | `False` | Convert exceptions to MCP errors |
|
||||
| `transform_errors` | `bool` | `True` | Convert exceptions to MCP errors |
|
||||
| `error_callback` | `Callable` | `None` | Custom callback on errors |
|
||||
|
||||
For automatic retries:
|
||||
|
|
|
|||
|
|
@ -448,14 +448,14 @@ A prompt can ask the client for information before it renders. On an MCP 2026-07
|
|||
|
||||
<VersionBadge version="2.1.0" />
|
||||
|
||||
You can configure how the FastMCP server handles attempts to register multiple prompts with the same name. Use the `on_duplicate_prompts` setting during `FastMCP` initialization.
|
||||
You can configure how the FastMCP server handles attempts to register the same prompt twice. Identity is the component's type, name, and version together, so a prompt may share a name with a tool, and two versions of one prompt coexist. The `on_duplicate` setting covers every component type, so it applies to prompts alongside tools and resources.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP(
|
||||
name="PromptServer",
|
||||
on_duplicate_prompts="error" # Raise an error if a prompt name is duplicated
|
||||
on_duplicate="error" # Raise an error on an exact duplicate
|
||||
)
|
||||
|
||||
@mcp.prompt
|
||||
|
|
|
|||
|
|
@ -70,12 +70,6 @@ When a client requests a component by name or URI, FastMCP queries providers and
|
|||
- [Proxy a remote server](/servers/providers/proxy) through yours
|
||||
- [Control visibility state](/servers/visibility) of components
|
||||
- [Build dynamic sources](/servers/providers/custom) like database-backed tools
|
||||
- [Transform components](/servers/transforms/transforms) to namespace, rename, or modify them
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Local](/servers/providers/local) - How decorators work
|
||||
- [Mounting](/servers/composition) - Compose servers together
|
||||
- [Proxying](/servers/providers/proxy) - Connect to remote servers
|
||||
- [Transforms](/servers/transforms/transforms) - Namespace, rename, and modify components
|
||||
- [Visibility](/servers/visibility) - Control which components clients can access
|
||||
- [Custom](/servers/providers/custom) - Build your own providers
|
||||
The decorators you already use are themselves a provider: [`LocalProvider`](/servers/providers/local) is what backs `@mcp.tool` and its siblings.
|
||||
|
|
|
|||
|
|
@ -240,6 +240,10 @@ proxy = create_proxy(
|
|||
|
||||
A modern client here reaches both `weather` and `calendar` on modern sessions, so a guard tool on either one round-trips end to end. An explicit `mode` pins every backend in the configuration, the same way it pins a single one.
|
||||
|
||||
### Request Metadata
|
||||
|
||||
Request `_meta` follows the same connection boundary. Progress tokens, tracing, task state, and application or vendor metadata pass through the proxy to the backend. The connection-owned keys — protocol version, client identity, and client capabilities — never copy from the frontend connection: a modern backend session stamps its own negotiated values, and a handshake-era backend receives none. This holds even when the two connections negotiate different eras, such as a modern client reaching a handshake-only backend through an explicit `mode`.
|
||||
|
||||
## Configuration-Based Proxies
|
||||
|
||||
<VersionBadge version="2.4.0" />
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ mcp = FastMCP(name="DataServer")
|
|||
)
|
||||
def get_application_status() -> str:
|
||||
"""Internal function description (ignored if description is provided above)."""
|
||||
return json.dumps({"status": "ok", "uptime": 12345, "version": mcp.settings.version})
|
||||
return json.dumps({"status": "ok", "uptime": 12345, "version": "2.1"})
|
||||
```
|
||||
|
||||
<Card icon="code" title="@resource Decorator Arguments">
|
||||
|
|
@ -793,14 +793,14 @@ A resource or resource template can ask the client for information before it pro
|
|||
|
||||
<VersionBadge version="2.1.0" />
|
||||
|
||||
You can configure how the FastMCP server handles attempts to register multiple resources or templates with the same URI. Use the `on_duplicate_resources` setting during `FastMCP` initialization.
|
||||
You can configure how the FastMCP server handles attempts to register the same resource or template twice. Identity is the component's type, URI, and version together, so two versions of one resource coexist and only an exact repeat collides. The `on_duplicate` setting covers every component type, so it applies to resources and templates alongside tools and prompts.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP(
|
||||
name="ResourceServer",
|
||||
on_duplicate_resources="error" # Raise error on duplicates
|
||||
on_duplicate="error" # Raise an error on an exact duplicate
|
||||
)
|
||||
|
||||
@mcp.resource("data://config")
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ Both parameters are required for production. **Wrap your storage in `FernetEncry
|
|||
|
||||
### Response Caching Middleware
|
||||
|
||||
The [Response Caching Middleware](/servers/middleware#caching-middleware) caches tool calls, resource reads, and prompt requests. Storage configuration is passed via the `cache_storage` parameter:
|
||||
The [Response Caching Middleware](/servers/middleware#caching) caches tool calls, resource reads, and prompt requests. Storage configuration is passed via the `cache_storage` parameter:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
|
@ -289,6 +289,6 @@ This allows clients to reconnect without re-authenticating after restarts.
|
|||
## More Resources
|
||||
|
||||
- [py-key-value-aio GitHub](https://github.com/strawgate/py-key-value) - Full library documentation
|
||||
- [Response Caching Middleware](/servers/middleware#caching-middleware) - Using storage for caching
|
||||
- [Response Caching Middleware](/servers/middleware#caching) - Using storage for caching
|
||||
- [OAuth Token Security](/deployment/http#oauth-token-security) - Production OAuth configuration
|
||||
- [HTTP Deployment](/deployment/http) - Complete deployment guide
|
||||
|
|
|
|||
|
|
@ -225,30 +225,35 @@ A tool can ask the client a question partway through — the same [guard pattern
|
|||
```python
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp_tasks import TasksExtension
|
||||
import mcp.types as mcp_types
|
||||
from mcp.types import (
|
||||
ElicitRequest,
|
||||
ElicitRequestFormParams,
|
||||
ElicitResult,
|
||||
InputRequiredResult,
|
||||
)
|
||||
|
||||
mcp = FastMCP("MyServer")
|
||||
mcp.add_extension(TasksExtension())
|
||||
|
||||
@mcp.tool(task=True)
|
||||
async def plan_dinner(ctx: Context) -> str | mcp_types.InputRequiredResult:
|
||||
async def plan_dinner(ctx: Context) -> str | InputRequiredResult:
|
||||
responses = ctx.input_responses
|
||||
if responses is None:
|
||||
# First leg: ask a question and end here.
|
||||
request = mcp_types.ElicitRequest(
|
||||
params=mcp_types.ElicitRequestFormParams(
|
||||
request = ElicitRequest(
|
||||
params=ElicitRequestFormParams(
|
||||
message="What are you in the mood for?",
|
||||
requested_schema={"type": "object", "properties": {"cuisine": {"type": "string"}}},
|
||||
)
|
||||
)
|
||||
return mcp_types.InputRequiredResult(
|
||||
return InputRequiredResult(
|
||||
result_type="input_required",
|
||||
input_requests={"prefs": request},
|
||||
)
|
||||
|
||||
# Re-entered leg: the client's answer is on ctx.input_responses.
|
||||
answer = responses["prefs"]
|
||||
assert isinstance(answer, mcp_types.ElicitResult)
|
||||
assert isinstance(answer, ElicitResult)
|
||||
return f"Tonight: {answer.content['cuisine']}!"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ def search_products_implementation(query: str, category: str | None = None) -> l
|
|||
</ParamField>
|
||||
|
||||
<ParamField body="title" type="str | None">
|
||||
A human-readable display title for the tool. If omitted, FastMCP falls back to `annotations.title` when present.
|
||||
A human-readable display title for the tool. If omitted, FastMCP falls back to `annotations.title` when present, then to a title derived from the tool's name (e.g. `find_products` becomes "Find Products") — some MCP clients drop tools that have no title at all.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="tags" type="set[str] | None">
|
||||
|
|
@ -431,7 +431,7 @@ def get_user_details(user_id: str = Depends(get_user_id)) -> str:
|
|||
return f"Details for {user_id}"
|
||||
```
|
||||
|
||||
See [Custom Dependencies](/servers/context#custom-dependencies) for more details on dependency injection.
|
||||
See [Custom Dependencies](/servers/dependency-injection#custom-dependencies) for more details on dependency injection.
|
||||
|
||||
## Return Values
|
||||
|
||||
|
|
@ -1081,22 +1081,22 @@ For full documentation on the Context object and all its capabilities, see the [
|
|||
|
||||
<VersionBadge version="2.1.0" />
|
||||
|
||||
You can control how the FastMCP server behaves if you try to register multiple tools with the same name. This is configured using the `on_duplicate_tools` argument when creating the `FastMCP` instance.
|
||||
You can control how the FastMCP server behaves if you register the same component twice. Identity is the component's type, name, and version together, so a tool and a prompt may share a name, and two versions of one tool coexist. Only an exact repeat of all three counts as a duplicate. The `on_duplicate` argument sets that policy once for every component type.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP(
|
||||
name="StrictServer",
|
||||
# Configure behavior for duplicate tool names
|
||||
on_duplicate_tools="error"
|
||||
# Configure behavior for exact component duplicates
|
||||
on_duplicate="error"
|
||||
)
|
||||
|
||||
@mcp.tool
|
||||
def my_tool(): return "Version 1"
|
||||
|
||||
# This will now raise a ValueError because 'my_tool' already exists
|
||||
# and on_duplicate_tools is set to "error".
|
||||
# and on_duplicate is set to "error".
|
||||
# @mcp.tool
|
||||
# def my_tool(): return "Version 2"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ You can cap result count with `default_limit`. The LLM can also override the lim
|
|||
Search(default_limit=5) # return at most 5 results per search
|
||||
```
|
||||
|
||||
If your tools use [tags](/servers/tools#tags), Search also accepts a `tags` parameter so the LLM can narrow results to specific categories before searching.
|
||||
If your tools use [tags](/servers/visibility#tags), Search also accepts a `tags` parameter so the LLM can narrow results to specific categories before searching.
|
||||
|
||||
### GetSchemas
|
||||
|
||||
|
|
@ -148,7 +148,7 @@ If your tools use [tags](/servers/tools#tags), Search also accepts a `tags` para
|
|||
|
||||
### GetTags
|
||||
|
||||
`GetTags` lets the LLM browse tools by category using [tag](/servers/tools#tags) metadata. At brief detail, the LLM sees tag names with counts. At full detail, it sees tools listed under each tag:
|
||||
`GetTags` lets the LLM browse tools by category using [tag](/servers/visibility#tags) metadata. At brief detail, the LLM sees tag names with counts. At full detail, it sees tools listed under each tag:
|
||||
|
||||
```
|
||||
- math (3 tools)
|
||||
|
|
@ -187,7 +187,7 @@ from fastmcp.experimental.transforms.code_mode import CodeMode
|
|||
mcp = FastMCP("Server", transforms=[CodeMode()])
|
||||
```
|
||||
|
||||
If your tools use [tags](/servers/tools#tags), add `GetTags` so the LLM can browse by category before searching — giving it four stages of progressive disclosure:
|
||||
If your tools use [tags](/servers/visibility#tags), add `GetTags` so the LLM can browse by category before searching — giving it four stages of progressive disclosure:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ The answer lies in **standardization**. The AI ecosystem is fragmented. Every mo
|
|||
|
||||
1. **Interoperability:** Build one MCP server, and it can be used by any MCP-compliant client (Claude, Gemini, OpenAI, custom agents, etc.) without custom integration code. This is the protocol's most important promise.
|
||||
2. **Discoverability:** Clients can dynamically ask a server what it's capable of at runtime. They receive a structured, machine-readable "menu" of tools and resources.
|
||||
3. **Security & Safety:** MCP provides a clear, sandboxed boundary. An LLM can't execute arbitrary code on your server; it can only *request* to run the specific, typed, and validated functions you explicitly expose.
|
||||
3. **Explicit boundaries:** MCP gives hosts and servers a typed inventory of the capabilities they expose. That creates a clear place to apply authorization, user confirmation, input validation, and sandboxing; the protocol defines the interface, while your application supplies those security policies.
|
||||
4. **Composability:** You can build small, specialized MCP servers and combine them to create powerful, complex applications.
|
||||
|
||||
## Core MCP Components
|
||||
|
|
@ -111,10 +111,6 @@ def summarize_text(text_to_summarize: str) -> str:
|
|||
|
||||
## Advanced Capabilities
|
||||
|
||||
Beyond the core components, MCP also supports more advanced interaction patterns, such as a server requesting that the *client's* LLM generate a completion (known as **sampling**), or a server sending asynchronous **notifications** to a client. These features enable more complex, bidirectional workflows and are fully supported by FastMCP.
|
||||
Beyond tools, resources, and prompts, MCP supports richer interaction patterns such as notifications, progress updates, user elicitation, and argument completion. Extensions add capabilities such as durable background tasks.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Now that you understand the core concepts of the Model Context Protocol, you're ready to start building. The best place to begin is our step-by-step tutorial.
|
||||
|
||||
[**Tutorial: How to Create an MCP Server in Python →**](/tutorials/create-mcp-server)
|
||||
FastMCP exposes these patterns through typed Python APIs. For example, [elicitation](/servers/elicitation) lets tools request missing information or confirmation, while [background tasks](/servers/tasks) let long-running work continue after the original request returns.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,40 @@ icon: "sparkles"
|
|||
tag: NEW
|
||||
---
|
||||
|
||||
<Update label="FastMCP 3.4.6" description="August 5, 2026" tags={["Releases"]}>
|
||||
<Card
|
||||
title="FastMCP v3.4.6: Trust, but Proxy"
|
||||
href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.6"
|
||||
cta="Read the release notes"
|
||||
>
|
||||
FastMCP 3.4.6 adds trusted-proxy support for SSRF-protected OAuth metadata and JWKS fetches on the 3.x line. Deployments can route these requests through a mandated corporate proxy while preserving custom CA certificates, and FastMCP refuses the fetch when no proxy is configured instead of risking an unprotected direct request.
|
||||
</Card>
|
||||
</Update>
|
||||
|
||||
<Update label="FastMCP 4.0.0b1" description="July 28, 2026" tags={["Releases"]}>
|
||||
<Card
|
||||
title="FastMCP v4.0.0b1: Fourgone Conclusion"
|
||||
href="https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b1"
|
||||
cta="Read the release notes"
|
||||
>
|
||||
FastMCP 4 makes stateful MCP applications work on the sessionless protocol while one deployment continues serving handshake-era clients. The engine underneath changed completely, but FastMCP absorbs nearly all of it — most FastMCP 3 servers upgrade untouched.
|
||||
|
||||
🌐 **Every protocol era** — one server answers both the sessionless `2026-07-28` protocol and the older session-based handshake, negotiated per connection.
|
||||
|
||||
💬 **Interactive tools** — tools ask follow-up questions across complete request-response rounds, with shared request-state keys for load balancing and worker restarts.
|
||||
|
||||
💾 **State without a session** — `UserSession` and `SessionId` give tools explicit server-side state on a protocol that deliberately has none, keyed per user when the request is authenticated.
|
||||
|
||||
⏳ **Background tasks** — the `io.modelcontextprotocol/tasks` extension in the new `fastmcp-tasks` package, on the same Docket engine FastMCP 3 used.
|
||||
|
||||
🧩 **Server extensions** — `add_extension()` turns capability-negotiated protocol features into a supported plugin surface.
|
||||
|
||||
🔐 **Enterprise auth** — server-side identity assertion (SEP-990), `require_roles`, scope step-up challenges, and DCR `application_type`.
|
||||
|
||||
⚠️ **Breaking** — server-initiated sampling and roots are removed from the server API, and the 3.x-era compatibility shims are gone. See the [upgrade guide](/getting-started/upgrading/from-fastmcp-3).
|
||||
</Card>
|
||||
</Update>
|
||||
|
||||
<Update label="FastMCP 3.4.5" description="July 27, 2026" tags={["Releases"]}>
|
||||
<Card
|
||||
title="FastMCP v3.4.5: Key Change"
|
||||
|
|
|
|||
|
|
@ -296,8 +296,10 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
|
|||
**`"remember"` — silent consent on return:**
|
||||
Users see the consent screen on first authorization; subsequent flows from the same browser for the same `(client_id, redirect_uri)` are silently approved via a signed cookie. Cross-site navigations (detected via `Sec-Fetch-Site`) fall back to the prompt. `Sec-Fetch-Site` is a browser-level heuristic rather than a protocol guarantee: an attacker who finds a way to initiate a non-cross-site navigation (XSS on a sibling origin, a same-site redirect chain, etc.) can reach the silent-consent path. `True` does not depend on this signal. See [Confused Deputy Attacks](#confused-deputy-attacks) for the underlying attack class.
|
||||
|
||||
**`"external"` — delegate to upstream:**
|
||||
Skip the built-in consent page; consent is collected by the upstream IdP or a custom login page referenced via `upstream_authorization_endpoint`. No security warning is logged.
|
||||
**`"external"` — externally managed:**
|
||||
Follows the same authorization path as `False`: FastMCP skips its consent page and associated browser-binding protections, then redirects directly to the upstream provider. The difference is logging. `False` emits a security warning, while `"external"` suppresses that warning as an explicit acknowledgment that the operator is enforcing equivalent consent and transaction-binding protections elsewhere. FastMCP does not provide or verify those external protections.
|
||||
|
||||
Ordinary upstream OAuth consent is generally not equivalent. It typically authorizes FastMCP's shared upstream application without identifying the downstream MCP client or binding approval to that client's transaction. Use `"external"` only when your surrounding authorization system supplies those protections.
|
||||
|
||||
**`False` — disable entirely:**
|
||||
Authorization proceeds directly to the upstream provider without any consent UI. Logs a security warning. Only for local development or testing.
|
||||
|
|
@ -317,7 +319,7 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
|
|||
```
|
||||
|
||||
<Warning>
|
||||
Disabling consent removes an important security layer. Only disable for local development or testing environments where you fully control all connecting clients.
|
||||
Both `False` and `"external"` disable FastMCP's consent and browser-binding protections. `False` warns about this configuration; `"external"` suppresses the warning because it is an operator acknowledgment that equivalent protections exist elsewhere. Prefer the default `True` unless you own that external authorization flow.
|
||||
</Warning>
|
||||
</ParamField>
|
||||
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ auth = OIDCProxy(
|
|||
</ParamField>
|
||||
|
||||
<ParamField body="require_authorization_consent" type='bool | Literal["remember", "external"]' default="True">
|
||||
Consent screen behavior for authorization requests. Accepts `True` (default; always prompt — strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (consent handled by upstream IdP or custom page), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/v2/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
|
||||
Consent screen behavior for authorization requests. Accepts `True` (default; always prompt — strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (same authorization path as `False`, but the warning is suppressed because the operator asserts that equivalent protections are enforced externally), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/v2/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="consent_csp_policy" type="str | None" default="None">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ description: The MCP platform from the FastMCP team
|
|||
icon: cloud
|
||||
---
|
||||
|
||||
[Prefect Horizon](https://www.prefect.io/horizon) is a platform for deploying and managing MCP servers. Built by the FastMCP team at [Prefect](https://www.prefect.io), Horizon provides managed hosting, authentication, access control, and a registry of MCP capabilities.
|
||||
[Prefect Horizon](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_horizon&utm_content=v3_guide_intro) is a platform for deploying and managing MCP servers. Built by the FastMCP team at [Prefect](https://www.prefect.io), Horizon provides managed hosting, authentication, access control, and a registry of MCP capabilities.
|
||||
|
||||
Horizon includes a **free personal tier for FastMCP users**, making it the fastest way to get a secure, production-ready server URL with built-in OAuth authentication.
|
||||
|
||||
|
|
|
|||
|
|
@ -310,8 +310,10 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
|
|||
**`"remember"` — silent consent on return:**
|
||||
Users see the consent screen on first authorization; subsequent flows from the same browser for the same `(client_id, redirect_uri)` are silently approved via a signed cookie. Cross-site navigations (detected via `Sec-Fetch-Site`) fall back to the prompt. `Sec-Fetch-Site` is a browser-level heuristic rather than a protocol guarantee: an attacker who finds a way to initiate a non-cross-site navigation (XSS on a sibling origin, a same-site redirect chain, etc.) can reach the silent-consent path. `True` does not depend on this signal. See [Confused Deputy Attacks](#confused-deputy-attacks) for the underlying attack class.
|
||||
|
||||
**`"external"` — delegate to upstream:**
|
||||
Skip the built-in consent page; consent is collected by the upstream IdP or a custom login page referenced via `upstream_authorization_endpoint`. No security warning is logged.
|
||||
**`"external"` — externally managed:**
|
||||
Follows the same authorization path as `False`: FastMCP skips its consent page and associated browser-binding protections, then redirects directly to the upstream provider. The difference is logging. `False` emits a security warning, while `"external"` suppresses that warning as an explicit acknowledgment that the operator is enforcing equivalent consent and transaction-binding protections elsewhere. FastMCP does not provide or verify those external protections.
|
||||
|
||||
Ordinary upstream OAuth consent is generally not equivalent. It typically authorizes FastMCP's shared upstream application without identifying the downstream MCP client or binding approval to that client's transaction. Use `"external"` only when your surrounding authorization system supplies those protections.
|
||||
|
||||
**`False` — disable entirely:**
|
||||
Authorization proceeds directly to the upstream provider without any consent UI. Logs a security warning. Only for local development or testing.
|
||||
|
|
@ -331,7 +333,7 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
|
|||
```
|
||||
|
||||
<Warning>
|
||||
Disabling consent removes an important security layer. Only disable for local development or testing environments where you fully control all connecting clients.
|
||||
Both `False` and `"external"` disable FastMCP's consent and browser-binding protections. `False` warns about this configuration; `"external"` suppresses the warning because it is an operator acknowledgment that equivalent protections exist elsewhere. Prefer the default `True` unless you own that external authorization flow.
|
||||
</Warning>
|
||||
</ParamField>
|
||||
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ auth = OIDCProxy(
|
|||
</ParamField>
|
||||
|
||||
<ParamField body="require_authorization_consent" type='bool | Literal["remember", "external"]' default="True">
|
||||
Consent screen behavior for authorization requests. Accepts `True` (default; always prompt — strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (consent handled by upstream IdP or custom page), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
|
||||
Consent screen behavior for authorization requests. Accepts `True` (default; always prompt — strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (same authorization path as `False`, but the warning is suppressed because the operator asserts that equivalent protections are enforced externally), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="consent_csp_policy" type="str | None" default="None">
|
||||
|
|
|
|||
|
|
@ -6,15 +6,13 @@ from importlib.metadata import PackageNotFoundError, version as _version
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastmcp import _install_hints
|
||||
from fastmcp._warnings import FastMCPDeprecationWarning
|
||||
from fastmcp.settings import Settings
|
||||
from fastmcp.utilities.logging import configure_logging as _configure_logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.client import Client as Client
|
||||
from fastmcp.apps.app import FastMCPApp as FastMCPApp
|
||||
from fastmcp.exceptions import (
|
||||
FastMCPDeprecationWarning as FastMCPDeprecationWarning,
|
||||
)
|
||||
from fastmcp.server.context import Context as Context
|
||||
from fastmcp.server.server import FastMCP as FastMCP
|
||||
|
||||
|
|
@ -39,12 +37,7 @@ except PackageNotFoundError:
|
|||
__version__ = _version("fastmcp")
|
||||
|
||||
if settings.deprecation_warnings:
|
||||
try:
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
warnings.simplefilter("default", FastMCPDeprecationWarning)
|
||||
warnings.simplefilter("default", FastMCPDeprecationWarning)
|
||||
|
||||
|
||||
# --- Lazy imports for performance (see #3292) ---
|
||||
|
|
@ -81,10 +74,6 @@ def __getattr__(name: str) -> object:
|
|||
raise ImportError(_install_hints.APP_SUPPORT) from exc
|
||||
|
||||
return FastMCPApp
|
||||
if name == "FastMCPDeprecationWarning":
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
return FastMCPDeprecationWarning
|
||||
if name == "client":
|
||||
try:
|
||||
return importlib.import_module("fastmcp.client")
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import warnings
|
|||
|
||||
import mcp_types
|
||||
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
from fastmcp._warnings import FastMCPDeprecationWarning
|
||||
|
||||
# Map each SDK model class to the camelCase -> snake_case field reads we bridge.
|
||||
# Limited to fields FastMCP users actually read (docs boundary inventory).
|
||||
|
|
|
|||
10
fastmcp_slim/fastmcp/_warnings.py
Normal file
10
fastmcp_slim/fastmcp/_warnings.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""Warning types that can be imported without loading FastMCP's exception stack."""
|
||||
|
||||
|
||||
class FastMCPDeprecationWarning(DeprecationWarning):
|
||||
"""Deprecation warning for FastMCP APIs.
|
||||
|
||||
Subclass of DeprecationWarning so that standard warning filters
|
||||
still apply, but FastMCP can selectively enable its own warnings
|
||||
without affecting other libraries in the process.
|
||||
"""
|
||||
|
|
@ -97,24 +97,33 @@ def _parse_mcp_servers(
|
|||
if not servers_dict:
|
||||
return []
|
||||
|
||||
normalized = {
|
||||
name: _normalize_server_entry(entry)
|
||||
for name, entry in servers_dict.items()
|
||||
if isinstance(entry, dict)
|
||||
}
|
||||
discovered: list[DiscoveredServer] = []
|
||||
for name, entry in servers_dict.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
try:
|
||||
config = MCPConfig.from_dict({"mcpServers": normalized})
|
||||
except Exception as exc:
|
||||
logger.warning("Could not parse MCP servers from %s: %s", config_path, exc)
|
||||
return []
|
||||
normalized = _normalize_server_entry(entry)
|
||||
try:
|
||||
config = MCPConfig.from_dict({"mcpServers": {name: normalized}})
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not parse MCP server %r from %s: %s",
|
||||
name,
|
||||
config_path,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
return [
|
||||
DiscoveredServer(
|
||||
name=name, source=source, config=server, config_path=config_path
|
||||
discovered.append(
|
||||
DiscoveredServer(
|
||||
name=name,
|
||||
source=source,
|
||||
config=config.mcpServers[name],
|
||||
config_path=config_path,
|
||||
)
|
||||
)
|
||||
for name, server in config.mcpServers.items()
|
||||
]
|
||||
|
||||
return discovered
|
||||
|
||||
|
||||
def _parse_mcp_config(path: Path, source: str) -> list[DiscoveredServer]:
|
||||
|
|
|
|||
|
|
@ -1029,7 +1029,10 @@ class Client(
|
|||
raise RuntimeError(
|
||||
"Session task completed without exception but connection failed"
|
||||
)
|
||||
raise _connection_failure(exception) from exception
|
||||
failure = _connection_failure(exception)
|
||||
if failure is exception:
|
||||
raise exception
|
||||
raise failure from exception
|
||||
|
||||
self._session_state.nesting_counter += 1
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ from typing import Any
|
|||
|
||||
from mcp_types import INTERNAL_ERROR, INVALID_PARAMS, ErrorData
|
||||
|
||||
from fastmcp import _warnings
|
||||
|
||||
try:
|
||||
from mcp import MCPError
|
||||
except ImportError:
|
||||
|
|
@ -30,14 +32,7 @@ except ImportError:
|
|||
# see the migration notes.
|
||||
McpError = MCPError
|
||||
|
||||
|
||||
class FastMCPDeprecationWarning(DeprecationWarning):
|
||||
"""Deprecation warning for FastMCP APIs.
|
||||
|
||||
Subclass of DeprecationWarning so that standard warning filters
|
||||
still apply, but FastMCP can selectively enable its own warnings
|
||||
without affecting other libraries in the process.
|
||||
"""
|
||||
FastMCPDeprecationWarning = _warnings.FastMCPDeprecationWarning
|
||||
|
||||
|
||||
class FastMCPError(Exception):
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ from fastmcp import _install_hints
|
|||
if TYPE_CHECKING:
|
||||
from fastmcp.client.transports import (
|
||||
ClientTransport,
|
||||
FastMCPTransport,
|
||||
SSETransport,
|
||||
StdioTransport,
|
||||
StreamableHttpTransport,
|
||||
|
|
@ -153,7 +154,7 @@ class _TransformingMCPServerMixin(BaseModel):
|
|||
|
||||
return wrapped_mcp_server, transport
|
||||
|
||||
def to_transport(self) -> ClientTransport:
|
||||
def to_transport(self) -> FastMCPTransport:
|
||||
"""Get the transport for the transforming MCP server."""
|
||||
try:
|
||||
from fastmcp.client.transports import FastMCPTransport
|
||||
|
|
@ -209,7 +210,7 @@ class StdioMCPServer(BaseModel):
|
|||
|
||||
model_config = ConfigDict(extra="allow") # Preserve unknown fields
|
||||
|
||||
def to_transport(self) -> StdioTransport:
|
||||
def to_transport(self) -> StdioTransport | FastMCPTransport:
|
||||
from fastmcp.client.transports import StdioTransport
|
||||
|
||||
return StdioTransport(
|
||||
|
|
@ -261,7 +262,9 @@ class RemoteMCPServer(BaseModel):
|
|||
extra="allow", arbitrary_types_allowed=True
|
||||
) # Preserve unknown fields
|
||||
|
||||
def to_transport(self) -> StreamableHttpTransport | SSETransport:
|
||||
def to_transport(
|
||||
self,
|
||||
) -> StreamableHttpTransport | SSETransport | FastMCPTransport:
|
||||
from fastmcp.client.transports import (
|
||||
SSETransport,
|
||||
StreamableHttpTransport,
|
||||
|
|
|
|||
|
|
@ -217,7 +217,10 @@ class FunctionPrompt(Prompt):
|
|||
schema_str = json.dumps(param_schema, separators=(",", ":"))
|
||||
|
||||
# Append schema info to description
|
||||
schema_note = f"Provide as a JSON string matching the following schema: {schema_str}"
|
||||
schema_note = (
|
||||
"Provide a value matching the following JSON schema: "
|
||||
f"{schema_str}. Encode non-string values as JSON."
|
||||
)
|
||||
if arg_description:
|
||||
arg_description = f"{arg_description}\n\n{schema_note}"
|
||||
else:
|
||||
|
|
@ -263,26 +266,38 @@ class FunctionPrompt(Prompt):
|
|||
if param_name in sig.parameters:
|
||||
param = sig.parameters[param_name]
|
||||
|
||||
# If parameter has no annotation or annotation is str, pass as-is
|
||||
if (
|
||||
param.annotation == inspect.Parameter.empty
|
||||
or param.annotation is str
|
||||
) or not isinstance(param_value, str):
|
||||
if param.annotation == inspect.Parameter.empty or not isinstance(
|
||||
param_value, str
|
||||
):
|
||||
converted_kwargs[param_name] = param_value
|
||||
else:
|
||||
# Try to convert string argument using type adapter
|
||||
try:
|
||||
adapter = get_cached_typeadapter(param.annotation)
|
||||
# Try JSON parsing first for complex types
|
||||
# Preserve the MCP wire string when validation keeps it
|
||||
# as a string. Non-string results still prefer JSON
|
||||
# decoding so coercible types such as bytes and Path do
|
||||
# not retain JSON quote characters.
|
||||
try:
|
||||
python_value = adapter.validate_python(param_value)
|
||||
except (ValueError, TypeError, pydantic_core.ValidationError):
|
||||
converted_kwargs[param_name] = adapter.validate_json(
|
||||
param_value
|
||||
)
|
||||
except (ValueError, TypeError, pydantic_core.ValidationError):
|
||||
# Fallback to direct validation
|
||||
converted_kwargs[param_name] = adapter.validate_python(
|
||||
param_value
|
||||
)
|
||||
else:
|
||||
if isinstance(python_value, str):
|
||||
converted_kwargs[param_name] = python_value
|
||||
else:
|
||||
try:
|
||||
converted_kwargs[param_name] = (
|
||||
adapter.validate_json(param_value)
|
||||
)
|
||||
except (
|
||||
ValueError,
|
||||
TypeError,
|
||||
pydantic_core.ValidationError,
|
||||
):
|
||||
converted_kwargs[param_name] = python_value
|
||||
except (ValueError, TypeError, pydantic_core.ValidationError) as e:
|
||||
# If conversion fails, provide informative error
|
||||
raise PromptError(
|
||||
|
|
|
|||
|
|
@ -1,17 +1,31 @@
|
|||
import importlib
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastmcp import _install_hints
|
||||
|
||||
try:
|
||||
from .context import Context
|
||||
from .server import FastMCP, create_proxy
|
||||
except ImportError as exc:
|
||||
raise ImportError(_install_hints.SERVER_SUPPORT) from exc
|
||||
if TYPE_CHECKING:
|
||||
from .context import Context as Context
|
||||
from .server import FastMCP as FastMCP
|
||||
from .server import create_proxy as create_proxy
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
if name == "dependencies":
|
||||
return importlib.import_module("fastmcp.server.dependencies")
|
||||
if name in {"context", "dependencies"}:
|
||||
return importlib.import_module(f"fastmcp.server.{name}")
|
||||
if name == "Context":
|
||||
try:
|
||||
from .context import Context
|
||||
except ImportError as exc:
|
||||
raise ImportError(_install_hints.SERVER_SUPPORT) from exc
|
||||
|
||||
return Context
|
||||
if name in {"FastMCP", "create_proxy"}:
|
||||
try:
|
||||
from .server import FastMCP, create_proxy
|
||||
except ImportError as exc:
|
||||
raise ImportError(_install_hints.SERVER_SUPPORT) from exc
|
||||
|
||||
return FastMCP if name == "FastMCP" else create_proxy
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -386,7 +386,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
|
|||
If None, an encrypted file store will be created in the data directory.
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes).
|
||||
If bytes are provided, they will be used as-is.
|
||||
If a string is provided, it will be derived into a 32-byte key using PBKDF2 (1.2M iterations).
|
||||
If a string is provided, it will be derived into a 32-byte key using PBKDF2 (1,000,000 iterations).
|
||||
If not provided, it will be derived from the upstream client secret using HKDF.
|
||||
require_authorization_consent: Consent screen behavior (default True).
|
||||
- True: always show the consent screen before redirecting to the
|
||||
|
|
@ -397,8 +397,10 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
|
|||
redirect_uri) in the same browser. Cross-site navigations are
|
||||
still prompted to block AS-in-the-middle attacks. Lower UX
|
||||
friction, but weaker protection than True.
|
||||
- "external": skip the built-in consent screen; consent is handled
|
||||
externally (e.g. by the upstream IdP or a custom login page).
|
||||
- "external": follow the same authorization path as False, but
|
||||
suppress the warning as an operator acknowledgment that equivalent
|
||||
consent and transaction-binding protections are enforced externally.
|
||||
FastMCP does not provide or verify those external protections.
|
||||
- False: skip consent entirely. SECURITY WARNING: only set to
|
||||
False for local development or testing environments.
|
||||
consent_csp_policy: Content Security Policy for the consent page.
|
||||
|
|
|
|||
|
|
@ -43,8 +43,7 @@ class AsyncOAuth2Client:
|
|||
Drop-in replacement for the slice of authlib's `AsyncOAuth2Client` that
|
||||
`OAuthProxy` uses. Subclasses of `OAuthProxy` that override
|
||||
`_create_upstream_oauth_client` may return any object with the same
|
||||
`fetch_token`/`refresh_token`/`client_secret`/`aclose` surface (including
|
||||
an authlib client, if legacy httpx is installed in their environment).
|
||||
`fetch_token`/`refresh_token`/`client_secret`/`aclose` surface.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
|
|
|||
|
|
@ -306,8 +306,9 @@ class OIDCProxy(OAuthProxy):
|
|||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to the upstream IdP.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
When "external", authorization follows the same direct path as False,
|
||||
but the warning is suppressed as an operator acknowledgment that
|
||||
equivalent protections are enforced externally.
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
consent_csp_policy: Content Security Policy for the consent page.
|
||||
If None (default), uses the built-in CSP policy with appropriate directives.
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ class Auth0Provider(OIDCProxy):
|
|||
fallback_refresh_token_expiry_seconds: int | None = None,
|
||||
fastmcp_access_token_expiry_seconds: int | None = None,
|
||||
token_expiry_threshold_seconds: int = 0,
|
||||
enable_cimd: bool = True,
|
||||
) -> None:
|
||||
"""Initialize Auth0 OAuth provider.
|
||||
|
||||
|
|
@ -134,8 +135,9 @@ class Auth0Provider(OIDCProxy):
|
|||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to Auth0.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
When "external", authorization follows the same direct path as False,
|
||||
but the warning is suppressed as an operator acknowledgment that
|
||||
equivalent protections are enforced externally.
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued
|
||||
refresh token when the upstream provider omits `refresh_expires_in`
|
||||
|
|
@ -148,6 +150,8 @@ class Auth0Provider(OIDCProxy):
|
|||
refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details.
|
||||
token_expiry_threshold_seconds: Number of seconds before actual expiry to
|
||||
treat a token as expired, refreshing early to avoid races. Defaults to 0.
|
||||
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
|
||||
client IDs (default True). Set to False to disable.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
auth0_required_scopes = (
|
||||
|
|
@ -174,6 +178,7 @@ class Auth0Provider(OIDCProxy):
|
|||
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
|
||||
fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds,
|
||||
token_expiry_threshold_seconds=token_expiry_threshold_seconds,
|
||||
enable_cimd=enable_cimd,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ class AWSCognitoProvider(OIDCProxy):
|
|||
fallback_refresh_token_expiry_seconds: int | None = None,
|
||||
fastmcp_access_token_expiry_seconds: int | None = None,
|
||||
token_expiry_threshold_seconds: int = 0,
|
||||
enable_cimd: bool = True,
|
||||
):
|
||||
"""Initialize AWS Cognito OAuth provider.
|
||||
|
||||
|
|
@ -174,8 +175,9 @@ class AWSCognitoProvider(OIDCProxy):
|
|||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to AWS Cognito.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
When "external", authorization follows the same direct path as False,
|
||||
but the warning is suppressed as an operator acknowledgment that
|
||||
equivalent protections are enforced externally.
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued
|
||||
refresh token when the upstream provider omits `refresh_expires_in`
|
||||
|
|
@ -188,6 +190,8 @@ class AWSCognitoProvider(OIDCProxy):
|
|||
refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details.
|
||||
token_expiry_threshold_seconds: Number of seconds before actual expiry to
|
||||
treat a token as expired, refreshing early to avoid races. Defaults to 0.
|
||||
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
|
||||
client IDs (default True). Set to False to disable.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
required_scopes_final = (
|
||||
|
|
@ -223,6 +227,7 @@ class AWSCognitoProvider(OIDCProxy):
|
|||
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
|
||||
fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds,
|
||||
token_expiry_threshold_seconds=token_expiry_threshold_seconds,
|
||||
enable_cimd=enable_cimd,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
|
|
|
|||
|
|
@ -173,8 +173,9 @@ class AzureProvider(OAuthProxy):
|
|||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to Azure.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
When "external", authorization follows the same direct path as False,
|
||||
but the warning is suppressed as an operator acknowledgment that
|
||||
equivalent protections are enforced externally.
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
http_client: Optional httpx2.AsyncClient for connection pooling in JWKS fetches.
|
||||
When provided, the client is reused for JWT key fetches and the caller
|
||||
|
|
|
|||
|
|
@ -327,8 +327,9 @@ class ClerkProvider(OAuthProxy):
|
|||
into a 32-byte key. If not provided, the upstream client secret will be used to
|
||||
derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing
|
||||
clients (default True). When "external", the built-in consent screen is skipped
|
||||
but no warning is logged, indicating that consent is handled externally by Clerk.
|
||||
clients (default True). When "external", authorization follows the same direct
|
||||
path as False, but the warning is suppressed as an operator acknowledgment that
|
||||
equivalent protections are enforced externally.
|
||||
consent_csp_policy: Custom CSP policy for the consent page.
|
||||
extra_authorize_params: Additional parameters to forward to Clerk's authorization
|
||||
endpoint. Example: {"prompt": "login"} to force re-authentication.
|
||||
|
|
|
|||
|
|
@ -241,8 +241,9 @@ class DiscordProvider(OAuthProxy):
|
|||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to Discord.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
When "external", authorization follows the same direct path as False,
|
||||
but the warning is suppressed as an operator acknowledgment that
|
||||
equivalent protections are enforced externally.
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
http_client: Optional httpx2.AsyncClient for connection pooling in token verification.
|
||||
When provided, the client is reused across verify_token calls and the caller
|
||||
|
|
|
|||
|
|
@ -257,8 +257,9 @@ class GitHubProvider(OAuthProxy):
|
|||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to GitHub.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
When "external", authorization follows the same direct path as False,
|
||||
but the warning is suppressed as an operator acknowledgment that
|
||||
equivalent protections are enforced externally.
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
http_client: Optional httpx2.AsyncClient for connection pooling in token verification.
|
||||
When provided, the client is reused across verify_token calls and the caller
|
||||
|
|
|
|||
|
|
@ -290,8 +290,9 @@ class GoogleProvider(OAuthProxy):
|
|||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to Google.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by Google's own consent).
|
||||
When "external", authorization follows the same direct path as False,
|
||||
but the warning is suppressed as an operator acknowledgment that
|
||||
equivalent protections are enforced externally.
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
extra_authorize_params: Additional parameters to forward to Google's authorization endpoint.
|
||||
By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import contextlib
|
|||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, TypeAlias, cast
|
||||
from typing import Any, Literal, TypeAlias, cast
|
||||
|
||||
import httpx2
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
|
@ -29,22 +29,30 @@ JWKKeyData: TypeAlias = dict[str, str | list[str]]
|
|||
SUPPORTED_JWS_HEADER_FIELDS = frozenset(JWS_HEADER_REGISTRY)
|
||||
|
||||
|
||||
def _import_key_for_algorithm(key: str | bytes | JWKKeyData, algorithm: str):
|
||||
def _key_type_for_algorithm(algorithm: str) -> Literal["oct", "RSA", "EC", "OKP"]:
|
||||
if algorithm.startswith("HS"):
|
||||
return jwk.import_key(key, "oct")
|
||||
return "oct"
|
||||
if algorithm.startswith(("RS", "PS")):
|
||||
return jwk.import_key(key, "RSA")
|
||||
return "RSA"
|
||||
if algorithm.startswith("ES"):
|
||||
return jwk.import_key(key, "EC")
|
||||
return "EC"
|
||||
if algorithm in {"EdDSA", "Ed25519", "Ed448"}:
|
||||
return "OKP"
|
||||
raise ValueError(f"Unsupported algorithm: {algorithm}.")
|
||||
|
||||
|
||||
def _import_key_for_algorithm(key: str | bytes | JWKKeyData, algorithm: str):
|
||||
return jwk.import_key(key, _key_type_for_algorithm(algorithm))
|
||||
|
||||
|
||||
def _jwk_to_pem(key_data: JWKKeyData) -> str:
|
||||
key_type = key_data.get("kty")
|
||||
if key_type == "RSA":
|
||||
return jwk.import_key(key_data, "RSA").as_pem().decode("utf-8")
|
||||
if key_type == "EC":
|
||||
return jwk.import_key(key_data, "EC").as_pem().decode("utf-8")
|
||||
if key_type == "OKP":
|
||||
return jwk.import_key(key_data, "OKP").as_pem().decode("utf-8")
|
||||
raise ValueError(f"Unsupported JWK key type: {key_type!r}")
|
||||
|
||||
|
||||
|
|
@ -72,6 +80,8 @@ class JWKData(TypedDict, total=False):
|
|||
alg: str # Algorithm (e.g., "RS256")
|
||||
n: str # Modulus (for RSA keys)
|
||||
e: str # Exponent (for RSA keys)
|
||||
crv: str # Curve name (for EC and OKP keys)
|
||||
x: str # Public key coordinate (for EC and OKP keys)
|
||||
x5c: list[str] # X.509 certificate chain (for JWKs)
|
||||
x5t: str # X.509 certificate thumbprint (for JWKs)
|
||||
|
||||
|
|
@ -194,10 +204,11 @@ def _looks_like_pem_public_key(key: str | bytes) -> bool:
|
|||
|
||||
class JWTVerifier(TokenVerifier):
|
||||
"""
|
||||
JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms.
|
||||
JWT token verifier supporting asymmetric (RSA/ECDSA/EdDSA) and symmetric (HMAC) algorithms.
|
||||
|
||||
This verifier validates JWT tokens using various signing algorithms:
|
||||
- **Asymmetric algorithms** (RS256/384/512, ES256/384/512, PS256/384/512):
|
||||
- **Asymmetric algorithms** (RS256/384/512, ES256/384/512, PS256/384/512,
|
||||
Ed25519, Ed448, and legacy EdDSA):
|
||||
Uses public/private key pairs. Ideal for external clients and services where
|
||||
only the authorization server has the private key.
|
||||
- **Symmetric algorithms** (HS256/384/512): Uses a shared secret for both
|
||||
|
|
@ -232,7 +243,7 @@ class JWTVerifier(TokenVerifier):
|
|||
jwks_uri: URI to fetch a JSON Web Key Set; used when verifying tokens with remote JWKS.
|
||||
issuer: Expected issuer claim value or list of allowed issuer values.
|
||||
audience: Expected audience claim value or list of allowed audience values.
|
||||
algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512.
|
||||
algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512, Ed25519, Ed448, and legacy EdDSA.
|
||||
required_scopes: Scopes that must be present in validated tokens.
|
||||
base_url: Base URL passed to the parent TokenVerifier.
|
||||
ssrf_safe: If True, JWKS fetches use SSRF protection (HTTPS-only,
|
||||
|
|
@ -275,6 +286,9 @@ class JWTVerifier(TokenVerifier):
|
|||
"PS256",
|
||||
"PS384",
|
||||
"PS512",
|
||||
"EdDSA",
|
||||
"Ed25519",
|
||||
"Ed448",
|
||||
}:
|
||||
raise ValueError(f"Unsupported algorithm: {algorithm}.")
|
||||
|
||||
|
|
@ -347,19 +361,31 @@ class JWTVerifier(TokenVerifier):
|
|||
try:
|
||||
jwks_data = await self._fetch_jwks()
|
||||
|
||||
# Cache all usable keys. A key that cannot be converted (e.g. an
|
||||
# unsupported kty like OKP/Ed25519) is skipped rather than failing
|
||||
# the whole set — per RFC 7517 §5, clients should ignore JWKs they
|
||||
# don't understand. Otherwise one exotic key published by the
|
||||
# authorization server would reject every token, including ones
|
||||
# signed by supported keys in the same set (#4515).
|
||||
# Cache all usable keys. A key that cannot be converted is skipped
|
||||
# rather than failing the whole set — per RFC 7517 §5, clients
|
||||
# should ignore JWKs they don't understand. Otherwise one exotic
|
||||
# key published by the authorization server would reject every
|
||||
# token, including ones signed by supported keys in the same set
|
||||
# (#4515).
|
||||
self._jwks_cache = {}
|
||||
skipped_kids: set[str] = set()
|
||||
expected_key_type = _key_type_for_algorithm(self.algorithm)
|
||||
for key_data in jwks_data.get("keys", []):
|
||||
if not isinstance(key_data, dict):
|
||||
self.logger.debug("Skipping non-object JWKS entry: %r", key_data)
|
||||
continue
|
||||
key_kid = key_data.get("kid")
|
||||
if key_data.get("kty") != expected_key_type:
|
||||
self.logger.debug(
|
||||
"Skipping JWKS key %r: key type %r is incompatible "
|
||||
"with algorithm %s",
|
||||
key_kid,
|
||||
key_data.get("kty"),
|
||||
self.algorithm,
|
||||
)
|
||||
if key_kid:
|
||||
skipped_kids.add(key_kid)
|
||||
continue
|
||||
try:
|
||||
public_key = _jwk_to_pem(key_data)
|
||||
except (JoseError, TypeError, KeyError, ValueError) as e:
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ class OCIProvider(OIDCProxy):
|
|||
fallback_refresh_token_expiry_seconds: int | None = None,
|
||||
fastmcp_access_token_expiry_seconds: int | None = None,
|
||||
token_expiry_threshold_seconds: int = 0,
|
||||
enable_cimd: bool = True,
|
||||
) -> None:
|
||||
"""Initialize OCI OIDC provider.
|
||||
|
||||
|
|
@ -174,6 +175,8 @@ class OCIProvider(OIDCProxy):
|
|||
refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details.
|
||||
token_expiry_threshold_seconds: Number of seconds before actual expiry to
|
||||
treat a token as expired, refreshing early to avoid races. Defaults to 0.
|
||||
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
|
||||
client IDs (default True). Set to False to disable.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
oci_required_scopes = (
|
||||
|
|
@ -200,6 +203,7 @@ class OCIProvider(OIDCProxy):
|
|||
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,
|
||||
fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds,
|
||||
token_expiry_threshold_seconds=token_expiry_threshold_seconds,
|
||||
enable_cimd=enable_cimd,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
|
|
|
|||
|
|
@ -213,8 +213,9 @@ class WorkOSProvider(OAuthProxy):
|
|||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to WorkOS.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
When "external", authorization follows the same direct path as False,
|
||||
but the warning is suppressed as an operator acknowledgment that
|
||||
equivalent protections are enforced externally.
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
extra_authorize_params: Additional parameters to forward to WorkOS's authorization endpoint.
|
||||
Useful for forcing scopes like `offline_access` so WorkOS issues a refresh token,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ from logging import Logger
|
|||
from typing import Any, Literal, cast, overload
|
||||
|
||||
import mcp_types
|
||||
from key_value.aio.errors import SerializationError
|
||||
from mcp import LoggingLevel, ServerSession
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp_types import (
|
||||
|
|
@ -963,9 +962,8 @@ class Context:
|
|||
*,
|
||||
response_title: str | None = None,
|
||||
response_description: str | None = None,
|
||||
) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ...
|
||||
|
||||
"""The accepted elicitation will contain the response data"""
|
||||
) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation:
|
||||
"""The accepted elicitation will contain the response data"""
|
||||
|
||||
@overload
|
||||
async def elicit(
|
||||
|
|
@ -975,10 +973,9 @@ class Context:
|
|||
*,
|
||||
response_title: str | None = None,
|
||||
response_description: str | None = None,
|
||||
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
|
||||
|
||||
"""When response_type is a list of strings, the accepted elicitation will
|
||||
contain the selected string response"""
|
||||
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation:
|
||||
"""When response_type is a list of strings, the accepted elicitation will
|
||||
contain the selected string response"""
|
||||
|
||||
@overload
|
||||
async def elicit(
|
||||
|
|
@ -988,10 +985,9 @@ class Context:
|
|||
*,
|
||||
response_title: str | None = None,
|
||||
response_description: str | None = None,
|
||||
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
|
||||
|
||||
"""When response_type is a dict mapping keys to title dicts, the accepted
|
||||
elicitation will contain the selected key"""
|
||||
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation:
|
||||
"""When response_type is a dict mapping keys to title dicts, the accepted
|
||||
elicitation will contain the selected key"""
|
||||
|
||||
@overload
|
||||
async def elicit(
|
||||
|
|
@ -1001,12 +997,9 @@ class Context:
|
|||
*,
|
||||
response_title: str | None = None,
|
||||
response_description: str | None = None,
|
||||
) -> (
|
||||
AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
|
||||
): ...
|
||||
|
||||
"""When response_type is a list containing a list of strings (multi-select),
|
||||
the accepted elicitation will contain a list of selected strings"""
|
||||
) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation:
|
||||
"""When response_type is a list containing a list of strings (multi-select),
|
||||
the accepted elicitation will contain a list of selected strings"""
|
||||
|
||||
@overload
|
||||
async def elicit(
|
||||
|
|
@ -1016,13 +1009,10 @@ class Context:
|
|||
*,
|
||||
response_title: str | None = None,
|
||||
response_description: str | None = None,
|
||||
) -> (
|
||||
AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
|
||||
): ...
|
||||
|
||||
"""When response_type is a list containing a dict mapping keys to title dicts
|
||||
(multi-select with titles), the accepted elicitation will contain a list of
|
||||
selected keys"""
|
||||
) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation:
|
||||
"""When response_type is a list containing a dict mapping keys to title dicts
|
||||
(multi-select with titles), the accepted elicitation will contain a list of
|
||||
selected keys"""
|
||||
|
||||
async def elicit(
|
||||
self,
|
||||
|
|
@ -1146,10 +1136,9 @@ class Context:
|
|||
value=StateValue(value=value),
|
||||
ttl=self._STATE_TTL_SECONDS,
|
||||
)
|
||||
except (ValueError, SerializationError) as e:
|
||||
except ValueError as e:
|
||||
# Pydantic raises PydanticSerializationError (a ValueError) and the
|
||||
# key_value library raises SerializationError; both carry "serialize"
|
||||
# in the message. Other ValueErrors propagate unchanged.
|
||||
# message carries "serialize". Other ValueErrors propagate unchanged.
|
||||
if "serialize" in str(e).lower():
|
||||
raise TypeError(
|
||||
f"Value for state key {key!r} is not serializable. "
|
||||
|
|
@ -1158,6 +1147,19 @@ class Context:
|
|||
f"request-scoped and will not persist across requests."
|
||||
) from e
|
||||
raise
|
||||
except Exception as e:
|
||||
# Import the optional storage implementation only on its error path,
|
||||
# rather than adding the key_value package to every server startup.
|
||||
from key_value.aio.errors import SerializationError
|
||||
|
||||
if not isinstance(e, SerializationError):
|
||||
raise
|
||||
raise TypeError(
|
||||
f"Value for state key {key!r} is not serializable. "
|
||||
f"Use set_state({key!r}, value, serializable=False) to store "
|
||||
f"non-serializable values. Note: non-serializable state is "
|
||||
f"request-scoped and will not persist across requests."
|
||||
) from e
|
||||
|
||||
async def get_state(self, key: str) -> Any:
|
||||
"""Get a value from the state store.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ from mcp.server.streamable_http import EventStore as SDKEventStore
|
|||
from mcp_types import JSONRPCMessage
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from fastmcp.server.session_scoped_event_store import (
|
||||
SessionScopedEventStore as SessionScopedEventStore,
|
||||
)
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.types import FastMCPBaseModel
|
||||
|
||||
|
|
@ -42,58 +45,6 @@ class StreamEventList(FastMCPBaseModel):
|
|||
event_ids: list[str]
|
||||
|
||||
|
||||
class SessionScopedEventStore(SDKEventStore):
|
||||
"""EventStore adapter that isolates stream IDs to one transport session."""
|
||||
|
||||
def __init__(self, event_store: SDKEventStore, session_id: str):
|
||||
self._event_store = event_store
|
||||
self._stream_prefix = f"{len(session_id)}:{session_id}:"
|
||||
|
||||
def _scope_stream_id(self, stream_id: StreamId) -> StreamId:
|
||||
return f"{self._stream_prefix}{stream_id}"
|
||||
|
||||
def _unscope_stream_id(self, stream_id: StreamId) -> StreamId | None:
|
||||
if not stream_id.startswith(self._stream_prefix):
|
||||
return None
|
||||
return stream_id[len(self._stream_prefix) :]
|
||||
|
||||
async def store_event(
|
||||
self, stream_id: StreamId, message: JSONRPCMessage | None
|
||||
) -> EventId:
|
||||
return await self._event_store.store_event(
|
||||
self._scope_stream_id(stream_id), message
|
||||
)
|
||||
|
||||
async def replay_events_after(
|
||||
self,
|
||||
last_event_id: EventId,
|
||||
send_callback: EventCallback,
|
||||
) -> StreamId | None:
|
||||
replayed_events: list[EventMessage] = []
|
||||
|
||||
async def buffer_event(event: EventMessage) -> None:
|
||||
replayed_events.append(event)
|
||||
|
||||
scoped_stream_id = await self._event_store.replay_events_after(
|
||||
last_event_id, buffer_event
|
||||
)
|
||||
if scoped_stream_id is None:
|
||||
return None
|
||||
|
||||
stream_id = self._unscope_stream_id(scoped_stream_id)
|
||||
if stream_id is None:
|
||||
logger.warning(
|
||||
"Event ID %s does not belong to this session-scoped event store",
|
||||
last_event_id,
|
||||
)
|
||||
return None
|
||||
|
||||
for event in replayed_events:
|
||||
await send_callback(event)
|
||||
|
||||
return stream_id
|
||||
|
||||
|
||||
class EventStore(SDKEventStore):
|
||||
"""EventStore implementation backed by AsyncKeyValue.
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from starlette.types import ASGIApp, Lifespan, Receive, Scope, Send
|
|||
|
||||
from fastmcp.server.auth import AuthProvider
|
||||
from fastmcp.server.auth.middleware import RequireAuthMiddleware
|
||||
from fastmcp.server.event_store import SessionScopedEventStore
|
||||
from fastmcp.server.session_scoped_event_store import SessionScopedEventStore
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""A middleware for response caching."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from logging import Logger
|
||||
from typing import Any, TypedDict
|
||||
|
|
@ -354,7 +355,11 @@ class ResponseCachingMiddleware(Middleware):
|
|||
|
||||
cache_key: str = _get_auth_partition_key()
|
||||
|
||||
if cached_value := await self._list_tools_cache.get(key=cache_key):
|
||||
# an empty list is a cached result, not a miss: `get` returns None when the key is
|
||||
# absent, so testing truthiness would re-list on every request for any caller whose
|
||||
# filtered view is empty
|
||||
cached_value = await self._list_tools_cache.get(key=cache_key)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
tools: Sequence[Tool] = await call_next(context)
|
||||
|
|
@ -383,7 +388,9 @@ class ResponseCachingMiddleware(Middleware):
|
|||
|
||||
cache_key: str = _get_auth_partition_key()
|
||||
|
||||
if cached_value := await self._list_resources_cache.get(key=cache_key):
|
||||
# an empty list is a cached result, not a miss (see on_list_tools)
|
||||
cached_value = await self._list_resources_cache.get(key=cache_key)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
resources: Sequence[Resource] = await call_next(context)
|
||||
|
|
@ -414,7 +421,9 @@ class ResponseCachingMiddleware(Middleware):
|
|||
|
||||
cache_key: str = _get_auth_partition_key()
|
||||
|
||||
if cached_value := await self._list_prompts_cache.get(key=cache_key):
|
||||
# an empty list is a cached result, not a miss (see on_list_tools)
|
||||
cached_value = await self._list_prompts_cache.get(key=cache_key)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
prompts: Sequence[Prompt] = await call_next(context)
|
||||
|
|
@ -474,6 +483,14 @@ class ResponseCachingMiddleware(Middleware):
|
|||
if not isinstance(tool_result, ToolResult):
|
||||
return tool_result
|
||||
|
||||
# Never cache an error result. A tool that reports failure by returning
|
||||
# is_error=True is describing this attempt, not a stable answer — the
|
||||
# upstream 503 or bad gateway it is reporting is exactly the kind of
|
||||
# thing that clears on retry. Caching it would pin the failure in place
|
||||
# for the full TTL and stop the tool from ever being retried.
|
||||
if tool_result.is_error:
|
||||
return tool_result
|
||||
|
||||
cacheable_tool_result: CacheableToolResult = CacheableToolResult.wrap(
|
||||
value=tool_result
|
||||
)
|
||||
|
|
@ -593,13 +610,19 @@ class ResponseCachingMiddleware(Middleware):
|
|||
|
||||
|
||||
def _get_arguments_str(arguments: dict[str, Any] | None) -> str:
|
||||
"""Get a string representation of the arguments."""
|
||||
"""Get a canonical string representation of the arguments."""
|
||||
|
||||
if arguments is None:
|
||||
return "null"
|
||||
|
||||
try:
|
||||
return pydantic_core.to_json(value=arguments, fallback=str).decode()
|
||||
return json.dumps(
|
||||
pydantic_core.to_jsonable_python(arguments, fallback=str),
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
)
|
||||
|
||||
except TypeError:
|
||||
return repr(arguments)
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@ import anyio
|
|||
import uvicorn
|
||||
from mcp.server.lowlevel.server import NotificationOptions
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.server.streamable_http import EventStore
|
||||
from starlette.middleware import Middleware as ASGIMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import BaseRoute, Route
|
||||
|
||||
import fastmcp
|
||||
from fastmcp.server.event_store import EventStore
|
||||
from fastmcp.server.http import (
|
||||
HostOriginProtection,
|
||||
StarletteWithLifespan,
|
||||
|
|
@ -28,7 +28,6 @@ from fastmcp.server.http import (
|
|||
from fastmcp.server.providers.base import Provider
|
||||
from fastmcp.server.providers.fastmcp_provider import FastMCPProvider
|
||||
from fastmcp.server.providers.wrapped_provider import _WrappedProvider
|
||||
from fastmcp.utilities.cli import log_server_banner
|
||||
from fastmcp.utilities.logging import get_logger, temporary_log_level
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -230,6 +229,8 @@ class TransportMixin:
|
|||
|
||||
# Display server banner
|
||||
if show_banner:
|
||||
from fastmcp.utilities.cli import log_server_banner
|
||||
|
||||
log_server_banner(server=self)
|
||||
|
||||
token = set_transport("stdio")
|
||||
|
|
@ -337,6 +338,8 @@ class TransportMixin:
|
|||
|
||||
# Display server banner
|
||||
if show_banner:
|
||||
from fastmcp.utilities.cli import log_server_banner
|
||||
|
||||
log_server_banner(server=self)
|
||||
uvicorn_config_from_user = uvicorn_config or {}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,17 +28,10 @@ from mcp_types import ToolAnnotations
|
|||
from fastmcp.tools.base import Tool
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
from fastmcp.utilities.authorization import AuthCheck
|
||||
from fastmcp.utilities.prefab import is_prefab_type, prefab_available
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
from fastmcp.utilities.types import AnyFunction, NotSet, NotSetT
|
||||
|
||||
try:
|
||||
from prefab_ui.app import PrefabApp as _PrefabApp
|
||||
from prefab_ui.components.base import Component as _PrefabComponent
|
||||
|
||||
_HAS_PREFAB = True
|
||||
except ImportError:
|
||||
_HAS_PREFAB = False
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.server.providers.local_provider import LocalProvider
|
||||
|
||||
|
|
@ -51,7 +44,7 @@ PREFAB_RENDERER_URI = "ui://prefab/renderer.html"
|
|||
|
||||
def _is_prefab_type(tp: Any) -> bool:
|
||||
"""Check if *tp* is or contains a prefab type, recursing through unions and Annotated."""
|
||||
if isinstance(tp, type) and issubclass(tp, (_PrefabApp, _PrefabComponent)):
|
||||
if is_prefab_type(tp):
|
||||
return True
|
||||
origin = get_origin(tp)
|
||||
if origin is Union or origin is types.UnionType or origin is Annotated:
|
||||
|
|
@ -61,7 +54,7 @@ def _is_prefab_type(tp: Any) -> bool:
|
|||
|
||||
def _has_prefab_return_type(tool: Tool) -> bool:
|
||||
"""Check if a FunctionTool's return type annotation is a prefab type."""
|
||||
if not _HAS_PREFAB or not isinstance(tool, FunctionTool):
|
||||
if not isinstance(tool, FunctionTool):
|
||||
return False
|
||||
rt = tool.return_type
|
||||
if rt is None or rt is inspect.Parameter.empty:
|
||||
|
|
@ -94,13 +87,10 @@ def _maybe_apply_prefab_ui(provider: LocalProvider, tool: Tool) -> None:
|
|||
it. ``app=True``, return-type inference, and ``PrefabAppConfig`` all
|
||||
funnel through the same placeholder marker.
|
||||
"""
|
||||
if not _HAS_PREFAB:
|
||||
return
|
||||
|
||||
meta = tool.meta or {}
|
||||
ui = meta.get("ui")
|
||||
|
||||
if ui is True:
|
||||
if ui is True and prefab_available():
|
||||
# Explicit app=True: stamp the placeholder so the synthesizer finds it.
|
||||
_stamp_prefab_marker(tool)
|
||||
elif ui is None and _has_prefab_return_type(tool):
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ The main server class orchestrates the stateless request building approach:
|
|||
|
||||
```python
|
||||
class FastMCPOpenAPI(FastMCP):
|
||||
def __init__(self, openapi_spec: dict, client: httpx.AsyncClient, **kwargs):
|
||||
def __init__(self, openapi_spec: dict, client: httpx2.AsyncClient, **kwargs):
|
||||
# 1. Parse OpenAPI spec to HTTP routes with pre-calculated schemas
|
||||
self._routes = parse_openapi_to_http_routes(openapi_spec)
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ OpenAPI Spec → HTTPRoute with Pre-calculated Fields → RequestDirector → HT
|
|||
2. **RequestDirector Setup**: openapi-core Spec initialized for request building
|
||||
3. **Component Creation**: Create components with RequestDirector reference
|
||||
4. **Request Building**: RequestDirector builds HTTP request from flat parameters
|
||||
5. **Request Execution**: Execute request with httpx client
|
||||
5. **Request Execution**: Execute request with httpx2 client
|
||||
6. **Response Processing**: Return structured MCP response
|
||||
|
||||
## Key Features
|
||||
|
|
@ -263,4 +263,4 @@ logging.getLogger("fastmcp.server.openapi_new").setLevel(logging.DEBUG)
|
|||
- `/utilities/openapi_new/README.md` - Utility implementation details
|
||||
- `/server/openapi/README.md` - Legacy implementation reference
|
||||
- `/tests/server/openapi_new/` - Comprehensive test suite
|
||||
- Project documentation on OpenAPI integration patterns
|
||||
- Project documentation on OpenAPI integration patterns
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx2
|
||||
from mcp_types import ToolAnnotations
|
||||
|
|
@ -18,11 +18,7 @@ from fastmcp.resources import (
|
|||
)
|
||||
from fastmcp.server.dependencies import get_http_headers
|
||||
from fastmcp.tools.base import Tool, ToolResult
|
||||
from fastmcp.utilities.exceptions import (
|
||||
HTTP_STATUS_ERRORS,
|
||||
REQUEST_ERRORS,
|
||||
TIMEOUT_ERRORS,
|
||||
)
|
||||
from fastmcp.utilities.exceptions import is_request_error, is_timeout_error
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.openapi import HTTPRoute
|
||||
from fastmcp.utilities.openapi.director import RequestDirector
|
||||
|
|
@ -63,6 +59,36 @@ logger = get_logger(__name__)
|
|||
_DEFAULT_MIME_TYPE = "application/json"
|
||||
|
||||
|
||||
def _raise_for_status(response: httpx2.Response) -> None:
|
||||
"""Raise an OpenAPI-formatted error without relying on client exception types."""
|
||||
if 200 <= response.status_code < 300:
|
||||
return
|
||||
|
||||
error_message = f"HTTP error {response.status_code}: {response.reason_phrase}"
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_message += f" - {error_data}"
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
if response.text:
|
||||
error_message += f" - {response.text}"
|
||||
raise ValueError(error_message)
|
||||
|
||||
|
||||
async def _send_request(
|
||||
client: httpx2.AsyncClient,
|
||||
request: httpx2.Request,
|
||||
) -> httpx2.Response:
|
||||
"""Send a request while preserving transitional legacy-client errors."""
|
||||
try:
|
||||
return await client.send(request)
|
||||
except Exception as exc:
|
||||
if is_timeout_error(exc):
|
||||
raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc
|
||||
if is_request_error(exc):
|
||||
raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc
|
||||
raise
|
||||
|
||||
|
||||
def _extract_mime_type_from_route(route: HTTPRoute) -> str:
|
||||
"""Extract the primary MIME type from an HTTPRoute's response definitions.
|
||||
|
||||
|
|
@ -176,12 +202,8 @@ class OpenAPITool(Tool):
|
|||
base_url = str(self._client.base_url) or "http://localhost"
|
||||
directed_request = self._director.build(self._route, arguments, base_url)
|
||||
|
||||
# Rebuild through the user's client so the request object comes
|
||||
# from whichever httpx library the client belongs to (a legacy
|
||||
# httpx.AsyncClient cannot send an httpx2.Request). Primitive
|
||||
# values (str/bytes/tuples) cross that boundary safely; client
|
||||
# default headers merge in with directed headers taking priority,
|
||||
# matching the previous manual merge.
|
||||
# Rebuild through the configured client so its default headers are
|
||||
# merged with the directed headers taking priority.
|
||||
request = self._client.build_request(
|
||||
method=directed_request.method,
|
||||
url=str(directed_request.url.copy_with(query=None)),
|
||||
|
|
@ -210,8 +232,8 @@ class OpenAPITool(Tool):
|
|||
f"run - sending request; headers: {_redact_headers(request.headers)}"
|
||||
)
|
||||
|
||||
response = await self._client.send(request)
|
||||
response.raise_for_status()
|
||||
response = await _send_request(self._client, request)
|
||||
_raise_for_status(response)
|
||||
|
||||
# Try to parse as JSON first
|
||||
try:
|
||||
|
|
@ -238,25 +260,11 @@ class OpenAPITool(Tool):
|
|||
except json.JSONDecodeError:
|
||||
return ToolResult(content=response.text)
|
||||
|
||||
except HTTP_STATUS_ERRORS as e:
|
||||
status_error = cast("httpx2.HTTPStatusError", e)
|
||||
error_message = (
|
||||
f"HTTP error {status_error.response.status_code}: "
|
||||
f"{status_error.response.reason_phrase}"
|
||||
)
|
||||
try:
|
||||
error_data = status_error.response.json()
|
||||
error_message += f" - {error_data}"
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
if status_error.response.text:
|
||||
error_message += f" - {status_error.response.text}"
|
||||
raise ValueError(error_message) from e
|
||||
except httpx2.TimeoutException as exc:
|
||||
raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc
|
||||
|
||||
except TIMEOUT_ERRORS as e:
|
||||
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
|
||||
|
||||
except REQUEST_ERRORS as e:
|
||||
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
|
||||
except httpx2.RequestError as exc:
|
||||
raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc
|
||||
|
||||
|
||||
class OpenAPIResource(Resource):
|
||||
|
|
@ -298,8 +306,7 @@ class OpenAPIResource(Resource):
|
|||
directed_request = self._director.build(
|
||||
self._route, self._arguments, base_url
|
||||
)
|
||||
# Primitive values only: a legacy httpx.AsyncClient cannot accept
|
||||
# httpx2 URL/QueryParams/Headers objects.
|
||||
# Build through the configured client so its defaults are applied.
|
||||
request = self._client.build_request(
|
||||
method=directed_request.method,
|
||||
url=str(directed_request.url.copy_with(query=None)),
|
||||
|
|
@ -314,8 +321,8 @@ class OpenAPIResource(Resource):
|
|||
if mcp_headers:
|
||||
request.headers.update(mcp_headers)
|
||||
|
||||
response = await self._client.send(request)
|
||||
response.raise_for_status()
|
||||
response = await _send_request(self._client, request)
|
||||
_raise_for_status(response)
|
||||
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
|
||||
|
|
@ -343,25 +350,11 @@ class OpenAPIResource(Resource):
|
|||
]
|
||||
)
|
||||
|
||||
except HTTP_STATUS_ERRORS as e:
|
||||
status_error = cast("httpx2.HTTPStatusError", e)
|
||||
error_message = (
|
||||
f"HTTP error {status_error.response.status_code}: "
|
||||
f"{status_error.response.reason_phrase}"
|
||||
)
|
||||
try:
|
||||
error_data = status_error.response.json()
|
||||
error_message += f" - {error_data}"
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
if status_error.response.text:
|
||||
error_message += f" - {status_error.response.text}"
|
||||
raise ValueError(error_message) from e
|
||||
except httpx2.TimeoutException as exc:
|
||||
raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc
|
||||
|
||||
except TIMEOUT_ERRORS as e:
|
||||
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
|
||||
|
||||
except REQUEST_ERRORS as e:
|
||||
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
|
||||
except httpx2.RequestError as exc:
|
||||
raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc
|
||||
|
||||
|
||||
def _path_argument_name(route: HTTPRoute, parameter_name: str) -> str:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from collections import Counter
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
|
|
@ -10,6 +11,7 @@ from typing import Any, Literal, cast
|
|||
import httpx2
|
||||
from jsonschema_path import SchemaPath
|
||||
|
||||
from fastmcp._warnings import FastMCPDeprecationWarning
|
||||
from fastmcp.prompts import Prompt
|
||||
from fastmcp.resources import Resource, ResourceTemplate
|
||||
from fastmcp.server.providers.base import Provider
|
||||
|
|
@ -48,6 +50,14 @@ logger = get_logger(__name__)
|
|||
DEFAULT_TIMEOUT: float = 30.0
|
||||
|
||||
|
||||
def _is_legacy_httpx_client(client: object) -> bool:
|
||||
"""Detect a legacy httpx client without importing the legacy package."""
|
||||
return any(
|
||||
cls.__module__.partition(".")[0] == "httpx" and cls.__name__ == "AsyncClient"
|
||||
for cls in type(client).__mro__
|
||||
)
|
||||
|
||||
|
||||
class OpenAPIProvider(Provider):
|
||||
"""Provider that creates MCP components from an OpenAPI specification.
|
||||
|
||||
|
|
@ -84,10 +94,12 @@ class OpenAPIProvider(Provider):
|
|||
|
||||
Args:
|
||||
openapi_spec: OpenAPI schema as a dictionary
|
||||
client: Optional httpx AsyncClient for making HTTP requests.
|
||||
client: Optional httpx2 AsyncClient for making HTTP requests.
|
||||
If not provided, a default client is created using the first
|
||||
server URL from the OpenAPI spec with a 30-second timeout.
|
||||
To customize timeout or other settings, pass your own client.
|
||||
Legacy httpx clients are temporarily accepted with a deprecation
|
||||
warning.
|
||||
route_maps: Optional list of RouteMap objects defining route mappings
|
||||
route_map_fn: Optional callable for advanced route type mapping
|
||||
mcp_component_fn: Optional callable for component customization
|
||||
|
|
@ -103,6 +115,14 @@ class OpenAPIProvider(Provider):
|
|||
self._owns_client = client is None
|
||||
if client is None:
|
||||
client = self._create_default_client(openapi_spec)
|
||||
elif _is_legacy_httpx_client(client):
|
||||
warnings.warn(
|
||||
"Passing an httpx.AsyncClient to OpenAPIProvider is deprecated "
|
||||
"and will be removed in a future release. Pass an "
|
||||
"httpx2.AsyncClient instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self._client = client
|
||||
self._mcp_component_fn = mcp_component_fn
|
||||
self._validate_output = validate_output
|
||||
|
|
|
|||
|
|
@ -130,6 +130,50 @@ def _proxy_upstream_error(error: Exception) -> MCPError:
|
|||
)
|
||||
|
||||
|
||||
# Request `_meta` keys that describe one negotiated MCP connection. They never
|
||||
# cross the proxy: a modern backend session stamps its own negotiated values on
|
||||
# every request, and a handshake-era backend must not receive them at all.
|
||||
_CONNECTION_META_KEYS = frozenset(
|
||||
{
|
||||
mcp_types.PROTOCOL_VERSION_META_KEY,
|
||||
mcp_types.CLIENT_INFO_META_KEY,
|
||||
mcp_types.CLIENT_CAPABILITIES_META_KEY,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _forwardable_request_meta(ctx: Context | None) -> dict[str, Any] | None:
|
||||
"""Frontend request metadata that may cross onto the backend connection.
|
||||
|
||||
This is the proxy's one sanctioned read of the inbound request's `_meta`:
|
||||
progress tokens, tracing, task, and application metadata pass through,
|
||||
while connection-owned keys (`_CONNECTION_META_KEYS`) are dropped because
|
||||
they describe the frontend connection, not the backend one.
|
||||
"""
|
||||
request_context = ctx.request_context if ctx is not None else None
|
||||
if request_context is None or not request_context.meta:
|
||||
return None
|
||||
forwarded = {
|
||||
key: value
|
||||
for key, value in request_context.meta.items()
|
||||
if key not in _CONNECTION_META_KEYS
|
||||
}
|
||||
return forwarded or None
|
||||
|
||||
|
||||
def _session_request_meta(
|
||||
meta: dict[str, Any] | None,
|
||||
) -> mcp_types.RequestParamsMeta | None:
|
||||
"""Adapt forwardable metadata for a direct backend-session call.
|
||||
|
||||
Direct session calls bypass the high-level client mixins, so trace context
|
||||
is injected here, matching what the mixins do on the legacy client paths.
|
||||
"""
|
||||
return cast(
|
||||
"mcp_types.RequestParamsMeta | None", inject_trace_context(meta) or None
|
||||
)
|
||||
|
||||
|
||||
async def _relay_read_resource(
|
||||
client: Client, uri: str, ctx: Context | None
|
||||
) -> (
|
||||
|
|
@ -143,15 +187,15 @@ async def _relay_read_resource(
|
|||
to forward, instead of the high-level client trying to answer it here — the
|
||||
proxy has no back-channel to the real user, so driving it fails outright.
|
||||
The inbound request's continuation state travels down so the backend guard
|
||||
sees the client's answers on its own `ctx.input_responses`. Trace context
|
||||
still propagates: the SDK's JSON-RPC dispatcher injects it on every outgoing
|
||||
request (SEP-414), below whichever client layer issued the call.
|
||||
sees the client's answers on its own `ctx.input_responses`.
|
||||
"""
|
||||
meta = _forwardable_request_meta(ctx)
|
||||
if client.protocol_version not in MODERN_PROTOCOL_VERSIONS:
|
||||
return await client.read_resource(uri)
|
||||
return await client.read_resource(uri, meta=meta)
|
||||
result = await client._await_with_session_monitoring(
|
||||
client.session.read_resource(
|
||||
uri,
|
||||
meta=_session_request_meta(meta),
|
||||
input_responses=ctx.input_responses if ctx else None,
|
||||
request_state=ctx.request_state if ctx else None,
|
||||
allow_input_required=True,
|
||||
|
|
@ -311,15 +355,11 @@ class ProxyTool(Tool):
|
|||
async with client:
|
||||
ctx = context or get_context()
|
||||
_stash_proxy_request_context(client, ctx)
|
||||
# Forward the inbound request's `_meta` block (trace context,
|
||||
# version, etc.) to the backend. In SDK v2 the request context
|
||||
# exposes the lifted `_meta` dict directly; task submission is a
|
||||
# first-class params field rather than context state, so there
|
||||
# is no separate task-metadata injection here.
|
||||
req_ctx = ctx.request_context
|
||||
meta: dict[str, Any] | None = (
|
||||
dict(req_ctx.meta) if req_ctx is not None and req_ctx.meta else None
|
||||
)
|
||||
# Forward the inbound request's hop-safe `_meta` (trace
|
||||
# context, progress token, etc.) to the backend. Task
|
||||
# submission is a first-class params field rather than context
|
||||
# state, so there is no separate task-metadata injection here.
|
||||
meta = _forwardable_request_meta(ctx)
|
||||
|
||||
if client.protocol_version in MODERN_PROTOCOL_VERSIONS:
|
||||
# Modern backend: call the session directly (not
|
||||
|
|
@ -330,10 +370,7 @@ class ProxyTool(Tool):
|
|||
# round. Forward the inbound request's continuation state
|
||||
# down so the backend guard tool sees the client's answers
|
||||
# on its own `ctx.input_responses` / `ctx.request_state`.
|
||||
request_meta = cast(
|
||||
"mcp_types.RequestParamsMeta | None",
|
||||
inject_trace_context(meta) or None,
|
||||
)
|
||||
request_meta = _session_request_meta(meta)
|
||||
# SEP-2243: a modern backend rejects a `tools/call` whose
|
||||
# `x-mcp-header` argument is not mirrored into an `Mcp-Param-*`
|
||||
# header. The SDK client emits those headers only for tools it
|
||||
|
|
@ -704,6 +741,7 @@ class ProxyPrompt(Prompt):
|
|||
ctx = get_context()
|
||||
async with client:
|
||||
_stash_proxy_request_context(client, ctx)
|
||||
meta = _forwardable_request_meta(ctx)
|
||||
if client.protocol_version in MODERN_PROTOCOL_VERSIONS:
|
||||
# See `_relay_read_resource`: surface a backend guard's ask
|
||||
# instead of trying to answer it inside the proxy.
|
||||
|
|
@ -711,6 +749,7 @@ class ProxyPrompt(Prompt):
|
|||
client.session.get_prompt(
|
||||
backend_name,
|
||||
arguments,
|
||||
meta=_session_request_meta(meta),
|
||||
input_responses=ctx.input_responses if ctx else None,
|
||||
request_state=ctx.request_state if ctx else None,
|
||||
allow_input_required=True,
|
||||
|
|
@ -720,7 +759,7 @@ class ProxyPrompt(Prompt):
|
|||
return InputRequiredPromptResult(raw)
|
||||
result = raw
|
||||
else:
|
||||
result = await client.get_prompt(backend_name, arguments)
|
||||
result = await client.get_prompt(backend_name, arguments, meta=meta)
|
||||
# Convert GetPromptResult to PromptResult, preserving meta from result
|
||||
# (not the static prompt meta which includes fastmcp tags)
|
||||
# Convert PromptMessages to Messages
|
||||
|
|
|
|||
|
|
@ -20,9 +20,6 @@ from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
|
|||
|
||||
import httpx2
|
||||
import mcp_types
|
||||
from key_value.aio.adapters.pydantic import PydanticAdapter
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from key_value.aio.stores.memory import MemoryStore
|
||||
from mcp.server.lowlevel.server import LifespanResultT
|
||||
from mcp.server.request_state import RequestStateSecurity
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
|
@ -91,7 +88,7 @@ from fastmcp.tools.base import Tool, ToolResult
|
|||
from fastmcp.tools.function_tool import FunctionTool
|
||||
from fastmcp.tools.tool_transform import ToolTransformConfig
|
||||
from fastmcp.utilities.components import FastMCPComponent, _coerce_version
|
||||
from fastmcp.utilities.exceptions import HTTP_STATUS_ERRORS, TIMEOUT_ERRORS
|
||||
from fastmcp.utilities.exceptions import get_http_status_code, is_timeout_error
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
from fastmcp.utilities.types import AnyFunction, FastMCPBaseModel, NotSet, NotSetT
|
||||
|
|
@ -101,6 +98,9 @@ from fastmcp.utilities.versions import (
|
|||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from key_value.aio.adapters.pydantic import PydanticAdapter
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.client import SDKServer
|
||||
from fastmcp.client.transports import ClientTransport, ClientTransportT
|
||||
|
|
@ -112,11 +112,6 @@ if TYPE_CHECKING:
|
|||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Both-library catch tuples for user-supplied code that may still raise legacy
|
||||
# httpx exceptions; see fastmcp.utilities.exceptions for the defensive import.
|
||||
_ACTIONABLE_HTTP_STATUS_ERRORS = HTTP_STATUS_ERRORS
|
||||
_ACTIONABLE_TIMEOUT_ERRORS = TIMEOUT_ERRORS
|
||||
|
||||
|
||||
def _version_request_meta(
|
||||
version: VersionSpec | None,
|
||||
|
|
@ -333,12 +328,8 @@ class FastMCP(
|
|||
self._additional_http_routes: list[BaseRoute] = []
|
||||
|
||||
# Session-scoped state store (shared across all requests)
|
||||
self._state_storage: AsyncKeyValue = session_state_store or MemoryStore()
|
||||
self._state_store: PydanticAdapter[StateValue] = PydanticAdapter[StateValue](
|
||||
key_value=self._state_storage,
|
||||
pydantic_model=StateValue,
|
||||
default_collection="fastmcp_state",
|
||||
)
|
||||
self._state_storage: AsyncKeyValue | None = session_state_store
|
||||
self.__state_store: PydanticAdapter[StateValue] | None = None
|
||||
|
||||
# Create LocalProvider for local components
|
||||
self._local_provider: LocalProvider = LocalProvider(
|
||||
|
|
@ -496,6 +487,22 @@ class FastMCP(
|
|||
def __repr__(self) -> str:
|
||||
return f"{type(self).__name__}({self.name!r})"
|
||||
|
||||
@property
|
||||
def _state_store(self) -> PydanticAdapter[StateValue]:
|
||||
"""Create the session-state adapter only when state is first used."""
|
||||
if self.__state_store is None:
|
||||
from key_value.aio.adapters.pydantic import PydanticAdapter
|
||||
from key_value.aio.stores.memory import MemoryStore
|
||||
|
||||
if self._state_storage is None:
|
||||
self._state_storage = MemoryStore()
|
||||
self.__state_store = PydanticAdapter[StateValue](
|
||||
key_value=self._state_storage,
|
||||
pydantic_model=StateValue,
|
||||
default_collection="fastmcp_state",
|
||||
)
|
||||
return self.__state_store
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._mcp_server.name
|
||||
|
|
@ -1534,15 +1541,11 @@ class FastMCP(
|
|||
logger.exception(f"Error calling tool {name!r}")
|
||||
# Handle actionable errors that should reach the LLM
|
||||
# even when masking is enabled
|
||||
if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS):
|
||||
if (
|
||||
cast("httpx2.HTTPStatusError", e).response.status_code
|
||||
== 429
|
||||
):
|
||||
raise ToolError(
|
||||
"Rate limited by upstream API, please retry later"
|
||||
) from e
|
||||
if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
|
||||
if get_http_status_code(e) == 429:
|
||||
raise ToolError(
|
||||
"Rate limited by upstream API, please retry later"
|
||||
) from e
|
||||
if is_timeout_error(e):
|
||||
raise ToolError(
|
||||
"Upstream request timed out, please retry"
|
||||
) from e
|
||||
|
|
@ -1637,15 +1640,11 @@ class FastMCP(
|
|||
except Exception as e:
|
||||
logger.exception(f"Error reading resource {uri!r}")
|
||||
# Handle actionable errors that should reach the LLM
|
||||
if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS):
|
||||
if (
|
||||
cast("httpx2.HTTPStatusError", e).response.status_code
|
||||
== 429
|
||||
):
|
||||
raise ResourceError(
|
||||
"Rate limited by upstream API, please retry later"
|
||||
) from e
|
||||
if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
|
||||
if get_http_status_code(e) == 429:
|
||||
raise ResourceError(
|
||||
"Rate limited by upstream API, please retry later"
|
||||
) from e
|
||||
if is_timeout_error(e):
|
||||
raise ResourceError(
|
||||
"Upstream request timed out, please retry"
|
||||
) from e
|
||||
|
|
@ -1700,15 +1699,11 @@ class FastMCP(
|
|||
except Exception as e:
|
||||
logger.exception(f"Error reading resource {uri!r}")
|
||||
# Handle actionable errors that should reach the LLM
|
||||
if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS):
|
||||
if (
|
||||
cast("httpx2.HTTPStatusError", e).response.status_code
|
||||
== 429
|
||||
):
|
||||
raise ResourceError(
|
||||
"Rate limited by upstream API, please retry later"
|
||||
) from e
|
||||
if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
|
||||
if get_http_status_code(e) == 429:
|
||||
raise ResourceError(
|
||||
"Rate limited by upstream API, please retry later"
|
||||
) from e
|
||||
if is_timeout_error(e):
|
||||
raise ResourceError(
|
||||
"Upstream request timed out, please retry"
|
||||
) from e
|
||||
|
|
@ -2400,10 +2395,10 @@ class FastMCP(
|
|||
Args:
|
||||
openapi_spec: OpenAPI schema as a dictionary
|
||||
client: Optional httpx2 AsyncClient for making HTTP requests.
|
||||
An httpx (v1) AsyncClient is also accepted and works via
|
||||
duck-typing. If not provided, a default client is created
|
||||
using the first
|
||||
If not provided, a default client is created using the first
|
||||
server URL from the OpenAPI spec with a 30-second timeout.
|
||||
Legacy httpx clients are temporarily accepted with a deprecation
|
||||
warning.
|
||||
name: Name for the MCP server
|
||||
route_maps: Optional list of RouteMap objects defining route mappings
|
||||
route_map_fn: Optional callable for advanced route type mapping
|
||||
|
|
|
|||
68
fastmcp_slim/fastmcp/server/session_scoped_event_store.py
Normal file
68
fastmcp_slim/fastmcp/server/session_scoped_event_store.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Lightweight session scoping for Streamable HTTP event stores."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp.server.streamable_http import (
|
||||
EventCallback,
|
||||
EventId,
|
||||
EventMessage,
|
||||
EventStore,
|
||||
StreamId,
|
||||
)
|
||||
from mcp_types import JSONRPCMessage
|
||||
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SessionScopedEventStore(EventStore):
|
||||
"""EventStore adapter that isolates stream IDs to one transport session."""
|
||||
|
||||
def __init__(self, event_store: EventStore, session_id: str):
|
||||
self._event_store = event_store
|
||||
self._stream_prefix = f"{len(session_id)}:{session_id}:"
|
||||
|
||||
def _scope_stream_id(self, stream_id: StreamId) -> StreamId:
|
||||
return f"{self._stream_prefix}{stream_id}"
|
||||
|
||||
def _unscope_stream_id(self, stream_id: StreamId) -> StreamId | None:
|
||||
if not stream_id.startswith(self._stream_prefix):
|
||||
return None
|
||||
return stream_id[len(self._stream_prefix) :]
|
||||
|
||||
async def store_event(
|
||||
self, stream_id: StreamId, message: JSONRPCMessage | None
|
||||
) -> EventId:
|
||||
return await self._event_store.store_event(
|
||||
self._scope_stream_id(stream_id), message
|
||||
)
|
||||
|
||||
async def replay_events_after(
|
||||
self,
|
||||
last_event_id: EventId,
|
||||
send_callback: EventCallback,
|
||||
) -> StreamId | None:
|
||||
replayed_events: list[EventMessage] = []
|
||||
|
||||
async def buffer_event(event: EventMessage) -> None:
|
||||
replayed_events.append(event)
|
||||
|
||||
scoped_stream_id = await self._event_store.replay_events_after(
|
||||
last_event_id, buffer_event
|
||||
)
|
||||
if scoped_stream_id is None:
|
||||
return None
|
||||
|
||||
stream_id = self._unscope_stream_id(scoped_stream_id)
|
||||
if stream_id is None:
|
||||
logger.warning(
|
||||
"Event ID %s does not belong to this session-scoped event store",
|
||||
last_event_id,
|
||||
)
|
||||
return None
|
||||
|
||||
for event in replayed_events:
|
||||
await send_callback(event)
|
||||
|
||||
return stream_id
|
||||
|
|
@ -26,6 +26,11 @@ from pydantic.json_schema import SkipJsonSchema
|
|||
from fastmcp.utilities.authorization import AuthCheck
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.prefab import (
|
||||
is_prefab_app,
|
||||
is_prefab_component,
|
||||
prefab_app_from_component,
|
||||
)
|
||||
from fastmcp.utilities.tasks import TaskConfig
|
||||
from fastmcp.utilities.types import (
|
||||
Audio,
|
||||
|
|
@ -35,14 +40,6 @@ from fastmcp.utilities.types import (
|
|||
NotSetT,
|
||||
)
|
||||
|
||||
try:
|
||||
from prefab_ui.app import PrefabApp as _PrefabApp
|
||||
from prefab_ui.components.base import Component as _PrefabComponent
|
||||
|
||||
_HAS_PREFAB = True
|
||||
except ImportError:
|
||||
_HAS_PREFAB = False
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
|
||||
|
|
@ -52,6 +49,16 @@ if TYPE_CHECKING:
|
|||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _default_title(name: str) -> str:
|
||||
"""Derive a display title from a tool name.
|
||||
|
||||
The MCP spec says clients should fall back to `name` for display when
|
||||
`title` is absent, but some clients (e.g. ChatGPT) instead drop the tool
|
||||
entirely. Always emitting a title avoids depending on that fallback.
|
||||
"""
|
||||
return name.replace("_", " ").replace("-", " ").title()
|
||||
|
||||
|
||||
def resolve_serialize_by_alias(value: Any) -> bool:
|
||||
"""Resolve the effective ``by_alias`` setting for serializing *value*.
|
||||
|
||||
|
|
@ -118,13 +125,12 @@ class ToolResult(BaseModel):
|
|||
if structured_content is not None:
|
||||
# Convert Prefab types to their wire-format envelope before
|
||||
# generic serialization, so the renderer gets the right shape.
|
||||
if _HAS_PREFAB:
|
||||
if isinstance(structured_content, _PrefabApp):
|
||||
structured_content = _prefab_to_json(structured_content)
|
||||
elif isinstance(structured_content, _PrefabComponent):
|
||||
structured_content = _prefab_to_json(
|
||||
_PrefabApp(view=structured_content)
|
||||
)
|
||||
if is_prefab_app(structured_content):
|
||||
structured_content = _prefab_to_json(structured_content)
|
||||
elif is_prefab_component(structured_content):
|
||||
structured_content = _prefab_to_json(
|
||||
prefab_app_from_component(structured_content)
|
||||
)
|
||||
|
||||
try:
|
||||
structured_content = pydantic_core.to_jsonable_python(
|
||||
|
|
@ -263,21 +269,28 @@ class Tool(FastMCPComponent):
|
|||
**overrides: Any,
|
||||
) -> MCPTool:
|
||||
"""Convert the FastMCP tool to an MCP tool."""
|
||||
title = None
|
||||
# Title precedence follows the effective (post-override) values, so a
|
||||
# caller renaming or re-annotating a tool doesn't get a stale title.
|
||||
name = overrides.get("name", self.name)
|
||||
annotations = overrides.get("annotations", self.annotations)
|
||||
if isinstance(annotations, dict):
|
||||
annotations = ToolAnnotations(**annotations)
|
||||
|
||||
if self.title:
|
||||
title = self.title
|
||||
elif self.annotations and self.annotations.title:
|
||||
title = self.annotations.title
|
||||
elif annotations and annotations.title:
|
||||
title = annotations.title
|
||||
else:
|
||||
title = _default_title(name)
|
||||
|
||||
mcp_tool = MCPTool(
|
||||
name=overrides.get("name", self.name),
|
||||
name=name,
|
||||
title=overrides.get("title", title),
|
||||
description=overrides.get("description", self.description),
|
||||
input_schema=overrides.get("inputSchema", self.parameters),
|
||||
output_schema=overrides.get("outputSchema", self.output_schema),
|
||||
icons=overrides.get("icons", self.icons),
|
||||
annotations=overrides.get("annotations", self.annotations),
|
||||
annotations=annotations,
|
||||
execution=overrides.get("execution", self.execution),
|
||||
_meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field
|
||||
"_meta", self.get_meta()
|
||||
|
|
@ -362,17 +375,16 @@ class Tool(FastMCPComponent):
|
|||
if isinstance(raw_value, CallToolResult):
|
||||
return ToolResult.from_mcp_result(raw_value)
|
||||
|
||||
if _HAS_PREFAB:
|
||||
if isinstance(raw_value, _PrefabApp):
|
||||
return _prefab_to_tool_result(
|
||||
raw_value,
|
||||
fastmcp_app_name=_get_fastmcp_app_name(self),
|
||||
)
|
||||
if isinstance(raw_value, _PrefabComponent):
|
||||
return _prefab_to_tool_result(
|
||||
_PrefabApp(view=raw_value),
|
||||
fastmcp_app_name=_get_fastmcp_app_name(self),
|
||||
)
|
||||
if is_prefab_app(raw_value):
|
||||
return _prefab_to_tool_result(
|
||||
raw_value,
|
||||
fastmcp_app_name=_get_fastmcp_app_name(self),
|
||||
)
|
||||
if is_prefab_component(raw_value):
|
||||
return _prefab_to_tool_result(
|
||||
prefab_app_from_component(raw_value),
|
||||
fastmcp_app_name=_get_fastmcp_app_name(self),
|
||||
)
|
||||
|
||||
content = _convert_to_content(raw_value)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from fastmcp.tools.base import ToolResult, resolve_serialize_by_alias
|
|||
from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring
|
||||
from fastmcp.utilities.json_schema import compress_schema
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.prefab import is_prefab_type
|
||||
from fastmcp.utilities.types import (
|
||||
Audio,
|
||||
File,
|
||||
|
|
@ -27,14 +28,6 @@ from fastmcp.utilities.types import (
|
|||
replace_type,
|
||||
)
|
||||
|
||||
try:
|
||||
from prefab_ui.app import PrefabApp as _PrefabApp
|
||||
from prefab_ui.components.base import Component as _PrefabComponent
|
||||
|
||||
_PREFAB_TYPES: tuple[type, ...] = (_PrefabApp, _PrefabComponent)
|
||||
except ImportError:
|
||||
_PREFAB_TYPES = ()
|
||||
|
||||
|
||||
def _contains_bytes_type(tp: Any) -> bool:
|
||||
"""Check if *tp* is or contains bytes, recursing through unions and Annotated."""
|
||||
|
|
@ -48,7 +41,7 @@ def _contains_bytes_type(tp: Any) -> bool:
|
|||
|
||||
def _contains_prefab_type(tp: Any) -> bool:
|
||||
"""Check if *tp* is or contains a prefab type, recursing through unions and Annotated."""
|
||||
if isinstance(tp, type) and issubclass(tp, _PREFAB_TYPES):
|
||||
if is_prefab_type(tp):
|
||||
return True
|
||||
origin = get_origin(tp)
|
||||
if origin is Union or origin is types.UnionType or origin is Annotated:
|
||||
|
|
@ -419,7 +412,7 @@ class ParsedFunction:
|
|||
# so we handle subclass matching explicitly here. We also need
|
||||
# to handle composite types like ``Column | None`` and
|
||||
# ``Annotated[PrefabApp, ...]`` by recursing into their args.
|
||||
if _PREFAB_TYPES and _contains_prefab_type(output_type):
|
||||
if _contains_prefab_type(output_type):
|
||||
output_type = _UnserializableType
|
||||
|
||||
# ToolResult subclasses should suppress schema generation just
|
||||
|
|
@ -464,7 +457,6 @@ class ParsedFunction:
|
|||
# A guard tool's suspend signal is control flow, not
|
||||
# output data (any residual bare arm is suppressed).
|
||||
mcp_types.InputRequiredResult,
|
||||
*_PREFAB_TYPES,
|
||||
),
|
||||
_UnserializableType,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ from collections.abc import Callable
|
|||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from griffe import Docstring, DocstringSectionKind
|
||||
|
||||
_PARSERS = ("google", "numpy", "sphinx")
|
||||
|
||||
logger = logging.getLogger("griffe")
|
||||
|
|
@ -43,6 +41,10 @@ def parse_docstring(fn: Callable[..., Any]) -> ParsedDocstring:
|
|||
if not doc:
|
||||
return ParsedDocstring()
|
||||
|
||||
# Griffe is only needed for functions that actually have docstrings. This
|
||||
# keeps its parser and model graph out of ordinary server startup.
|
||||
from griffe import Docstring, DocstringSectionKind
|
||||
|
||||
# Try each parser and use the first one that finds parameters.
|
||||
for parser in _PARSERS:
|
||||
docstring = Docstring(doc, lineno=1, parser=parser)
|
||||
|
|
|
|||
|
|
@ -7,30 +7,42 @@ from mcp import MCPError
|
|||
|
||||
import fastmcp
|
||||
|
||||
# FastMCP uses httpx2 internally, but user-supplied code (tools, resources, and
|
||||
# clients handed to the OpenAPI integration) may still raise exceptions from the
|
||||
# legacy httpx package. These catch tuples include both families when httpx is
|
||||
# installed, so user errors keep their specific handling without making httpx a
|
||||
# FastMCP dependency. The two libraries' exception hierarchies match name-for-name.
|
||||
try:
|
||||
import httpx
|
||||
|
||||
HTTP_STATUS_ERRORS: tuple[type[BaseException], ...] = (
|
||||
httpx2.HTTPStatusError,
|
||||
httpx.HTTPStatusError,
|
||||
def _is_legacy_httpx_exception(exc: BaseException, exception_type: str) -> bool:
|
||||
"""Check a legacy-httpx exception without importing the legacy package."""
|
||||
return any(
|
||||
cls.__module__.partition(".")[0] == "httpx" and cls.__name__ == exception_type
|
||||
for cls in type(exc).__mro__
|
||||
)
|
||||
TIMEOUT_ERRORS: tuple[type[BaseException], ...] = (
|
||||
httpx2.TimeoutException,
|
||||
httpx.TimeoutException,
|
||||
|
||||
|
||||
def is_http_status_error(exc: BaseException) -> bool:
|
||||
"""Return whether an exception is an httpx2 or legacy-httpx status error."""
|
||||
return isinstance(exc, httpx2.HTTPStatusError) or _is_legacy_httpx_exception(
|
||||
exc, "HTTPStatusError"
|
||||
)
|
||||
REQUEST_ERRORS: tuple[type[BaseException], ...] = (
|
||||
httpx2.RequestError,
|
||||
httpx.RequestError,
|
||||
|
||||
|
||||
def get_http_status_code(exc: BaseException) -> int | None:
|
||||
"""Return the response status code from a recognized HTTP status error."""
|
||||
if not is_http_status_error(exc):
|
||||
return None
|
||||
status_code = getattr(getattr(exc, "response", None), "status_code", None)
|
||||
return status_code if isinstance(status_code, int) else None
|
||||
|
||||
|
||||
def is_timeout_error(exc: BaseException) -> bool:
|
||||
"""Return whether an exception is an httpx2 or legacy-httpx timeout."""
|
||||
return isinstance(exc, httpx2.TimeoutException) or _is_legacy_httpx_exception(
|
||||
exc, "TimeoutException"
|
||||
)
|
||||
|
||||
|
||||
def is_request_error(exc: BaseException) -> bool:
|
||||
"""Return whether an exception is an httpx2 or legacy-httpx request error."""
|
||||
return isinstance(exc, httpx2.RequestError) or _is_legacy_httpx_exception(
|
||||
exc, "RequestError"
|
||||
)
|
||||
except ImportError:
|
||||
HTTP_STATUS_ERRORS = (httpx2.HTTPStatusError,)
|
||||
TIMEOUT_ERRORS = (httpx2.TimeoutException,)
|
||||
REQUEST_ERRORS = (httpx2.RequestError,)
|
||||
|
||||
|
||||
def iter_exc(group: BaseExceptionGroup):
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ from __future__ import annotations
|
|||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from jsonref import JsonRefError, replace_refs
|
||||
|
||||
def replace_refs(*args: Any, **kwargs: Any) -> Any:
|
||||
"""Call jsonref lazily while preserving the module's patchable boundary."""
|
||||
from jsonref import replace_refs as _replace_refs
|
||||
|
||||
return _replace_refs(*args, **kwargs)
|
||||
|
||||
|
||||
def _copy_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
|
|
@ -221,6 +226,10 @@ def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]:
|
|||
if _defs_have_cycles(schema.get("$defs", {})):
|
||||
return resolve_root_ref(schema)
|
||||
|
||||
# Most schema operations do not dereference. Keep jsonref (and its requests
|
||||
# dependency tree) out of server startup until a schema actually needs it.
|
||||
from jsonref import JsonRefError
|
||||
|
||||
try:
|
||||
# Use jsonref to resolve all $ref references
|
||||
# proxies=False returns plain dicts (not proxy objects)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"""Logging utilities for FastMCP."""
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from rich.console import Console
|
||||
|
|
@ -11,6 +13,17 @@ from typing_extensions import override
|
|||
import fastmcp
|
||||
|
||||
|
||||
def _get_package_path(package: str) -> str | None:
|
||||
"""Return a package directory without importing the package."""
|
||||
try:
|
||||
spec = importlib.util.find_spec(package)
|
||||
except ImportError:
|
||||
return None
|
||||
if spec is None or spec.origin is None:
|
||||
return None
|
||||
return str(Path(spec.origin).parent)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a logger nested under FastMCP namespace.
|
||||
|
||||
|
|
@ -83,14 +96,11 @@ def configure_logging(
|
|||
# no path or level name to maximize width available for the traceback
|
||||
# suppress framework frames and limit the number of frames to 3
|
||||
|
||||
import pydantic
|
||||
|
||||
try:
|
||||
import mcp
|
||||
except ImportError:
|
||||
tracebacks_suppress = [fastmcp, pydantic]
|
||||
else:
|
||||
tracebacks_suppress = [fastmcp, mcp, pydantic]
|
||||
tracebacks_suppress = [
|
||||
package_path
|
||||
for package in ("fastmcp", "mcp", "pydantic")
|
||||
if (package_path := _get_package_path(package)) is not None
|
||||
]
|
||||
|
||||
# Build traceback kwargs with defaults that can be overridden
|
||||
traceback_kwargs = {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ OpenAPI Spec → Parser → HTTPRoute with Pre-calculated Fields → RequestDire
|
|||
### Request Processing
|
||||
|
||||
```
|
||||
MCP Tool Call → RequestDirector.build() → httpx.Request → HTTP Response → Structured Output
|
||||
MCP Tool Call → RequestDirector.build() → httpx2.Request → HTTP Response → Structured Output
|
||||
```
|
||||
|
||||
1. **Tool Invocation**: FastMCP receives tool call with parameters
|
||||
|
|
@ -103,14 +103,14 @@ All components use the same RequestDirector approach:
|
|||
### Basic Server Setup
|
||||
|
||||
```python
|
||||
import httpx
|
||||
import httpx2
|
||||
from fastmcp.server.openapi import FastMCPOpenAPI
|
||||
|
||||
# OpenAPI spec (can be loaded from file/URL)
|
||||
openapi_spec = {...}
|
||||
|
||||
# Create HTTP client
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx2.AsyncClient() as client:
|
||||
# Create server with stateless request building
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=openapi_spec,
|
||||
|
|
@ -134,8 +134,8 @@ director = RequestDirector(spec)
|
|||
# Build HTTP request
|
||||
request = director.build(route, flat_arguments, base_url)
|
||||
|
||||
# Execute with httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Execute with httpx2
|
||||
async with httpx2.AsyncClient() as client:
|
||||
response = await client.send(request)
|
||||
```
|
||||
|
||||
|
|
@ -206,6 +206,6 @@ Tests are located in `/tests/server/openapi/`:
|
|||
## Dependencies
|
||||
|
||||
- `openapi-core` - OpenAPI specification processing and validation
|
||||
- `httpx` - HTTP client library
|
||||
- `httpx2` - HTTP client library
|
||||
- `pydantic` - Data validation and serialization
|
||||
- `urllib.parse` - URL building and manipulation
|
||||
- `urllib.parse` - URL building and manipulation
|
||||
|
|
|
|||
74
fastmcp_slim/fastmcp/utilities/prefab.py
Normal file
74
fastmcp_slim/fastmcp/utilities/prefab.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Lazy helpers for FastMCP's optional Prefab UI integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from importlib.util import find_spec
|
||||
from typing import Any
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def prefab_available() -> bool:
|
||||
"""Return whether Prefab UI is installed without importing it."""
|
||||
return find_spec("prefab_ui") is not None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_prefab_types() -> tuple[type[Any], type[Any]] | None:
|
||||
"""Import and return Prefab's public app and component types on demand."""
|
||||
if not prefab_available():
|
||||
return None
|
||||
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.components.base import Component
|
||||
|
||||
return PrefabApp, Component
|
||||
|
||||
|
||||
def _could_be_prefab(value_or_type: Any) -> bool:
|
||||
"""Cheaply reject ordinary values before importing Prefab UI."""
|
||||
candidate_type = (
|
||||
value_or_type if isinstance(value_or_type, type) else type(value_or_type)
|
||||
)
|
||||
module = getattr(candidate_type, "__module__", "")
|
||||
return (
|
||||
"prefab_ui" in sys.modules
|
||||
or module == "prefab_ui"
|
||||
or module.startswith("prefab_ui.")
|
||||
)
|
||||
|
||||
|
||||
def is_prefab_type(candidate: Any) -> bool:
|
||||
"""Return whether a type is a Prefab app or component type."""
|
||||
if not isinstance(candidate, type) or not _could_be_prefab(candidate):
|
||||
return False
|
||||
|
||||
prefab_types = _get_prefab_types()
|
||||
return prefab_types is not None and issubclass(candidate, prefab_types)
|
||||
|
||||
|
||||
def is_prefab_app(value: Any) -> bool:
|
||||
"""Return whether a value is a Prefab app."""
|
||||
if not _could_be_prefab(value):
|
||||
return False
|
||||
|
||||
prefab_types = _get_prefab_types()
|
||||
return prefab_types is not None and isinstance(value, prefab_types[0])
|
||||
|
||||
|
||||
def is_prefab_component(value: Any) -> bool:
|
||||
"""Return whether a value is a Prefab component."""
|
||||
if not _could_be_prefab(value):
|
||||
return False
|
||||
|
||||
prefab_types = _get_prefab_types()
|
||||
return prefab_types is not None and isinstance(value, prefab_types[1])
|
||||
|
||||
|
||||
def prefab_app_from_component(component: Any) -> Any:
|
||||
"""Wrap a Prefab component in a Prefab app."""
|
||||
prefab_types = _get_prefab_types()
|
||||
if prefab_types is None or not isinstance(component, prefab_types[1]):
|
||||
raise TypeError("Expected a Prefab UI component")
|
||||
return prefab_types[0](view=component)
|
||||
|
|
@ -205,7 +205,7 @@ async def download_skill(
|
|||
|
||||
# Write content
|
||||
if isinstance(content, mcp_types.TextResourceContents):
|
||||
file_path.write_text(content.text)
|
||||
file_path.write_text(content.text, encoding="utf-8")
|
||||
elif isinstance(content, mcp_types.BlobResourceContents):
|
||||
file_path.write_bytes(base64.b64decode(content.blob))
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ client = [
|
|||
"authlib>=1.6.11",
|
||||
"py-key-value-aio[filetree,keyring,memory]>=0.4.4,<0.5.0",
|
||||
]
|
||||
code-mode = ["pydantic-monty==0.0.17"]
|
||||
code-mode = ["pydantic-monty==0.0.18"]
|
||||
gemini = ["google-genai>=1.18.0", "jsonref>=1.1.0"]
|
||||
mcp = [
|
||||
"exceptiongroup>=1.2.2",
|
||||
|
|
@ -96,7 +96,7 @@ server = [
|
|||
"griffelib>=2.0.0",
|
||||
"jsonref>=1.1.0",
|
||||
"jsonschema-path>=0.3.4",
|
||||
"joserfc>=1.1.0",
|
||||
"joserfc>=1.5.0",
|
||||
"openapi-pydantic>=0.5.1",
|
||||
"packaging>=24.0",
|
||||
"py-key-value-aio[filetree,keyring,memory]>=0.4.4,<0.5.0",
|
||||
|
|
|
|||
|
|
@ -157,6 +157,8 @@ exclude = [
|
|||
"examples/smart_home", # needs phue
|
||||
"examples/apps/qr_server", # needs qrcode
|
||||
"examples/providers/sqlite", # needs aiosqlite
|
||||
"examples/fastmcp_config_demo", # needs pyautogui, Pillow
|
||||
"examples/screenshot.py", # needs pyautogui, Pillow
|
||||
"examples/memory.py", # needs asyncpg, numpy, pydantic_ai, pgvector
|
||||
"examples/get_file.py", # needs aiohttp
|
||||
]
|
||||
|
|
@ -165,9 +167,8 @@ exclude = [
|
|||
python-version = "3.10"
|
||||
|
||||
[tool.ty.analysis]
|
||||
# prefab_ui is the apps SDK; pyautogui/PIL are optional runtime deps used only
|
||||
# inside example tool bodies (screenshot demos) and are not installed here.
|
||||
replace-imports-with-any = ["prefab_ui.**", "pyautogui", "PIL", "PIL.**"]
|
||||
# prefab_ui is the apps SDK and is not installed here.
|
||||
replace-imports-with-any = ["prefab_ui.**"]
|
||||
|
||||
[tool.ty.rules]
|
||||
division-by-zero = "warn"
|
||||
|
|
|
|||
169
scripts/benchmark_http_startup.py
Normal file
169
scripts/benchmark_http_startup.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
#!/usr/bin/env python
|
||||
"""Benchmark FastMCP's HTTP server cold-start path in fresh interpreters.
|
||||
|
||||
The benchmark separates the work users pay before an HTTP server can accept
|
||||
requests:
|
||||
|
||||
1. import the public ``FastMCP`` entry point;
|
||||
2. construct a server and register representative tools;
|
||||
3. build the Streamable HTTP ASGI application.
|
||||
|
||||
Every sample runs in a fresh interpreter. Use ratios and the shape of the
|
||||
results rather than treating single-machine absolute timings as universal.
|
||||
|
||||
Usage:
|
||||
uv run python scripts/benchmark_http_startup.py
|
||||
uv run python scripts/benchmark_http_startup.py --runs 10
|
||||
uv run python scripts/benchmark_http_startup.py --json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from collections.abc import Sequence
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class Sample(TypedDict):
|
||||
import_ms: float
|
||||
server_ms: float
|
||||
app_ms: float
|
||||
total_ms: float
|
||||
module_count: int
|
||||
rss_mib: float
|
||||
heavy_module_counts: dict[str, int]
|
||||
|
||||
|
||||
_PROBE = textwrap.dedent(
|
||||
"""
|
||||
import json
|
||||
import resource
|
||||
import sys
|
||||
import time
|
||||
|
||||
started = time.perf_counter()
|
||||
from fastmcp import FastMCP
|
||||
imported = time.perf_counter()
|
||||
|
||||
server = FastMCP("HTTP cold-start benchmark")
|
||||
|
||||
def make_tool(index):
|
||||
def tool(value: int = index) -> int:
|
||||
return value
|
||||
|
||||
tool.__name__ = f"tool_{index}"
|
||||
return tool
|
||||
|
||||
for index in range(10):
|
||||
server.tool(make_tool(index))
|
||||
configured = time.perf_counter()
|
||||
|
||||
app = server.http_app(transport="http", stateless_http=True)
|
||||
assert app is not None
|
||||
ready = time.perf_counter()
|
||||
|
||||
heavy_roots = {
|
||||
"authlib",
|
||||
"cryptography",
|
||||
"httpx2",
|
||||
"key_value",
|
||||
"mcp",
|
||||
"mcp_types",
|
||||
"opentelemetry",
|
||||
"pydantic",
|
||||
"rich",
|
||||
"sse_starlette",
|
||||
"starlette",
|
||||
"uvicorn",
|
||||
}
|
||||
heavy_module_counts = {
|
||||
root: sum(
|
||||
module == root or module.startswith(f"{root}.") for module in sys.modules
|
||||
)
|
||||
for root in sorted(heavy_roots)
|
||||
}
|
||||
heavy_module_counts = {
|
||||
root: count for root, count in heavy_module_counts.items() if count
|
||||
}
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"import_ms": (imported - started) * 1000,
|
||||
"server_ms": (configured - imported) * 1000,
|
||||
"app_ms": (ready - configured) * 1000,
|
||||
"total_ms": (ready - started) * 1000,
|
||||
"module_count": len(sys.modules),
|
||||
"rss_mib": (
|
||||
resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||||
/ (1024 * 1024)
|
||||
if sys.platform == "darwin"
|
||||
else resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
|
||||
),
|
||||
"heavy_module_counts": heavy_module_counts,
|
||||
}
|
||||
)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _sample() -> Sample:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", _PROBE],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr)
|
||||
return json.loads(result.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def _median(samples: Sequence[Sample], key: str) -> float:
|
||||
return statistics.median(float(sample[key]) for sample in samples) # type: ignore[literal-required]
|
||||
|
||||
|
||||
def _summarize(samples: list[Sample]) -> dict[str, object]:
|
||||
return {
|
||||
"runs": len(samples),
|
||||
"import_ms": round(_median(samples, "import_ms"), 1),
|
||||
"server_ms": round(_median(samples, "server_ms"), 1),
|
||||
"app_ms": round(_median(samples, "app_ms"), 1),
|
||||
"total_ms": round(_median(samples, "total_ms"), 1),
|
||||
"module_count": round(_median(samples, "module_count")),
|
||||
"rss_mib": round(_median(samples, "rss_mib"), 1),
|
||||
"heavy_module_counts": samples[-1]["heavy_module_counts"],
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--runs", type=int, default=5)
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
samples = [_sample() for _ in range(args.runs)]
|
||||
summary = _summarize(samples)
|
||||
if args.json:
|
||||
print(json.dumps(summary, indent=2))
|
||||
return
|
||||
|
||||
print(f"Python: {sys.version.split()[0]}")
|
||||
print(f"Runs: {summary['runs']}")
|
||||
print(f"Import FastMCP: {summary['import_ms']:.1f} ms")
|
||||
print(f"Construct + 10 tools: {summary['server_ms']:.1f} ms")
|
||||
print(f"Build HTTP app: {summary['app_ms']:.1f} ms")
|
||||
print(f"Total to ASGI app: {summary['total_ms']:.1f} ms")
|
||||
print(f"Modules: {summary['module_count']}")
|
||||
print(f"Peak RSS: {summary['rss_mib']:.1f} MiB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -140,6 +140,30 @@ class TestParseMcpConfig:
|
|||
servers = _parse_mcp_config(path, "test")
|
||||
assert servers == []
|
||||
|
||||
def test_invalid_server_does_not_hide_valid_servers(
|
||||
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
path = tmp_path / "config.json"
|
||||
_write_config(
|
||||
path,
|
||||
{
|
||||
"mcpServers": {
|
||||
"working": {
|
||||
"command": "python",
|
||||
"args": ["server.py"],
|
||||
},
|
||||
"broken": {
|
||||
"args": ["missing-command.py"],
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
servers = _parse_mcp_config(path, "test")
|
||||
|
||||
assert [server.name for server in servers] == ["working"]
|
||||
assert "broken" in caplog.text
|
||||
|
||||
def test_remote_server(self, tmp_path: Path):
|
||||
path = tmp_path / "config.json"
|
||||
_write_config(path, _REMOTE_CONFIG)
|
||||
|
|
|
|||
|
|
@ -96,10 +96,12 @@ async def test_unauthorized(client_unauthorized: Client):
|
|||
SDK v2 surfaces the server's 401 as an MCPError ("Server returned an error
|
||||
response") rather than re-raising the raw httpx2.HTTPStatusError.
|
||||
"""
|
||||
with pytest.raises(MCPError, match="error response"):
|
||||
with pytest.raises(MCPError, match="error response") as exc_info:
|
||||
async with client_unauthorized:
|
||||
pass
|
||||
|
||||
assert exc_info.value.__cause__ is not exc_info.value
|
||||
|
||||
|
||||
async def test_ping(streamable_http_server: str):
|
||||
"""Test that we can ping the server.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue