Merge remote-tracking branch 'origin/main' into codex/otel-on-start-attributes

# Conflicts:
#	fastmcp_slim/fastmcp/server/telemetry.py
This commit is contained in:
Jeremiah Lowin 2026-07-18 19:34:04 -04:00
commit d0afccb028
No known key found for this signature in database
159 changed files with 4974 additions and 1029 deletions

73
.github/scripts/triage-label.sh vendored Executable file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Locked-down label helper for the Marvin triage workflow.
#
# Marvin runs on untrusted issue/PR bodies from non-write users, so it must
# NOT be handed raw `gh api` (that would expose every endpoint the app token
# can reach). This helper is the ONLY GitHub write it is allowed to perform:
# it adds or removes repository labels on the one issue/PR being triaged.
#
# The target repo and number come from the environment set by the workflow —
# never from the model — and the operation is fixed to the additive labels
# endpoint (POST/DELETE /repos/{repo}/issues/{n}/labels), which works for both
# issues and PRs and cannot clobber labels applied by other workflows.
set -euo pipefail
repo="${TRIAGE_REPO:?TRIAGE_REPO not set}"
number="${TRIAGE_NUMBER:?TRIAGE_NUMBER not set}"
if [[ ! "$number" =~ ^[0-9]+$ ]]; then
echo "TRIAGE_NUMBER must be numeric, got: $number" >&2
exit 1
fi
op="${1:-}"
shift || true
case "$op" in
add) method=POST ;;
remove) method=DELETE ;;
*)
echo "usage: triage-label.sh <add|remove> <label>..." >&2
exit 1
;;
esac
if [[ "$#" -eq 0 ]]; then
echo "no labels given" >&2
exit 1
fi
# Reject anything that isn't a plausible label name. Notably blocks '/' so a
# crafted value can't turn the DELETE path into a different endpoint.
label_re="^[A-Za-z0-9 ._'-]+$"
for label in "$@"; do
if [[ ! "$label" =~ $label_re ]]; then
echo "refusing suspicious label name: $label" >&2
exit 1
fi
done
# Never let triage add or remove the Require Issue Link control labels. Those
# govern PR enforcement (bypass-issue-check / trusted-contributor are sticky
# exemptions) and reopening (missing-issue-link is how closed PRs are found),
# so a prompt-injected triage run must not be able to grant an exemption or
# break recovery. Enforced here — in code — not merely in the prompt.
protected=" missing-issue-link bypass-issue-check trusted-contributor "
for label in "$@"; do
lower="${label,,}"
if [[ "$protected" == *" $lower "* ]]; then
echo "refusing to touch protected control label: $label" >&2
exit 1
fi
done
if [[ "$method" == POST ]]; then
args=()
for label in "$@"; do
args+=(-f "labels[]=$label")
done
gh api --method POST "/repos/${repo}/issues/${number}/labels" "${args[@]}"
else
for label in "$@"; do
gh api --method DELETE "/repos/${repo}/issues/${number}/labels/${label}"
done
fi

View file

@ -49,13 +49,16 @@ jobs:
PROMPT<<PROMPT_END
You're an issue triage assistant for FastMCP, a Python framework for building Model Context Protocol servers and clients. Your task is to analyze issues/PRs and apply appropriate labels.
IMPORTANT: Your primary action should be to apply labels using mcp__github__update_issue. DO NOT post comments EXCEPT when applying the too-long label (see below).
IMPORTANT: Your primary action should be to apply labels using the locked-down helper `.github/scripts/triage-label.sh`. DO NOT post comments EXCEPT when applying the too-long label (see below).
CRITICAL — LABEL MECHANICS:
- `mcp__github__update_issue` REPLACES all labels on the issue — it does not add to them.
- Before applying labels, read the issue's current labels with `mcp__github__get_issue`.
- Always include any existing labels you want to keep alongside the new ones.
- Apply labels ONLY through the helper, which adds or removes repository labels on THIS issue/PR. It already knows the target repo and number (from the workflow environment) — you never pass them:
add: `bash .github/scripts/triage-label.sh add "label1" "label2"`
remove: `bash .github/scripts/triage-label.sh remove "label1"`
- The helper uses the additive REST labels endpoint, so it works for both issues and PRs and never clobbers labels applied by other workflows — notably the Require Issue Link workflow's `missing-issue-link` control label, which must survive or an auto-closed PR won't reopen when its author is assigned.
- The helper is your ONLY GitHub write access. Do NOT use raw `gh api`, `gh issue edit`, `gh pr edit`, or any other mutation — they are not available to you.
- Only apply labels that exist in the repository (from `gh label list` in step 1). Never invent labels.
- Use `remove` only to correct a label you believe is wrong, and never remove the control labels `missing-issue-link`, `bypass-issue-check`, or `trusted-contributor`.
Issue/PR Information:
- REPO: ${{ github.repository }}
@ -131,7 +134,7 @@ jobs:
- DON'T MERGE: Only if PR author explicitly states it's not ready
4. Apply selected labels:
Use mcp__github__update_issue to apply your selected labels
Add them with `bash .github/scripts/triage-label.sh add "label1" "label2"`.
DO NOT post any comments unless applying too-long (see above)
PROMPT_END
EOF
@ -149,11 +152,13 @@ jobs:
allowed_non_write_users: "*"
allowed_bots: "marvin-context-protocol"
claude_args: |
--allowedTools Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__add_issue_comment,mcp__github__get_pull_request_files
--allowedTools Bash(gh label list),Bash(bash .github/scripts/triage-label.sh:*),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__add_issue_comment,mcp__github__get_pull_request_files
settings: |
{
"model": "claude-sonnet-4-6",
"env": {
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}",
"TRIAGE_REPO": "${{ github.repository }}",
"TRIAGE_NUMBER": "${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number }}"
}
}

View file

@ -354,29 +354,30 @@ jobs:
async function enforceFailure(kind) {
await addLabel();
const intro = kind === 'no-link'
? '**This PR has been automatically closed** because its description does not reference a tracked issue.'
: '**This PR has been automatically closed** because you are not assigned to the issue it references.';
const reason = kind === 'no-link'
? "it doesn't reference a tracked issue assigned to you"
: "you aren't assigned to the issue it references";
const steps = kind === 'no-link'
? [
`1. Find or [open an issue](https://github.com/${owner}/${repo}/issues/new/choose) describing the change.`,
'2. Comment on the issue to ask a maintainer to assign it to you.',
'3. Add `Fixes #<issue>`, `Closes #<issue>`, or `Resolves #<issue>` to the PR description.',
'4. Once you are assigned and the link is present, the PR reopens automatically.',
`1. Find or [open an issue](https://github.com/${owner}/${repo}/issues/new/choose) describing the change — if you open it, you have first claim on it.`,
"2. Add `Fixes #<issue>`, `Closes #<issue>`, or `Resolves #<issue>` to **this** PR's description — edit it in place, don't open a new PR.",
]
: [
'1. Comment on the linked issue to ask a maintainer to assign it to you.',
'2. Once a maintainer assigns you, the PR reopens automatically.',
"1. If you opened the linked issue, a maintainer will assign you when they pick it up and this PR reopens automatically. If someone else opened it, the PR reopens only if a maintainer chooses to assign it to you — please don't comment to ask.",
];
const commentBody = [
MARKER,
intro,
"**Don't open a new pull request — this one reopens on its own.** It's closed for " +
`now because ${reason}, but the moment that's fixed it reopens automatically. Keep this ` +
'PR and edit it; opening a fresh duplicate just starts you over and creates more to triage.',
'',
`Per [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md), an external PR must reference an issue that is assigned to its author. To proceed:`,
`Per [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md), an external PR must reference an issue that's assigned to its author. To get there:`,
'',
...steps,
'',
"Once you're assigned and the link is present, this PR reopens automatically — no further action needed.",
'',
`*Maintainers: reopen this PR or remove the \`${LABEL}\` label to bypass this check.*`,
].join('\n');

View file

@ -18,9 +18,13 @@ That's it. No need to diagnose root causes, propose API designs, or suggest impl
We encourage you to use LLMs to help identify bugs, write MREs, and prepare contributions. But if you do, your LLM must take into account the conventions and contributing guidelines of this repo — including how we want issues formatted and when it's appropriate to open a PR. Generic LLM output that ignores these guidelines tells us the contribution wasn't made thoughtfully, and we will close it. A good AI-assisted contribution is indistinguishable from a good human one. A bad one is obvious.
If you're driving an agent: do **not** have it post comments asking to be assigned to an issue or announcing that it intends to work on one. Those comments are ignored. If the agent intends to contribute, open a PR instead — it will be gated on assignment (see below). Comment on an issue only to propose a genuinely novel, differentiated solution, never to claim a task that's already described.
## When to open a pull request
An open issue is not an invitation to submit a PR. Issues track problems; whether and how to solve them is a separate decision. If you want to work on something, propose your approach in the issue first and ask a maintainer to assign it to you — especially for anything beyond a trivial fix. External PRs that reference an issue not assigned to their author are closed automatically (see [PR guidelines](#pr-guidelines)).
An open issue is not an invitation to submit a PR, and it is not a queue you join by commenting. Issues track problems; who implements them and how is a separate decision maintainers make, and whoever opened the issue has first claim on it.
**Don't post drive-by comments claiming an issue** — "can I work on this?", "please assign me", "I'll take this." They don't affect who gets assigned, they're the most common form of noise we get, and automated versions are ignored. Whoever opens the issue has first claim on it; if that's you, a maintainer will assign you. If you want to implement something someone else reported, just open a PR — you don't need permission to try, and competing PRs are fine — but it's reviewed only if a maintainer assigns you to the issue, which usually won't happen if the reporter intends to handle it. The one comment worth posting is a genuinely different approach worth discussing; a substantive design proposal is welcome, a bare claim on the task is not.
**Bug fixes** — PRs are welcome for simple, well-scoped bug fixes where the problem and solution are both straightforward. "The function raises `TypeError` when passed `None` because of a missing guard" is a good candidate. If the fix requires design decisions or touches multiple subsystems, open an issue with a design proposal instead.
@ -34,7 +38,8 @@ An open issue is not an invitation to submit a PR. Issues track problems; whethe
If you do open a PR:
- **Reference an issue you're assigned to.** Every PR must reference a tracked issue using an auto-close keyword (`Fixes #123`, `Closes #123`, or `Resolves #123`), and the referenced issue must be assigned to you. If there isn't an issue, open one; then comment to ask a maintainer to assign it to you. This lets us deconflict effort and steer the approach before you invest time in code. External PRs that don't meet both conditions are automatically labeled `missing-issue-link` and closed; they reopen automatically once the link is present and you're assigned.
- **Reference an issue you're assigned to.** Every PR must reference a tracked issue using an auto-close keyword (`Fixes #123`, `Closes #123`, or `Resolves #123`), and the referenced issue must be assigned to you. If there isn't an issue, open one. This lets us deconflict effort and steer the approach before you invest time in code. External PRs that don't meet both conditions are automatically labeled `missing-issue-link` and closed; they reopen automatically once the link is present and you're assigned.
- **If your PR was auto-closed, don't open a new one.** Edit the *existing* PR to add the issue link, get assigned to that issue, and it reopens on its own — the branch and history are preserved. A duplicate PR just starts you over and adds to the triage pile.
- **Keep it focused.** One logical change per PR. Don't bundle unrelated fixes or refactors.
- **Match existing patterns.** Follow the code style, type annotation conventions, and test patterns you see in the codebase. Run `uv run prek run --all-files` before submitting.
- **Write tests.** Bug fixes should include a test that fails without the fix. Enhancements should include tests for the new behavior.

View file

@ -57,7 +57,7 @@ async with Client(transport) as client:
## `BearerAuth` Helper
If you prefer to be more explicit and not rely on FastMCP to transform your string token, you can use the `BearerAuth` class yourself, which implements the `httpx.Auth` interface.
If you prefer to be more explicit and not rely on FastMCP to transform your string token, you can use the `BearerAuth` class yourself, which implements the `httpx2.Auth` interface.
```python {6}
from fastmcp import Client

View file

@ -35,7 +35,7 @@ async with Client("https://your-server.fastmcp.app/mcp", auth="oauth") as client
### `OAuth` Helper
To fully configure the OAuth flow, use the `OAuth` helper and pass it to the `auth` parameter of the `Client` or transport instance. `OAuth` manages the complexities of the OAuth 2.1 Authorization Code Grant with PKCE (Proof Key for Code Exchange) for enhanced security, and implements the full `httpx.Auth` interface.
To fully configure the OAuth flow, use the `OAuth` helper and pass it to the `auth` parameter of the `Client` or transport instance. `OAuth` manages the complexities of the OAuth 2.1 Authorization Code Grant with PKCE (Proof Key for Code Exchange) for enhanced security, and implements the full `httpx2.Auth` interface.
```python {2, 4, 6}
from fastmcp import Client
@ -61,7 +61,7 @@ You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` —
- **`token_storage`** (`AsyncKeyValue`, optional): Storage backend for persisting OAuth tokens. Defaults to in-memory storage (tokens lost on restart). See [Token Storage](#token-storage) for encrypted storage options
- **`additional_client_metadata`** (`dict[str, Any]`, optional): Extra metadata for client registration
- **`callback_port`** (`int`, optional): Fixed port for OAuth callback server. If not specified, uses a random available port
- **`httpx_client_factory`** (`McpHttpClientFactory`, optional): Factory for creating httpx clients
- **`httpx_client_factory`** (`McpHttpClientFactory`, optional): Factory for creating httpx2 clients
## OAuth Flow

View file

@ -234,6 +234,27 @@ async with client:
fresh = await client.list_tools_mcp(cache_mode="refresh")
```
### Sharing a cache across clients
The default cache lives in each client's process. To share cached responses across a fleet — a set of proxy replicas backed by one Redis, for example — pass a `KeyValueResponseCacheStore`, FastMCP's adapter over the same `AsyncKeyValue` key-value abstraction the event store and OAuth proxy use. It accepts any compatible backend (memory, Redis, and more).
A shared store mingles responses from different principals, so it requires an explicit `partition` that isolates them. Derive the partition from a verified credential — never from request data or the server URL — and construct a new client when the principal changes. Only responses the server marks `"public"` are ever served across partitions.
```python
from fastmcp import Client
from fastmcp.client.caching import KeyValueResponseCacheStore
from mcp.client.caching import CacheConfig
from key_value.aio.stores.redis import RedisStore
backend = RedisStore(url="redis://localhost")
store = KeyValueResponseCacheStore(storage=backend)
config = CacheConfig(store=store, partition="tenant-a", target_id="weather-api")
client = Client("https://example.com/mcp", mode="auto", cache=config)
```
The adapter serializes each result through a type-tagged envelope validated against an allowlist of cacheable result models, so a value naming an unknown type is treated as a cache miss rather than deserialized blindly. Each store instance owns its own collection namespace; `clear()` affects only that namespace, never another tenant's entries.
## Operations
FastMCP clients interact with three types of server components.

View file

@ -126,7 +126,7 @@ client = Client(
### SSL Verification
By default, HTTPS connections verify the server's SSL certificate. You can customize this behavior with the `verify` parameter, which accepts the same values as [httpx](https://www.python-httpx.org/advanced/ssl/):
By default, HTTPS connections verify the server's SSL certificate. You can customize this behavior with the `verify` parameter, which accepts the same values as httpx2 (documented in [httpx's SSL guide](https://www.python-httpx.org/advanced/ssl/), which httpx2 follows):
```python
from fastmcp import Client

View file

@ -1,69 +1,55 @@
/* Banner styling -- improve readability with better contrast */
/* Banner: an animated brand-rainbow wash behind Mintlify's white text.
Mintlify always renders banner text white, so every gradient stop is a
deep, saturated shade (all >=7:1 on white) the colors evoke the FastMCP
watercolor logo while keeping the announcement legible in both themes.
A dark fallback color is configured in docs.json for the no-CSS case. */
#banner {
background: #f1f5f9 !important;
color: #1e293b !important;
font-size: 0.95rem !important;
font-weight: 600 !important;
padding-top: 12px !important;
padding-bottom: 12px !important;
overflow: hidden !important;
position: relative;
}
#banner::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
inset: 0;
z-index: 0;
background: linear-gradient(
90deg,
rgba(6, 182, 212, 0.25) 0%,
rgba(6, 182, 212, 0.05) 25%,
rgba(6, 182, 212, 0.35) 50%,
rgba(6, 182, 212, 0.08) 75%,
rgba(6, 182, 212, 0.28) 100%
#1e40af 0%,
#5b21b6 22%,
#115e59 44%,
#9a3412 66%,
#9d174d 88%,
#1e40af 100%
);
background-size: 300% 100%;
animation: colorWave 14s ease-in-out infinite alternate;
background-size: 250% 100%;
animation: colorWave 18s ease-in-out infinite alternate;
pointer-events: none;
}
.dark #banner {
background: #475569 !important;
color: #f1f5f9 !important;
}
.dark #banner::before {
background: linear-gradient(
90deg,
rgba(247, 37, 133, 0.35) 0%,
rgba(247, 37, 133, 0.08) 25%,
rgba(247, 37, 133, 0.45) 50%,
rgba(247, 37, 133, 0.12) 75%,
rgba(247, 37, 133, 0.38) 100%
);
background-size: 300% 100%;
/* Keep the announcement text above the animated wash. */
#banner > * {
position: relative;
z-index: 1;
}
@keyframes colorWave {
0% {
background-position: 0% 0%;
background-position: 0% 50%;
}
100% {
background-position: 100% 0%;
background-position: 100% 50%;
}
}
#banner * {
color: #1e293b !important;
margin: 0 !important;
}
.dark #banner * {
color: #f1f5f9 !important;
}
@media (max-width: 767px) {
#banner {
font-size: 0.8rem !important;
@ -71,4 +57,3 @@
padding-bottom: 8px !important;
}
}

View file

@ -152,6 +152,12 @@ SDK v2 seeds an `OpenTelemetryMiddleware` into every lowlevel `Server`, so each
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (the `OpenTelemetryMiddleware` filter); `tests/server/telemetry/test_server_tracing.py::TestSingleServerSpan`.
### Telemetry on by default, with an explicit off-switch — Absorbed
FastMCP's OpenTelemetry instrumentation is on by default. Because FastMCP uses only the OpenTelemetry API, span creation is a no-op with negligible overhead (the API's `NonRecordingSpan`) unless the user configures an SDK and exporter — so being always-on costs nothing until you opt into collection. The new `FASTMCP_ENABLE_TELEMETRY` setting (`fastmcp.settings.enable_telemetry`, default `true`) is the explicit off-switch: set it to `false` and `get_tracer()` returns a genuine no-op tracer, so no FastMCP spans are created even when an SDK is configured. The off-switch governs FastMCP's own spans (all SERVER spans, plus FastMCP's high-level CLIENT span); the SDK's low-level `mcp-python-sdk` `MCP send <method>` CLIENT spans are governed by the user's OpenTelemetry SDK, not this setting. FastMCP's SERVER span now also carries `mcp.protocol.version` — the attribute the dropped SDK `OpenTelemetryMiddleware` set — restoring parity with the SDK's semantic conventions.
*Verify:* `fastmcp_slim/fastmcp/settings.py` (`enable_telemetry`); `fastmcp_slim/fastmcp/telemetry.py` (`get_tracer` off-switch); `fastmcp_slim/fastmcp/server/telemetry.py` (`get_protocol_span_attributes`); `tests/server/telemetry/test_server_tracing.py::TestTelemetryEnabledByDefault`, `::TestProtocolVersionAttribute`.
### Spec-correct error codes via a central translator — Breaking (wire error code)
Resource-not-found responses from the core `resources/read` handler previously used `-32002`. SEP-2164 (and the SDK's own mcpserver, which maps `ResourceNotFoundError` → `INVALID_PARAMS`) makes this `-32602`. The per-adapter `MCPError(code=..., ...)` literals in `server/mixins/mcp_operations.py` are replaced by a single `fastmcp.exceptions.to_mcp_error()` translator that maps FastMCP's public exceptions to the `mcp_types` code constants (`NotFoundError`/`DisabledError`/`ValidationError` → `INVALID_PARAMS`, else `INTERNAL_ERROR`). Clients that string-matched on the old `-32002` for resource-not-found must switch to `-32602`; the human-readable message ("Resource not found: ...") is unchanged. The opt-in `ErrorHandlingMiddleware`, which has its own documented per-method-prefix code mapping, is intentionally left as-is.
@ -185,7 +191,7 @@ client = Client("my_mcp_server.py", timeout=30.0) # also works
### `get_session_id` via header sniff — Bridged
The SDK dropped `get_session_id` from the streamable-HTTP transport with no replacement (the SDK source has an author TODO acknowledging it breaks the Transport protocol). FastMCP reconstructs it by registering an httpx response event hook on the client it owns, capturing the `mcp-session-id` response header. The removal trigger is the upstream TODO.
The SDK dropped `get_session_id` from the streamable-HTTP transport with no replacement (the SDK source has an author TODO acknowledging it breaks the Transport protocol). FastMCP reconstructs it by registering an httpx2 response event hook on the client it owns, capturing the `mcp-session-id` response header (httpx2 preserves httpx's `event_hooks` API). The removal trigger is the upstream TODO.
*Verify:* `fastmcp_slim/fastmcp/client/transports/http.py` (`_capture_session_id`, `get_session_id`).
@ -219,6 +225,21 @@ Proxy forwarding handlers stash the request context so a backend that issues a s
*Verify:* commit `1ac166bd`, `server/providers/proxy.py`.
### Shared response cache via `KeyValueResponseCacheStore` — New
The SDK's client response cache (SEP-2549) reads and writes through a pluggable `ResponseCacheStore`; the default is a per-client in-memory LRU. FastMCP adds `KeyValueResponseCacheStore`, an adapter over the same `AsyncKeyValue` key-value abstraction the event store and OAuth proxy already use, so a fleet of clients (e.g. proxy replicas) can share one Redis-backed response cache. Pass it via `CacheConfig(store=...)`; a custom store requires an explicit `partition` (SDK) and `target_id` (FastMCP). Results serialize through a type-tagged envelope validated against an allowlist of cacheable result models — an unknown tag is a cache miss, never an import-by-name — and each adapter owns its own collection so `clear()` never touches another tenant.
```python
from fastmcp.client.caching import KeyValueResponseCacheStore
from mcp.client.caching import CacheConfig
from key_value.aio.stores.redis import RedisStore
store = KeyValueResponseCacheStore(storage=RedisStore(url="redis://localhost"))
config = CacheConfig(store=store, partition="tenant-a", target_id="weather-api")
```
*Verify:* `fastmcp_slim/fastmcp/client/caching.py`, `tests/client/client/test_kv_response_cache.py`.
## HTTP
The maintainer asked whether FastMCP can now delete its custom HTTP app and let the SDK's `Server.streamable_http_app()` handle everything. The answer for this PR is **no** — every override earns its keep. Convergence is a v4 project gated on three upstream additions (see [Feature Program](/development/v4-notes/feature-program)).
@ -240,6 +261,23 @@ FastMCP owns DNS-rebinding protection through its `HostOriginGuardMiddleware`, w
*Verify:* `fastmcp_slim/fastmcp/server/http.py` (`enable_dns_rebinding_protection=False`, `HostOriginGuardMiddleware`).
### httpx2 replaces httpx — Breaking (custom client/factory, typing) / Absorbed (everything else)
SDK v2.0.0b2 replaces `httpx` + `httpx-sse` with [httpx2](https://pypi.org/project/httpx2/) (`>=2.5.0`), a next-generation httpx fork with built-in SSE. httpx2 is a near drop-in fork: the public API (`AsyncClient`, `Auth`, `Request`, `Response`, `Timeout`, `MockTransport`, exception hierarchy, `event_hooks`) matches httpx name-for-name. The SDK duck-types the client you hand it — `streamable_http_client(http_client=...)` and `sse_client(httpx_client_factory=...)` are type-hinted `httpx2.AsyncClient` with no `isinstance` gate — but the objects that cross into the SDK must be httpx2.
FastMCP now uses **httpx2 exclusively** and no longer depends on `httpx`. Every FastMCP-owned HTTP path moves to httpx2: the client transports (`client/transports/{base,http,sse}.py`), client auth (`client/auth/{oauth,bearer}.py` — `BearerAuth`/`OAuth` subclass `httpx2.Auth`), the client-side exception-group handler (`utilities/exceptions.py`), the proxy's upstream client (`server/providers/proxy.py`), the `MCPConfig` client-auth field (`mcp_config.py`), **and** all the server-side code that the earlier seam pass had left on httpx — the ~15 server auth providers' upstream IdP calls, the OpenAPI provider, `from_openapi`/`from_fastapi`, `version_check`, `resources/types.py`, the SSRF download guard, and the `apps_dev` CLI. `httpx` is dropped from the `mcp` extra entirely (it may still arrive transitively via other libraries, but FastMCP never imports it). The ~170 `httpx_mock` calls across the security-critical server-auth test files are ported to a local httpx2-backed `httpx_mock` fixture (`tests/utilities/httpx2_mock.py`) that preserves the `add_response`/`add_exception`/`get_request(s)` API verbatim, so `pytest-httpx` is dropped too.
User-visible deltas:
- **Custom client factory / client.** `StreamableHttpTransport(httpx_client_factory=...)`, `SSETransport(httpx_client_factory=...)`, and `OAuth(httpx_client_factory=...)` factories must now return `httpx2.AsyncClient`; a custom `httpx.Auth` passed as `Client(auth=...)` should become `httpx2.Auth`. httpx2 is a drop-in fork, so the change is an import swap (`import httpx` → `import httpx2`). This is a typing break; at runtime a duck-compatible httpx client still satisfies the SDK, but mixing `httpx.Timeout`/`httpx.Auth` with an httpx2 client is unsupported.
- **OpenAPI client.** `FastMCP.from_openapi(client=...)` and `OpenAPIProvider(client=...)` are now type-hinted `httpx2.AsyncClient`. There is no `isinstance` gate, so an existing `httpx.AsyncClient` still works at runtime via duck-typing this release; the typing nudges you to httpx2.
- **TLS trust store.** httpx2 verifies TLS against the OS trust store via `truststore` (honoring `SSL_CERT_FILE`/`SSL_CERT_DIR` first) instead of the bundled certifi CA set. This now applies to **all** FastMCP HTTP, including server-auth upstream IdP calls — not just the client path. Corporate-CA and certifi-pinned setups may see different trust behavior.
- **Logger renames.** FastMCP HTTP now logs under `httpx2` and `httpcore2.*` (was `httpx`/`httpcore.*`). Anyone filtering FastMCP HTTP logs by logger name must update the names.
The session-id header hook (below) works unchanged: httpx2 keeps httpx's `event_hooks` API. FastMCP's tool/resource/prompt handlers still map upstream 429/timeout errors to actionable `ToolError`/`ResourceError`; because a user's own tool may raise from either library, `server/server.py` catches both `httpx2` and (if installed) legacy `httpx` `HTTPStatusError`/`TimeoutException` via a defensive `try: import httpx` shim.
*Verify:* `fastmcp_slim/pyproject.toml` (`mcp` extra lists only `httpx2`); no FastMCP source imports `httpx` except the documented defensive shim in `server/server.py`.
## Protocol eras
The SDK v2 serves multiple protocol eras from one server, and FastMCP formally embraces this.
@ -303,6 +341,14 @@ FastMCP keeps its own DCR redirect-URI hardening (PRs #4419, #4408) regardless o
*Verify:* recent commits `67527c1f` (block unsafe OAuth redirect schemes), `57a27992` (DNS rebinding), `cccb529f` (DCR redirect URI validation) on `main`.
### Templated resource parameters are path-screened by default — Breaking (behavior)
Every templated resource now has its extracted parameter values screened for path-traversal (`..` segments), absolute paths, and null bytes **before the handler runs** — on by default, at the server's read chokepoint, covering local and provider-sourced (mounted/proxied) templates alike. Previously these payloads reached handlers raw; a template whose parameter flowed into a filesystem path or upstream URL was exposed unless the author added their own check. A rejected read now surfaces a non-leaky "resource not found" error (`-32602`) and a debug log.
The check is component-based, matching the SDK's `contains_path_traversal`: only a standalone `..` segment is traversal, so values that merely contain dots (`HEAD~3..HEAD`, `file.tar.gz`) and dotfiles (`.env`) still pass. This can break a template that legitimately accepts `..`-bearing or absolute values — exempt the parameter with `ResourceSecurity(exempt_params={...})`, disable per-component with `security=None`, or set a server-wide default with `FastMCP(resource_security=...)`. See [Resources → Path Security](/servers/resources#path-security).
*Verify:* `fastmcp_slim/fastmcp/resources/security.py` (`ResourceSecurity`), the screening block in `FastMCP.read_resource` (`fastmcp_slim/fastmcp/server/server.py`), and `tests/resources/test_resource_security.py`.
## Removed in 4.0
Deprecations that warned in 3.x are removed in 4.0. Each entry below is a hard removal — the old surface raises `TypeError` / `AttributeError` rather than warning, unless noted otherwise.
@ -314,7 +360,7 @@ Deprecations that warned in 3.x are removed in 4.0. Each entry below is a hard r
- **`fastmcp.experimental.server.openapi`** and **`fastmcp.experimental.utilities.openapi`** shims (deprecated 2.14) — Breaking. Import from `fastmcp.server.providers.openapi` and `fastmcp.utilities.openapi` respectively.
- **`fastmcp.server.apps`** and **`fastmcp.server.app`** shims (deprecated 3.2) — Breaking. Import from `fastmcp.apps` (e.g. `AppConfig`) or `fastmcp` (`FastMCPApp`) instead.
- **`PromptToolMiddleware`** and **`ResourceToolMiddleware`** (deprecated 3.1) — Breaking. Use the `PromptsAsTools` / `ResourcesAsTools` transforms from `fastmcp.server.transforms` instead. The non-deprecated `ToolInjectionMiddleware` base class is retained.
- **`StreamableHttpTransport(sse_read_timeout=...)`** (deprecated no-op) — Breaking. The parameter had no effect under the SDK v2 client; configure timeouts via `read_timeout_seconds` in `session_kwargs` or on the httpx client via `httpx_client_factory`. `SSETransport` still accepts `sse_read_timeout`.
- **`StreamableHttpTransport(sse_read_timeout=...)`** (deprecated no-op) — Breaking. The parameter had no effect under the SDK v2 client; configure timeouts via `read_timeout_seconds` in `session_kwargs` or on the httpx2 client via `httpx_client_factory`. `SSETransport` still accepts `sse_read_timeout`.
### `FastMCP` server methods and `mount()` kwargs

View file

@ -12,6 +12,10 @@
"decoration": "gradient"
},
"banner": {
"color": {
"dark": "#475569",
"light": "#1e3a5f"
},
"content": "Meet [Prefect Horizon](https://prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_banner&utm_content=sitewide_banner), the enterprise MCP gateway built by the team behind FastMCP"
},
"colors": {

View file

@ -68,7 +68,7 @@ BREAKING CHANGES (will crash at import or runtime):
6. WSTRANSPORT: Removed. Use StreamableHttpTransport.
7. OPENAPI: timeout parameter removed from OpenAPIProvider. Set timeout on the httpx.AsyncClient instead.
7. OPENAPI: timeout parameter removed from OpenAPIProvider. Set timeout on the httpx2.AsyncClient instead.
8. METADATA: Namespace changed from "_fastmcp" to "fastmcp" in tool.meta. The include_fastmcp_meta parameter is removed (always included).
@ -276,14 +276,14 @@ transport = StreamableHttpTransport("http://localhost:8000/mcp")
**OpenAPI `timeout` parameter removed**
`OpenAPIProvider` no longer accepts a `timeout` parameter. Configure timeout on the httpx client directly. The `client` parameter is also now optional — when omitted, a default client is created from the spec's `servers` URL with a 30-second timeout:
`OpenAPIProvider` no longer accepts a `timeout` parameter. Configure timeout on the httpx2 client directly. The `client` parameter is also now optional — when omitted, a default client is created from the spec's `servers` URL with a 30-second timeout:
```python
# Before
provider = OpenAPIProvider(spec, client, timeout=60)
# After
client = httpx.AsyncClient(base_url="https://api.example.com", timeout=60)
client = httpx2.AsyncClient(base_url="https://api.example.com", timeout=60)
provider = OpenAPIProvider(spec, client)
```

View file

@ -114,6 +114,43 @@ Catching and `err.error.code` are unchanged — only construction moved.
**Raw session access sees v2 objects.** If you reach past FastMCP's client and server surfaces into `client.session`, `ctx.session`, or the internals of `ctx.request_context`, you're now holding raw SDK v2 objects with snake_case fields and the v2 method signatures. FastMCP does not wrap these; code that depends on their v1 shape needs updating.
**FastMCP now uses httpx2 exclusively.** FastMCP has replaced `httpx` with [httpx2](https://pypi.org/project/httpx2/), a next-generation httpx fork, across its entire HTTP stack — client transports and every server-side path (auth providers, the OpenAPI integration, the version check). `httpx` is no longer a FastMCP dependency. If you pass a custom client or factory into a FastMCP client transport — `StreamableHttpTransport(httpx_client_factory=...)`, `SSETransport(httpx_client_factory=...)`, `OAuth(httpx_client_factory=...)`, or a custom `httpx.Auth` as `Client(auth=...)` — those objects must now be httpx2. httpx2 is a drop-in fork with the same public API, so the change is an import swap:
```python
# Before
import httpx
transport = StreamableHttpTransport(
"https://example.com/mcp",
httpx_client_factory=lambda **kwargs: httpx.AsyncClient(verify=False, **kwargs),
)
# After
import httpx2
transport = StreamableHttpTransport(
"https://example.com/mcp",
httpx_client_factory=lambda **kwargs: httpx2.AsyncClient(verify=False, **kwargs),
)
```
The `client` you pass to `FastMCP.from_openapi(client=...)` (and `OpenAPIProvider(client=...)`) is now type-hinted `httpx2.AsyncClient`. FastMCP does not gate on the type, so an existing `httpx.AsyncClient` keeps working at runtime via duck-typing this release — but switching it to `httpx2.AsyncClient` clears the type hint and is the supported path going forward. HTTP made inside your own tools is entirely yours and is unaffected either way.
**The subtlest break is exception handlers, and no type checker will catch it.** `httpx` very likely remains installed in your environment (the Anthropic, OpenAI, and Google SDKs all depend on it), so code that catches old-httpx exceptions around FastMCP calls still imports and still type-checks — it just never matches, because FastMCP now raises `httpx2` exceptions. The handler silently becomes dead code:
```python
import httpx # still installed transitively — this import works
try:
result = await client.call_tool("fetch", {"url": url})
except httpx.ConnectError: # dead code: FastMCP now raises httpx2.ConnectError
return fallback()
```
Grep your codebase for `except httpx.` and move those handlers to `httpx2`. The exception hierarchies match name-for-name, so the fix is an import swap — the hard part is remembering to look. One place you are covered automatically: exceptions raised *inside your tools and resources* (for example, a tool whose own old-httpx call gets a 429) are still mapped to `ToolError`/`ResourceError` by FastMCP's error boundary, which recognizes both libraries' exceptions during the transition.
Two runtime behaviors shift with httpx2, and because the switch is now wholesale they apply to **all** FastMCP HTTP — including server-auth upstream calls, not just the client path. TLS verification uses the operating system's trust store (via `truststore`, honoring `SSL_CERT_FILE`/`SSL_CERT_DIR`) instead of the bundled certifi CA set, so corporate-CA or certifi-pinned setups may verify differently. And the FastMCP HTTP loggers are renamed from `httpx`/`httpcore.*` to `httpx2`/`httpcore2.*` — update any logging filters that select the HTTP stack by logger name.
## Deprecation timeline
The camelCase bridge is a migration aid, not a permanent fixture. It works today and warns on every bridged read so you can find and update the affected call sites. Plan to migrate your reads to snake_case: the shims will be removed in a future release, after which only the snake_case names resolve — the same state you get today by setting `mcp_camelcase_compat = False`. Turning the setting off is a good way to surface every remaining camelCase read in your code as a hard `AttributeError` before the shims go away.

View file

@ -409,7 +409,7 @@ The `EntraOBOToken` dependency handles the complete OBO flow automatically. Decl
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.azure import AzureProvider, EntraOBOToken
import httpx
import httpx2
auth_provider = AzureProvider(
client_id="your-client-id",
@ -431,7 +431,7 @@ async def get_recent_emails(
graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]),
) -> list[dict]:
"""Get the user's recent emails from Microsoft Graph."""
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
response = await client.get(
f"https://graph.microsoft.com/v1.0/me/messages?$top={count}",
headers={"Authorization": f"Bearer {graph_token}"},

View file

@ -23,11 +23,10 @@ Before you begin, you will need:
### Step 1: Configure Descope
<Steps>
<Step title="Create an MCP Server">
1. Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console, and create a new MCP Server.
2. Give the MCP server a name and description.
3. Ensure that **Dynamic Client Registration (DCR)** is enabled. Then click **Create**.
4. Once you've created the MCP Server, note your Well-Known URL.
<Step title="Configure a Descope application">
You can use either a resource-specific Descope MCP Server or a project-level inbound app.
To create an MCP Server, go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console, create a server, and enable **Dynamic Client Registration (DCR)**.
<Warning>
@ -35,10 +34,17 @@ Before you begin, you will need:
</Warning>
</Step>
<Step title="Note Your Well-Known URL">
Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers):
<Step title="Copy the Well-Known URL">
`DescopeProvider` accepts both resource-specific MCP Server URLs:
```
Well-Known URL: https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration
https://api.descope.com/v1/apps/agentic/P.../M.../.well-known/openid-configuration
```
and project-level inbound app URLs:
```
https://api.descope.com/v1/apps/P.../.well-known/openid-configuration
```
</Step>
</Steps>
@ -48,7 +54,7 @@ Before you begin, you will need:
Create a `.env` file with your Descope configuration:
```bash
DESCOPE_CONFIG_URL=https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration # Your Descope Well-Known URL
DESCOPE_CONFIG_URL=https://api.descope.com/v1/apps/P.../.well-known/openid-configuration
SERVER_URL=http://localhost:3000 # Your server's base URL
```
@ -60,18 +66,35 @@ Create your FastMCP server file and use the DescopeProvider to handle all the OA
from fastmcp import FastMCP
from fastmcp.server.auth.providers.descope import DescopeProvider
# The DescopeProvider automatically discovers Descope endpoints
# and configures JWT token validation
# DescopeProvider accepts either supported Well-Known URL format.
auth_provider = DescopeProvider(
config_url="https://.../.well-known/openid-configuration", # Your MCP Server .well-known URL
base_url=SERVER_URL, # Your server's public URL
config_url="https://api.descope.com/v1/apps/P.../.well-known/openid-configuration",
base_url="https://your-fastmcp-server.com",
)
# Create FastMCP server with auth
mcp = FastMCP(name="My Descope Protected Server", auth=auth_provider)
```
### Scope discovery and validation
When both `scopes_supported` and `required_scopes` are omitted, `DescopeProvider` discovers `scopes_supported` lazily from the OpenID configuration and advertises them to MCP clients. Provider construction remains network-free, and a transient discovery failure is retried on a later metadata request.
Set both options when clients should request a broader set of scopes than the server requires on every token:
```python
from fastmcp.server.auth.providers.descope import DescopeProvider
auth_provider = DescopeProvider(
config_url="https://api.descope.com/v1/apps/P.../.well-known/openid-configuration",
base_url="https://your-fastmcp-server.com",
scopes_supported=["mcp:read", "mcp:write"],
required_scopes=["mcp:read"],
)
```
`scopes_supported` controls what the protected resource metadata advertises. `required_scopes` controls what the JWT verifier requires during token validation. When only `required_scopes` is set, those scopes are also advertised to clients.
## Testing
To test your server, you can use the `fastmcp` CLI to run it locally. Assuming you've saved the above code to `server.py` (after replacing the environment variables with your actual values!), you can run the following command:

View file

@ -26,14 +26,14 @@ We recommend using the FastAPI integration for bootstrapping and prototyping, no
To convert an OpenAPI specification to an MCP server, use the `FastMCP.from_openapi()` class method:
```python server.py
import httpx
import httpx2
from fastmcp import FastMCP
# Create an HTTP client for your API
client = httpx.AsyncClient(base_url="https://api.example.com")
client = httpx2.AsyncClient(base_url="https://api.example.com")
# Load your OpenAPI spec
openapi_spec = httpx.get("https://api.example.com/openapi.json").json()
openapi_spec = httpx2.get("https://api.example.com/openapi.json").json()
# Create the MCP server
mcp = FastMCP.from_openapi(
@ -51,11 +51,11 @@ if __name__ == "__main__":
If your API requires authentication, configure it on the HTTP client:
```python
import httpx
import httpx2
from fastmcp import FastMCP
# Bearer token authentication
api_client = httpx.AsyncClient(
api_client = httpx2.AsyncClient(
base_url="https://api.example.com",
headers={"Authorization": "Bearer YOUR_TOKEN"}
)

View file

@ -72,6 +72,12 @@ These control how the server listens when running with an HTTP transport.
| `FASTMCP_SHOW_SERVER_BANNER` | `bool` | `true` | Show the server banner on startup. Also controllable via `--no-banner` or `server.run(show_banner=False)`. |
| `FASTMCP_CHECK_FOR_UPDATES` | `Literal["stable", "prerelease", "off"]` | `stable` | Update checking on CLI startup. `stable` checks stable releases only, `prerelease` includes pre-releases, `off` disables checking. |
## Telemetry
| Environment Variable | Type | Default | Description |
|---|---|---|---|
| `FASTMCP_ENABLE_TELEMETRY` | `bool` | `true` | Whether FastMCP's native [OpenTelemetry instrumentation](/servers/telemetry) is active. Enabled by default; FastMCP uses only the OpenTelemetry API, so span creation is a no-op with negligible overhead unless an OpenTelemetry SDK and exporter are configured. Set to `false` to turn instrumentation off entirely, in which case no FastMCP spans are created even when an SDK is configured. |
## Tasks (Docket)
These configure the [Docket](https://github.com/prefecthq/docket) task queue used by [server tasks](/servers/tasks). All use the `FASTMCP_DOCKET_` prefix.

View file

@ -147,7 +147,7 @@ When not set, `scopes_supported` defaults to the token verifier's `required_scop
You can extend `RemoteAuthProvider` to add additional endpoints beyond the standard OAuth protected resource metadata. These don't have to be OAuth-specific - you can add any endpoints your authentication integration requires.
```python
import httpx
import httpx2
from starlette.responses import JSONResponse
from starlette.routing import Route
@ -173,7 +173,7 @@ class CompanyAuthProvider(RemoteAuthProvider):
# Add authorization server metadata forwarding for client convenience
async def authorization_server_metadata(request):
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
response = await client.get(
"https://auth.yourcompany.com/.well-known/oauth-authorization-server"
)

View file

@ -327,21 +327,21 @@ This pattern enables comprehensive testing of JWT validation logic without depen
<VersionBadge version="2.18.0" />
All token verifiers that make HTTP calls accept an optional `http_client` parameter. This lets you provide your own `httpx.AsyncClient` for connection pooling, custom TLS configuration, or proxy settings.
All token verifiers that make HTTP calls accept an optional `http_client` parameter. This lets you provide your own `httpx2.AsyncClient` for connection pooling, custom TLS configuration, or proxy settings.
### Connection Pooling
By default, each token verification call creates a fresh HTTP client. Under high load, this means repeated TCP connections and TLS handshakes. Providing a shared client enables connection pooling across calls:
```python
import httpx
import httpx2
from fastmcp import FastMCP
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
# Create a shared client with connection pooling
http_client = httpx.AsyncClient(
http_client = httpx2.AsyncClient(
timeout=10,
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
limits=httpx2.Limits(max_connections=20, max_keepalive_connections=10),
)
verifier = IntrospectionTokenVerifier(
@ -378,7 +378,7 @@ from contextlib import asynccontextmanager
from fastmcp import FastMCP
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
http_client = httpx.AsyncClient(timeout=10)
http_client = httpx2.AsyncClient(timeout=10)
verifier = IntrospectionTokenVerifier(
introspection_url="https://auth.example.com/introspect",

View file

@ -186,7 +186,7 @@ from contextlib import asynccontextmanager
from collections.abc import AsyncIterator, Sequence
from fastmcp.server.providers import Provider
from fastmcp.resources import Resource
import httpx
import httpx2
class ApiResourceProvider(Provider):
"""Provides resources backed by an external API."""
@ -199,7 +199,7 @@ class ApiResourceProvider(Provider):
@asynccontextmanager
async def lifespan(self) -> AsyncIterator[None]:
self.client = httpx.AsyncClient(
self.client = httpx2.AsyncClient(
base_url=self.base_url,
headers={"Authorization": f"Bearer {self.api_key}"}
)

View file

@ -350,7 +350,7 @@ if data_dir_path.is_dir():
- `TextResource`: For simple string content.
- `BinaryResource`: For raw `bytes` content.
- `FileResource`: Reads content from a local file path. Handles text/binary modes, encoding, and lazy reading.
- `HttpResource`: Fetches content from an HTTP(S) URL (requires `httpx`).
- `HttpResource`: Fetches content from an HTTP(S) URL (requires `httpx2`).
- `DirectoryResource`: Lists files in a local directory (returns JSON).
- (`FunctionResource`: Internal class used by `@mcp.resource`).
@ -522,11 +522,85 @@ Wildcard parameters are useful when:
Note that like regular parameters, each wildcard parameter must still be a named parameter in your function signature, and all required function parameters must appear in the URI template.
#### Filesystem Path Safety
#### Path Security
Template parameters are decoded before your function receives them. A standard `{filename}` parameter matches one URI segment before decoding, so a request like `files://a%2Fb` passes `filename="a/b"` to the handler. Treat template values as untrusted decoded URI data whenever they determine filesystem paths.
Template parameters are extracted from the request URI and decoded before your function receives them, so a path-traversal payload like `../` or an absolute path can reach a handler that builds filesystem paths or upstream URLs. FastMCP screens every templated resource's parameter values **before the handler runs**, and this screening is **on by default**.
Validate the final resolved path against an allowed root before reading:
By default, a parameter value is rejected if its `..` path segments would escape the value's own starting depth, if it looks like an absolute path, or if it contains a null byte. A rejected read surfaces a clean "resource not found" error to the client and logs the reason at debug level, so the failing parameter and policy are never revealed on the wire.
The traversal check is component-based and tracks net depth: `..` only counts against you when it climbs above where the value starts. `../secret`, a bare `..`, and `a/../../b` are rejected; `foo/../bar` is allowed because it never leaves the starting directory, and values that merely *contain* dots — `HEAD~3..HEAD`, `v1..v2`, `file.tar.gz`, dotfiles like `.env` — all pass. Screening runs on the decoded value, so `..%2F` is caught the same as a literal `../`. This bounds relative escapes; anchoring the *final* path inside a root directory is still your handler's job (for example with `safe_join`), since only the handler knows what the value is joined to.
```python
from pathlib import Path
from fastmcp import FastMCP
mcp = FastMCP(name="DocsServer")
DOCS_ROOT = Path("/srv/docs")
@mcp.resource("docs://{path*}")
def read_doc(path: str) -> str:
# A request for docs://../secret is rejected before this runs.
return (DOCS_ROOT / path).read_text(encoding="utf-8")
```
##### Exempting parameters
Some parameters legitimately carry values that look like traversal — a git ref, a version range, an opaque token. Exempt them by name with `ResourceSecurity`:
```python
from fastmcp import FastMCP
from fastmcp.resources import ResourceSecurity
mcp = FastMCP(name="DocsServer")
@mcp.resource(
"git://diff/{ref}",
security=ResourceSecurity(exempt_params={"ref"}),
)
def git_diff(ref: str) -> str:
# ref="HEAD~3..HEAD" is allowed
...
```
##### Disabling screening
Pass `security=None` to turn screening off for a single component:
```python
from fastmcp import FastMCP
mcp = FastMCP(name="DocsServer")
@mcp.resource("raw://{value}", security=None)
def raw(value: str) -> str: ...
```
Or set a server-wide default with `resource_security`, which applies to every templated resource that does not set its own `security`:
```python
from fastmcp import FastMCP
from fastmcp.resources import ResourceSecurity
# Relax one check across the whole server:
relaxed = FastMCP(
name="DocsServer",
resource_security=ResourceSecurity(reject_absolute_paths=False),
)
# Or disable screening entirely across the server:
unscreened = FastMCP(name="DocsServer", resource_security=None)
```
A per-component `security` always overrides the server default.
<Warning>
Screening rejects the obvious injection shapes, but it does not know your filesystem root. When a parameter determines a real path, still resolve it against an allowed root and confirm containment before reading — screening and containment are complementary layers.
</Warning>
```python
from pathlib import Path
@ -548,8 +622,6 @@ def read_doc(filename: str) -> str:
return requested_path.read_text(encoding="utf-8")
```
Use wildcard parameters (`{path*}`) for resources whose URI shape intentionally includes slashes, and apply the same containment check before accessing the filesystem.
#### Query Parameters
<VersionBadge version="2.13.0" />

View file

@ -6,17 +6,23 @@ icon: chart-line
tag: NEW
---
FastMCP includes native OpenTelemetry instrumentation for observability. Traces are automatically generated for tool, prompt, resource, and resource template operations, providing visibility into server behavior, request handling, and provider delegation chains.
FastMCP includes native OpenTelemetry instrumentation for observability. Traces are automatically generated for tool, prompt, resource, resource template, and task management operations, providing visibility into server behavior, request handling, and provider delegation chains.
## How It Works
FastMCP uses the OpenTelemetry API for instrumentation. This means:
- **Zero configuration required** - Instrumentation is always active
- **On by default** - Instrumentation is active out of the box, no opt-in required
- **No overhead when unused** - Without an SDK, all operations are no-ops
- **Bring your own SDK** - You control collection, export, and sampling
- **Works with any OTEL backend** - Jaeger, Zipkin, Datadog, New Relic, etc.
Because FastMCP only depends on the OpenTelemetry API, span creation is a no-op until you configure an SDK and exporter — so being on by default costs nothing until you opt into collection.
### Turning Telemetry Off
To disable FastMCP's instrumentation entirely, set `FASTMCP_ENABLE_TELEMETRY=false` (or `fastmcp.settings.enable_telemetry = False`). When disabled, FastMCP creates no spans even if an SDK is configured.
## Enabling Telemetry
The easiest way to export traces is using `opentelemetry-instrument`, which configures the SDK automatically:
@ -63,12 +69,13 @@ The server creates spans for each operation using [MCP semantic conventions](htt
| `tools/call {name}` | Tool execution (e.g., `tools/call get_weather`) |
| `resources/read` | Resource read (URI in `mcp.resource.uri` attribute, not span name) |
| `prompts/get {name}` | Prompt render (e.g., `prompts/get greeting`) |
| `tasks/{operation}` | Task management (`tasks/get`, `tasks/result`, `tasks/list`, or `tasks/cancel`) |
For mounted servers, an additional `delegate {name}` span shows the delegation to the child server.
### Client Spans
The FastMCP client creates spans for outgoing requests with the same naming pattern (`tools/call {name}`, `resources/read`, `prompts/get {name}`).
The FastMCP client creates spans for outgoing requests with the same naming pattern (`tools/call {name}`, `resources/read`, `prompts/get {name}`, and `tasks/{operation}`).
### Span Hierarchy
@ -89,6 +96,54 @@ tools/call remote_search (CLIENT)
└── [remote server spans via trace context propagation]
```
### Background tasks
Background task traces have two parts:
- Task submission and management requests use normal client-to-server context propagation. `tasks/get`, `tasks/result`, `tasks/list`, and `tasks/cancel` server spans are descendants of the corresponding FastMCP client spans.
- Deferred execution runs in a Docket worker. Docket records its `CONSUMER` span as a new trace root with a span link to the submission context, rather than making it a child of the submission span. Custom spans created inside the task are children of that worker span.
Span links preserve the causal relationship without forcing worker sampling to inherit the submit trace's sampling decision. Some tracing backends do not display links prominently, so the worker trace may look disconnected even though the link is present.
Frequent status and list polling can produce more detail than you need. You can drop those client and server spans with a sampler that checks the span name before delegating to `ParentBased`:
```python
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.sampling import (
ALWAYS_ON,
Decision,
ParentBased,
Sampler,
SamplingResult,
)
class DropTaskPolls(Sampler):
def __init__(self):
self._delegate = ParentBased(ALWAYS_ON)
def should_sample(self, parent_context, trace_id, name, *args, **kwargs):
if name in {"tasks/get", "tasks/list"}:
return SamplingResult(Decision.DROP)
return self._delegate.should_sample(
parent_context,
trace_id,
name,
*args,
**kwargs,
)
def get_description(self):
return "DropTaskPolls"
provider = TracerProvider(sampler=DropTaskPolls())
trace.set_tracer_provider(provider)
```
The name check must happen before `ParentBased` delegates. If the name-based sampler is nested inside `ParentBased`, it is not consulted for child spans whose parent was already sampled.
## Programmatic Configuration
For more control, configure the SDK in your Python code before importing FastMCP:
@ -270,7 +325,8 @@ FastMCP implements the [OpenTelemetry MCP semantic conventions](https://opentele
| Attribute | Description |
|-----------|-------------|
| `mcp.method.name` | The MCP method being called (`tools/call`, `resources/read`, `prompts/get`) |
| `mcp.method.name` | The MCP method being called (`tools/call`, `resources/read`, `prompts/get`, `tasks/get`, etc.) |
| `mcp.protocol.version` | The negotiated MCP protocol version for the request |
| `mcp.session.id` | Session identifier for the MCP connection |
| `mcp.resource.uri` | The resource URI (for resource operations) |
| `gen_ai.tool.name` | Tool name (on `tools/call` spans) |

View file

@ -32,7 +32,7 @@ For this tutorial, we'll use the [JSONPlaceholder API](https://jsonplaceholder.t
## Step 2: Create the MCP Server
Now for the magic. We'll use `FastMCP.from_openapi`. This method takes an `httpx.AsyncClient` configured for your API and its OpenAPI specification, and automatically converts **every endpoint** into a callable MCP `Tool`.
Now for the magic. We'll use `FastMCP.from_openapi`. This method takes an `httpx2.AsyncClient` configured for your API and its OpenAPI specification, and automatically converts **every endpoint** into a callable MCP `Tool`.
<Tip>
Learn more about working with OpenAPI specs in the [OpenAPI integration docs](/integrations/openapi).
@ -45,11 +45,11 @@ For this tutorial, we'll use a simplified OpenAPI spec directly in the code. In
Create a file named `api_server.py`:
```python api_server.py {31-35}
import httpx
import httpx2
from fastmcp import FastMCP
# Create an HTTP client for the target API
client = httpx.AsyncClient(base_url="https://jsonplaceholder.typicode.com")
client = httpx2.AsyncClient(base_url="https://jsonplaceholder.typicode.com")
# Define a simplified OpenAPI spec for JSONPlaceholder
openapi_spec = {
@ -150,13 +150,13 @@ Learn more about route maps in the [OpenAPI integration docs](/integrations/open
Heres how you can add custom route maps to turn `GET` requests into `Resources` and `ResourceTemplates` (if they have path parameters):
```python api_server_with_resources.py {3, 37-42}
import httpx
import httpx2
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import RouteMap, MCPType
# Create an HTTP client for the target API
client = httpx.AsyncClient(base_url="https://jsonplaceholder.typicode.com")
client = httpx2.AsyncClient(base_url="https://jsonplaceholder.typicode.com")
# Define a simplified OpenAPI spec for JSONPlaceholder
openapi_spec = {

View file

@ -11,7 +11,7 @@ from __future__ import annotations
from textwrap import dedent
import httpx
import httpx2
from prefab_ui.app import PrefabApp
from prefab_ui.components import (
Badge,
@ -32,7 +32,7 @@ NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
def _geocode(query: str) -> dict | None:
"""Geocode an address using OpenStreetMap Nominatim (free, no key)."""
resp = httpx.get(
resp = httpx2.get(
NOMINATIM_URL,
params={"q": query, "format": "json", "limit": 1},
headers={"User-Agent": "fastmcp-map-example/1.0"},

View file

@ -224,7 +224,7 @@ def _build_quote_with_images_embed(
quote_uri: str, image_urls: list[str], image_alts: list[str] | None, client
):
"""Build quote embed with images."""
import httpx
import httpx2
# Get the quoted post
quoted_post = client.app.bsky.feed.get_posts(params={"uris": [quote_uri]})
@ -239,7 +239,7 @@ def _build_quote_with_images_embed(
alts = image_alts or [""] * len(image_urls)
for i, url in enumerate(image_urls[:4]):
response = httpx.get(url, follow_redirects=True)
response = httpx2.get(url, follow_redirects=True)
response.raise_for_status()
# Upload to blob storage
@ -267,7 +267,7 @@ def _send_images(
client,
):
"""Send post with images using the client's send_images method."""
import httpx
import httpx2
# Ensure alt_texts has same length as images
if image_alts is None:
@ -279,7 +279,7 @@ def _send_images(
alts = []
for i, url in enumerate(image_urls[:4]): # Max 4 images
# Download image (follow redirects)
response = httpx.get(url, follow_redirects=True)
response = httpx2.get(url, follow_redirects=True)
response.raise_for_status()
image_data.append(response.content)

View file

@ -7,7 +7,7 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
import httpx
import httpx2
from fastmcp import FastMCP
from fastmcp.server import create_proxy
@ -44,7 +44,7 @@ async def lifespan(server: FastMCP) -> AsyncIterator[None]:
)
# Wait for server to be ready (async to avoid blocking event loop)
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
for _ in range(50):
try:
await client.get(

View file

@ -3,11 +3,13 @@ revision = 3
requires-python = ">=3.10"
[options]
exclude-newer = "2026-06-20T14:09:01.281965Z"
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P1W"
[options.exclude-newer-package]
mcp-types = false
prefab-ui = false
mcp = false
[[package]]
name = "aiofile"
@ -625,7 +627,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.26.0"
version = "1.27.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -643,9 +645,9 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
{ url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" },
]
[[package]]

View file

@ -20,7 +20,7 @@ Visit https://surgemsg.com/ and click "Get Started" to obtain these values.
from typing import Annotated
import httpx
import httpx2
from pydantic import BeforeValidator
from pydantic_settings import BaseSettings, SettingsConfigDict
@ -49,7 +49,7 @@ surge_settings = SurgeSettings() # type: ignore
@mcp.tool(name="textme", description="Send a text message to me")
def text_me(text_content: str) -> str:
"""Send a text message to a phone number via https://surgemsg.com/"""
with httpx.Client() as client:
with httpx2.Client() as client:
response = client.post(
"https://api.surgemsg.com/messages",
headers={

View file

@ -42,8 +42,8 @@ from pathlib import Path
from typing import Any
from urllib.parse import urlencode
import httpcore
import httpx
import httpcore2
import httpx2
import uvicorn
from starlette.applications import Starlette
from starlette.requests import Request
@ -1300,7 +1300,7 @@ def _fetch_app_bridge_bundle_sync(
# We do this before the (potentially cached) app-bridge download so that
# any network error is surfaced early and clearly.
types_url = f"{sdk_base}/types.js"
with httpx.Client(timeout=30.0) as client:
with httpx2.Client(timeout=30.0) as client:
resp = client.get(types_url, follow_redirects=True)
resp.raise_for_status()
types_content = resp.text
@ -1314,7 +1314,7 @@ def _fetch_app_bridge_bundle_sync(
zod_wrapper_path = zod_wrapper_match.group(1) # e.g. /zod@^4.3.5/v4?target=es2022
zod_wrapper_url = f"https://esm.sh{zod_wrapper_path}"
with httpx.Client(timeout=30.0) as client:
with httpx2.Client(timeout=30.0) as client:
resp = client.get(zod_wrapper_url, follow_redirects=True)
resp.raise_for_status()
wrapper_content = resp.text
@ -1344,7 +1344,7 @@ def _fetch_app_bridge_bundle_sync(
return app_bridge_js, import_map_json
npm_url = f"https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-{version}.tgz"
with httpx.Client(timeout=30.0) as client:
with httpx2.Client(timeout=30.0) as client:
resp = client.get(npm_url, follow_redirects=True)
resp.raise_for_status()
data = resp.content
@ -1545,11 +1545,11 @@ def _make_dev_app(
# Use a reasonable default timeout to prevent the proxy from hanging
# if the backend server is unresponsive.
client = httpx.AsyncClient(
timeout=httpx.Timeout(60.0, read=None), trust_env=False
client = httpx2.AsyncClient(
timeout=httpx2.Timeout(60.0, read=None), trust_env=False
)
async def _stream_and_cleanup(resp: httpx.Response) -> Any:
async def _stream_and_cleanup(resp: httpx2.Response) -> Any:
is_sse = "text/event-stream" in resp.headers.get("content-type", "")
buf: list[bytes] = []
sse_buf = ""
@ -1574,10 +1574,10 @@ def _make_dev_app(
else:
buf.append(chunk)
except (
httpx.RemoteProtocolError,
httpx.ReadError,
httpx.ReadTimeout,
httpcore.RemoteProtocolError,
httpx2.RemoteProtocolError,
httpx2.ReadError,
httpx2.ReadTimeout,
httpcore2.RemoteProtocolError,
):
pass # Connection closed during shutdown — not an error
finally:
@ -1617,7 +1617,7 @@ def _make_dev_app(
headers=fwd_headers,
media_type=content_type or "application/octet-stream",
)
except (httpx.ConnectError, httpx.ConnectTimeout):
except (httpx2.ConnectError, httpx2.ConnectTimeout):
await client.aclose()
return Response(
content=json.dumps({"error": "MCP server not reachable"}).encode(),
@ -1709,15 +1709,15 @@ async def _wait_for_server(url: str, timeout: float = 15.0) -> bool:
"""Poll until the server is accepting connections."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
async with httpx.AsyncClient(trust_env=False) as client:
async with httpx2.AsyncClient(trust_env=False) as client:
while loop.time() < deadline:
try:
await client.get(url, timeout=1.0)
return True
except (
httpx.ConnectError,
httpx.RemoteProtocolError,
httpx.TimeoutException,
httpx2.ConnectError,
httpx2.RemoteProtocolError,
httpx2.TimeoutException,
):
await asyncio.sleep(0.25)
return False

View file

@ -1,4 +1,4 @@
import httpx
import httpx2
from pydantic import SecretStr
from fastmcp.utilities.logging import get_logger
@ -8,7 +8,7 @@ __all__ = ["BearerAuth"]
logger = get_logger(__name__)
class BearerAuth(httpx.Auth):
class BearerAuth(httpx2.Auth):
def __init__(self, token: str):
self.token = SecretStr(token)

View file

@ -7,7 +7,7 @@ from contextlib import aclosing
from typing import Any
import anyio
import httpx
import httpx2
from key_value.aio.adapters.pydantic import PydanticAdapter
from key_value.aio.protocols import AsyncKeyValue
from key_value.aio.stores.memory import MemoryStore
@ -51,6 +51,10 @@ class ClientNotFoundError(Exception):
"""Raised when OAuth client credentials are not found on the server."""
class ExpiredClientRegistrationError(Exception):
"""Raised when dynamic registration returns an expired client secret."""
async def check_if_auth_required(
mcp_url: str, httpx_kwargs: dict[str, Any] | None = None
) -> bool:
@ -60,7 +64,7 @@ async def check_if_auth_required(
Returns:
True if auth appears to be required, False otherwise
"""
async with httpx.AsyncClient(**(httpx_kwargs or {})) as client:
async with httpx2.AsyncClient(**(httpx_kwargs or {})) as client:
try:
# Try a simple request to the endpoint
response = await client.get(mcp_url, timeout=5.0)
@ -76,7 +80,7 @@ async def check_if_auth_required(
# If we get a successful response, auth may not be required
return False
except httpx.RequestError:
except httpx2.RequestError:
# If we can't connect, assume auth might be required
return True
@ -166,6 +170,11 @@ class TokenStorageAdapter(TokenStorage):
if client_info.client_secret_expires_at:
ttl = client_info.client_secret_expires_at - int(time.time())
if ttl <= 0:
await self._storage_client_info.delete(
key=self._get_client_info_cache_key()
)
return
await self._storage_client_info.put(
key=self._get_client_info_cache_key(),
@ -237,7 +246,7 @@ class OAuth(OAuthClientProvider):
self._client_id = client_id
self._client_secret = client_secret
self._static_client_info = None
self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient
self.httpx_client_factory = httpx_client_factory or httpx2.AsyncClient
self._bound = False
if mcp_url is not None:
@ -340,6 +349,20 @@ class OAuth(OAuthClientProvider):
else:
self.context.update_token_expiry(self.context.current_tokens)
async def _perform_authorization(self) -> httpx2.Request:
"""Reject expired registrations before attempting authorization."""
client_info = self.context.client_info
if (
client_info is not None
and client_info.client_secret is not None
and client_info.client_secret_expires_at
and client_info.client_secret_expires_at <= int(time.time())
):
raise ExpiredClientRegistrationError(
"OAuth dynamic registration returned an expired client secret"
)
return await super()._perform_authorization()
async def redirect_handler(self, authorization_url: str) -> None:
"""Open browser for authorization, with pre-flight check for invalid client."""
# Pre-flight check to detect invalid client_id before opening browser
@ -405,8 +428,8 @@ class OAuth(OAuthClientProvider):
raise RuntimeError("OAuth callback handler could not be started")
async def async_auth_flow(
self, request: httpx.Request
) -> AsyncGenerator[httpx.Request, httpx.Response]:
self, request: httpx2.Request
) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
"""HTTPX auth flow with automatic retry on stale cached credentials.
If the OAuth flow fails due to invalid/stale client credentials,
@ -429,7 +452,7 @@ class OAuth(OAuthClientProvider):
except StopAsyncIteration:
break
except ClientNotFoundError:
except (ClientNotFoundError, ExpiredClientRegistrationError) as exc:
# Static credentials are fixed — retrying won't help. Surface the
# error so the user can correct their client_id / client_secret.
if self._static_client_info is not None:
@ -437,10 +460,10 @@ class OAuth(OAuthClientProvider):
"OAuth server rejected the static client credentials. "
"Verify that the client_id (and client_secret, if provided) "
"are correct and that the client is registered with the server."
) from None
) from exc
logger.debug(
"OAuth client not found on server, clearing cache and retrying..."
"OAuth client registration is invalid, clearing cache and retrying..."
)
# Clear cached state and retry once
self._initialized = False

View file

@ -0,0 +1,223 @@
"""A client response cache store backed by AsyncKeyValue.
The MCP SDK's client response cache (SEP-2549) reads and writes through a
pluggable `ResponseCacheStore` protocol; the default is a per-client in-memory
LRU. This module adapts that protocol onto the `AsyncKeyValue` key-value
abstraction FastMCP already uses for its other state-management surfaces (the
event store, the OAuth proxy, the response-caching middleware), so a fleet of
FastMCP clients for example a set of proxy replicas can share one
Redis-backed response cache.
Because a shared store mingles cached responses across principals, the SDK
requires an explicit `partition` on any custom store (and FastMCP additionally
requires a `target_id`). The partition is folded into every stored key so
entries can never collide or leak across authorization contexts, and the
adapter round-trips each result through a small type-tagged envelope validated
against an allowlist of cacheable result models a stored value that names an
unknown type is treated as a miss, never imported by name.
Example:
```python
from fastmcp import Client
from fastmcp.client.caching import KeyValueResponseCacheStore
from mcp.client.caching import CacheConfig
from key_value.aio.stores.redis import RedisStore
store = KeyValueResponseCacheStore(storage=RedisStore(url="redis://localhost"))
config = CacheConfig(store=store, partition="tenant-a", target_id="weather-api")
client = Client("https://example.com/mcp", mode="auto", cache=config)
```
"""
from __future__ import annotations
import hashlib
import time
from types import UnionType
from typing import Literal, get_args
from key_value.aio.adapters.pydantic import PydanticAdapter
from key_value.aio.protocols import AsyncKeyValue
from key_value.aio.protocols.key_value import (
AsyncDestroyCollectionProtocol,
AsyncEnumerateKeysProtocol,
)
from key_value.aio.stores.memory import MemoryStore
from mcp.client.caching import CacheEntry, CacheKey
from mcp_types import CacheableResult
from mcp_types.methods import MONOLITH_RESULTS
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import FastMCPBaseModel
logger = get_logger(__name__)
DEFAULT_CACHE_COLLECTION = "fastmcp_response_cache"
"""Collection namespace owned by one adapter instance; `clear()` never reaches beyond it."""
def _cacheable_result_models() -> dict[str, type[CacheableResult]]:
"""Allowlist of `{class name: model}` for every cacheable result type.
Derived from `MONOLITH_RESULTS` (the SDK's per-method result registry) so it
tracks the CACHEABLE_METHODS surface automatically. The class name is the
type tag written into the envelope; reconstruction looks the model up here
rather than importing an arbitrary name from store contents.
"""
models: dict[str, type[CacheableResult]] = {}
for row in MONOLITH_RESULTS.values():
arms = get_args(row) if isinstance(row, UnionType) else (row,)
for arm in arms:
if isinstance(arm, type) and issubclass(arm, CacheableResult):
models[arm.__name__] = arm
return models
CACHEABLE_RESULT_MODELS = _cacheable_result_models()
"""Type tag -> model class allowlist for envelope reconstruction."""
class _CacheEnvelope(FastMCPBaseModel):
"""Serializable form of a `CacheEntry` for a remote store.
A `CacheEntry.value` is a cacheable result model; a remote store cannot hold
it as an object, so it is serialized to `value_json` under a `type_tag`
(the model class name) and reconstructed against the allowlist on read. The
freshness/sharing metadata (`scope`, `expires_at`) round-trips alongside it.
"""
type_tag: str
value_json: str
scope: str
expires_at: float | None
class KeyValueResponseCacheStore:
"""A `ResponseCacheStore` backed by any `AsyncKeyValue` store.
Implements the SDK client response cache contract (`get`/`set`/`delete`/
`clear`) over the key-value abstraction FastMCP already uses elsewhere, so a
distributed deployment can point every client at one shared backend (memory,
Redis, etc.). Pass an instance as `CacheConfig(store=...)`; the SDK requires
an explicit `partition` on any custom store, and FastMCP additionally
requires a `target_id`.
Each adapter instance owns one collection (`collection`), so `clear()` only
affects its own namespace and never another tenant's data. `clear()` needs
the backend to support collection destruction or key enumeration; against a
backend that supports neither it is a no-op and entries age out by TTL (a
warning is logged once).
The SDK wraps every store call defensively a raised operation degrades to
a cache miss rather than failing the request so this adapter does not
re-wrap its own operations.
Args:
storage: The `AsyncKeyValue` backend. Defaults to an in-process `MemoryStore`.
collection: Collection namespace for this adapter's entries.
"""
def __init__(
self,
storage: AsyncKeyValue | None = None,
*,
collection: str = DEFAULT_CACHE_COLLECTION,
) -> None:
self._storage: AsyncKeyValue = storage or MemoryStore()
self._collection = collection
self._adapter: PydanticAdapter[_CacheEnvelope] = PydanticAdapter[
_CacheEnvelope
](
key_value=self._storage,
pydantic_model=_CacheEnvelope,
default_collection=collection,
)
self._warned_clear_unsupported = False
@staticmethod
def _string_key(key: CacheKey) -> str:
"""Derive a stable store key from every `CacheKey` field.
`CacheKey` is `(method, params_key, partition)`, where the coordinator has
already packed scope, negotiated protocol version, server arm id, and the
caller's partition into the `partition` field as a JSON array. Every field
is folded into the digest, so entries cannot collide across partitions,
protocol eras, or servers. The fields are length-prefixed before hashing
so no two distinct field tuples can produce the same pre-image.
"""
parts = [key.method, key.params_key, key.partition]
preimage = "".join(f"{len(part)}:{part}" for part in parts)
return hashlib.sha256(preimage.encode("utf-8")).hexdigest()
async def get(self, key: CacheKey) -> CacheEntry | None:
envelope = await self._adapter.get(key=self._string_key(key))
if envelope is None:
return None
model = CACHEABLE_RESULT_MODELS.get(envelope.type_tag)
if model is None:
# An unknown tag is never imported by name; a wrong-shape entry is a miss.
return None
value = model.model_validate_json(envelope.value_json)
scope: Literal["public", "private"] = (
"public" if envelope.scope == "public" else "private"
)
return CacheEntry(value=value, scope=scope, expires_at=envelope.expires_at)
async def set(self, key: CacheKey, entry: CacheEntry) -> None:
value = entry.value
if not isinstance(value, CacheableResult):
return
type_tag = type(value).__name__
if type_tag not in CACHEABLE_RESULT_MODELS:
return
envelope = _CacheEnvelope(
type_tag=type_tag,
value_json=value.model_dump_json(by_alias=True),
scope=entry.scope,
expires_at=entry.expires_at,
)
ttl = self._entry_ttl(entry)
await self._adapter.put(key=self._string_key(key), value=envelope, ttl=ttl)
async def delete(self, key: CacheKey) -> None:
await self._adapter.delete(key=self._string_key(key))
async def clear(self) -> None:
"""Clear this adapter's collection only.
Prefers deleting each enumerated key (which leaves the collection
usable), and falls back to whole-collection destruction. Against a
backend that supports neither, this is a no-op (entries age out by TTL)
and a warning is logged once. Either path is scoped to this adapter's
own collection, so a shared store's other tenants are never touched.
"""
storage = self._storage
if isinstance(storage, AsyncEnumerateKeysProtocol):
keys = await storage.keys(collection=self._collection)
for stored_key in keys:
await storage.delete(key=stored_key, collection=self._collection)
return
if isinstance(storage, AsyncDestroyCollectionProtocol):
await storage.destroy_collection(collection=self._collection)
return
if not self._warned_clear_unsupported:
self._warned_clear_unsupported = True
logger.warning(
"Response cache store backend %s supports neither collection "
"destruction nor key enumeration; clear() is a no-op and entries "
"will age out by TTL.",
type(storage).__name__,
)
def _entry_ttl(self, entry: CacheEntry) -> float | None:
"""Seconds until the entry's own expiry, so the backend can evict it independently.
The SDK gates freshness on `expires_at`, but a shared backend should not
retain a stale entry indefinitely; a store TTL lets it reclaim space. A
non-positive remaining lifetime stores with no backend TTL (the SDK will
still treat the already-stale entry as a miss).
"""
if entry.expires_at is None:
return None
remaining = entry.expires_at - time.time()
return remaining if remaining > 0 else None

View file

@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import anyio
import anyio.lowlevel
import httpx
import httpx2
import mcp_types
from exceptiongroup import catch
from mcp import ClientSession, MCPError
@ -362,7 +362,7 @@ class Client(
auto_initialize: bool = True,
init_timeout: datetime.timedelta | float | int | None = None,
client_info: mcp_types.Implementation | None = None,
auth: httpx.Auth | Literal["oauth"] | str | None = None,
auth: httpx2.Auth | Literal["oauth"] | str | None = None,
verify: ssl.SSLContext | bool | str | None = None,
mode: ConnectMode = "legacy",
prior_discover: mcp_types.DiscoverResult | None = None,
@ -896,7 +896,7 @@ class Client(
"Session task completed without exception but connection failed"
)
# Preserve specific exception types that clients may want to handle
if isinstance(exception, httpx.HTTPStatusError | MCPError):
if isinstance(exception, httpx2.HTTPStatusError | MCPError):
raise exception
raise RuntimeError(
f"Client failed to connect: {exception}"

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
import mcp_types
from mcp import MCPError
@ -23,6 +23,8 @@ from mcp_types import (
PaginatedRequestParams,
)
from fastmcp.client.telemetry import client_span
from fastmcp.telemetry import inject_trace_context
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
@ -64,13 +66,27 @@ class ClientTaskManagementMixin:
RuntimeError: If client not connected
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
request = GetTaskRequest(params=GetTaskRequestParams(task_id=task_id))
return await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=GetTaskResult,
with client_span(
"tasks/get",
"tasks/get",
task_id,
session_id=self.transport.get_session_id(),
):
request_meta = cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context()
)
request = GetTaskRequest(
params=GetTaskRequestParams(
task_id=task_id,
_meta=request_meta, # type: ignore[unknown-argument]
)
)
return await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=GetTaskResult,
)
)
)
async def get_task_result(self: Client, task_id: str) -> Any:
"""Retrieve the raw result of a completed background task.
@ -88,20 +104,32 @@ class ClientTaskManagementMixin:
RuntimeError: If client not connected, task not found, or task failed
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
request = GetTaskPayloadRequest(
params=GetTaskPayloadRequestParams(task_id=task_id)
)
# Return raw result - Task classes handle type-specific parsing
result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=_RawTaskPayloadResult,
with client_span(
"tasks/result",
"tasks/result",
task_id,
session_id=self.transport.get_session_id(),
):
request_meta = cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context()
)
)
# Return as dict for compatibility with Task class parsing. The payload
# fields (content, structuredContent, messages, contents, ...) survive
# via the permissive result type's extra="allow".
return result.model_dump(exclude_none=True, by_alias=True)
request = GetTaskPayloadRequest(
params=GetTaskPayloadRequestParams(
task_id=task_id,
_meta=request_meta, # type: ignore[unknown-argument]
)
)
# Return raw result - Task classes handle type-specific parsing
result = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[arg-type]
result_type=_RawTaskPayloadResult,
)
)
# Return as dict for compatibility with Task class parsing. The payload
# fields (content, structuredContent, messages, contents, ...) survive
# via the permissive result type's extra="allow".
return result.model_dump(exclude_none=True, by_alias=True)
async def list_tasks(
self: Client,
@ -127,31 +155,43 @@ class ClientTaskManagementMixin:
RuntimeError: If client not connected
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
# Send protocol request
params = PaginatedRequestParams(cursor=cursor, limit=limit) # type: ignore[call-arg] # Optional field in MCP SDK # ty:ignore[unknown-argument]
request = ListTasksRequest(params=params)
server_response = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[invalid-argument-type]
result_type=mcp_types.ListTasksResult,
with client_span(
"tasks/list",
"tasks/list",
"",
session_id=self.transport.get_session_id(),
):
request_meta = cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context()
)
)
# If server returned tasks, use those
if server_response.tasks:
return server_response.model_dump(by_alias=True)
# Send protocol request
params = PaginatedRequestParams.model_validate(
{"cursor": cursor, "limit": limit, "_meta": request_meta}
)
request = ListTasksRequest(params=params)
server_response = await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[invalid-argument-type]
result_type=mcp_types.ListTasksResult,
)
)
# Server returned empty - fall back to client-side tracking
tasks = []
for task_id in list(self._submitted_task_ids)[:limit]:
try:
status = await self.get_task_status(task_id)
tasks.append(status.model_dump(by_alias=True))
except MCPError:
# Task may have expired or been deleted, skip it
continue
# If server returned tasks, use those
if server_response.tasks:
return server_response.model_dump(by_alias=True)
return {"tasks": tasks, "nextCursor": None}
# Server returned empty - fall back to client-side tracking
tasks = []
for task_id in list(self._submitted_task_ids)[:limit]:
try:
status = await self.get_task_status(task_id)
tasks.append(status.model_dump(by_alias=True))
except MCPError:
# Task may have expired or been deleted, skip it
continue
return {"tasks": tasks, "nextCursor": None}
async def cancel_task(self: Client, task_id: str) -> mcp_types.CancelTaskResult:
"""Cancel a task, transitioning it to cancelled state.
@ -169,10 +209,24 @@ class ClientTaskManagementMixin:
RuntimeError: If task doesn't exist
MCPError: If the request results in a TimeoutError | JSONRPCError
"""
request = CancelTaskRequest(params=CancelTaskRequestParams(task_id=task_id))
return await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[invalid-argument-type]
result_type=mcp_types.CancelTaskResult,
with client_span(
"tasks/cancel",
"tasks/cancel",
task_id,
session_id=self.transport.get_session_id(),
):
request_meta = cast(
"mcp_types.RequestParamsMeta | None", inject_trace_context()
)
request = CancelTaskRequest(
params=CancelTaskRequestParams(
task_id=task_id,
_meta=request_meta, # type: ignore[unknown-argument]
)
)
return await self._await_with_session_monitoring(
self.session.send_request(
request=request, # type: ignore[invalid-argument-type]
result_type=mcp_types.CancelTaskResult,
)
)
)

View file

@ -2,6 +2,7 @@
import base64
from collections.abc import Sequence
from typing import Any, cast
from uuid import uuid4
try:
@ -123,7 +124,7 @@ class GoogleGenaiSamplingHandler:
max_output_tokens=params.max_tokens,
stop_sequences=params.stop_sequences,
thinking_config=thinking_config,
tools=google_tools, # ty: ignore[invalid-argument-type]
tools=cast(Any, google_tools),
tool_config=tool_config,
),
)

View file

@ -2,7 +2,7 @@
import json
from collections.abc import Iterator, Sequence
from typing import Any, Literal, get_args
from typing import Any, Literal, cast, get_args
from mcp import ClientSession, ServerSession
from mcp_types import (
@ -507,7 +507,7 @@ class OpenAISamplingHandler:
raise ValueError("No content in response from completion")
return CreateMessageResultWithTools(
content=content, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
content=cast(Any, content),
role="assistant",
model=chat_completion.model,
stop_reason=stop_reason,

View file

@ -3,7 +3,7 @@ import contextlib
from collections.abc import AsyncIterator, Sequence
from typing import Any, Literal, TypeVar
import httpx
import httpx2
import mcp_types
from mcp import ClientSession
from mcp.client.extension import NotificationBinding
@ -78,6 +78,6 @@ class ClientTransport(abc.ABC):
"""Get the session ID for this transport, if available."""
return None
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
def _set_auth(self, auth: httpx2.Auth | Literal["oauth"] | str | None):
if auth is not None:
raise ValueError("This transport does not support auth")

View file

@ -7,7 +7,7 @@ import ssl
from collections.abc import AsyncIterator
from typing import Any, Literal, cast
import httpx
import httpx2
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
@ -27,7 +27,7 @@ class StreamableHttpTransport(ClientTransport):
self,
url: str | AnyUrl,
headers: dict[str, str] | None = None,
auth: httpx.Auth | Literal["oauth"] | str | None = None,
auth: httpx2.Auth | Literal["oauth"] | str | None = None,
httpx_client_factory: McpHttpClientFactory | None = None,
verify: ssl.SSLContext | bool | str | None = None,
):
@ -36,9 +36,9 @@ class StreamableHttpTransport(ClientTransport):
Args:
url: The MCP server endpoint URL.
headers: Optional headers to include in requests.
auth: Authentication method - httpx.Auth, "oauth" for OAuth flow,
auth: Authentication method - httpx2.Auth, "oauth" for OAuth flow,
or a bearer token string.
httpx_client_factory: Optional factory for creating httpx.AsyncClient.
httpx_client_factory: Optional factory for creating httpx2.AsyncClient.
If provided, must accept keyword arguments: headers, auth,
follow_redirects, and optionally timeout. Using **kwargs is
recommended to ensure forward compatibility.
@ -82,7 +82,7 @@ class StreamableHttpTransport(ClientTransport):
# client we own (see connect_session / _capture_session_id).
self._session_id: str | None = None
async def _capture_session_id(self, response: httpx.Response) -> None:
async def _capture_session_id(self, response: httpx2.Response) -> None:
"""httpx response event hook: record the server's `mcp-session-id`.
The streamable HTTP server assigns the session id in the response to
@ -93,8 +93,8 @@ class StreamableHttpTransport(ClientTransport):
if sid:
self._session_id = sid
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
resolved: httpx.Auth | None
def _set_auth(self, auth: httpx2.Auth | Literal["oauth"] | str | None):
resolved: httpx2.Auth | None
if auth == "oauth":
resolved = OAuth(
self.url,
@ -105,7 +105,7 @@ class StreamableHttpTransport(ClientTransport):
auth._bind(self.url)
# Only inject the transport's factory into OAuth if OAuth still
# has the bare default — preserve any factory the caller attached
if auth.httpx_client_factory is httpx.AsyncClient:
if auth.httpx_client_factory is httpx2.AsyncClient:
factory = self.httpx_client_factory or self._make_verify_factory()
if factory is not None:
auth.httpx_client_factory = factory
@ -114,7 +114,7 @@ class StreamableHttpTransport(ClientTransport):
resolved = BearerAuth(auth)
else:
resolved = auth
self.auth: httpx.Auth | None = resolved
self.auth: httpx2.Auth | None = resolved
def _make_verify_factory(self) -> McpHttpClientFactory | None:
if self.verify is None:
@ -123,11 +123,11 @@ class StreamableHttpTransport(ClientTransport):
def factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
if timeout is None:
timeout = httpx.Timeout(30.0, read=300.0)
timeout = httpx2.Timeout(30.0, read=300.0)
kwargs: dict[str, Any] = {
"follow_redirects": True,
"timeout": timeout,
@ -137,7 +137,7 @@ class StreamableHttpTransport(ClientTransport):
kwargs["headers"] = headers
if auth is not None:
kwargs["auth"] = auth
return httpx.AsyncClient(**kwargs)
return httpx2.AsyncClient(**kwargs)
return cast(McpHttpClientFactory, factory)
@ -156,10 +156,10 @@ class StreamableHttpTransport(ClientTransport):
# Configure timeout if provided, preserving MCP's 30s connect default.
# SDK v2 session read timeouts are float seconds (see SessionKwargs).
timeout: httpx.Timeout | None = None
timeout: httpx2.Timeout | None = None
read_timeout_seconds = session_kwargs.get("read_timeout_seconds")
if read_timeout_seconds is not None:
timeout = httpx.Timeout(30.0, read=read_timeout_seconds)
timeout = httpx2.Timeout(30.0, read=read_timeout_seconds)
# Create httpx client from factory or use default with MCP-appropriate
# timeouts. Note: create_mcp_http_client enables follow_redirects, but

View file

@ -8,7 +8,7 @@ import ssl
from collections.abc import AsyncIterator
from typing import Any, Literal, cast
import httpx
import httpx2
from mcp import ClientSession
from mcp.client.sse import sse_client
from mcp.shared._httpx_utils import McpHttpClientFactory
@ -29,7 +29,7 @@ class SSETransport(ClientTransport):
self,
url: str | AnyUrl,
headers: dict[str, str] | None = None,
auth: httpx.Auth | Literal["oauth"] | str | None = None,
auth: httpx2.Auth | Literal["oauth"] | str | None = None,
sse_read_timeout: datetime.timedelta | float | int | None = None,
httpx_client_factory: McpHttpClientFactory | None = None,
verify: ssl.SSLContext | bool | str | None = None,
@ -65,8 +65,8 @@ class SSETransport(ClientTransport):
self.sse_read_timeout = normalize_timeout_to_timedelta(sse_read_timeout)
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
resolved: httpx.Auth | None
def _set_auth(self, auth: httpx2.Auth | Literal["oauth"] | str | None):
resolved: httpx2.Auth | None
if auth == "oauth":
resolved = OAuth(
self.url,
@ -77,7 +77,7 @@ class SSETransport(ClientTransport):
auth._bind(self.url)
# Only inject the transport's factory into OAuth if OAuth still
# has the bare default — preserve any factory the caller attached
if auth.httpx_client_factory is httpx.AsyncClient:
if auth.httpx_client_factory is httpx2.AsyncClient:
factory = self.httpx_client_factory or self._make_verify_factory()
if factory is not None:
auth.httpx_client_factory = factory
@ -86,7 +86,7 @@ class SSETransport(ClientTransport):
resolved = BearerAuth(auth)
else:
resolved = auth
self.auth: httpx.Auth | None = resolved
self.auth: httpx2.Auth | None = resolved
def _make_verify_factory(self) -> McpHttpClientFactory | None:
if self.verify is None:
@ -95,11 +95,11 @@ class SSETransport(ClientTransport):
def factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
if timeout is None:
timeout = httpx.Timeout(30.0, read=300.0)
timeout = httpx2.Timeout(30.0, read=300.0)
kwargs: dict[str, Any] = {
"follow_redirects": True,
"timeout": timeout,
@ -109,7 +109,7 @@ class SSETransport(ClientTransport):
kwargs["headers"] = headers
if auth is not None:
kwargs["auth"] = auth
return httpx.AsyncClient(**kwargs)
return httpx2.AsyncClient(**kwargs)
return cast(McpHttpClientFactory, factory)

View file

@ -80,6 +80,17 @@ class DisabledError(Exception):
"""Object is disabled."""
class ResourceSecurityError(NotFoundError):
"""A templated resource parameter failed path-security screening.
Subclasses ``NotFoundError`` so the read handler surfaces a
non-leaky ``INVALID_PARAMS`` (-32602) "resource not found" error to
the client a traversal attempt is indistinguishable from a request
for a resource that does not exist, and never reveals which parameter
or policy tripped.
"""
class AuthorizationError(FastMCPError):
"""Error when authorization check fails."""

View file

@ -30,7 +30,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
from urllib.parse import urlparse
import httpx
import httpx2
from pydantic import (
AnyUrl,
BaseModel,
@ -229,9 +229,9 @@ class RemoteMCPServer(BaseModel):
# Authentication
auth: Annotated[
str | Literal["oauth"] | httpx.Auth | None,
str | Literal["oauth"] | httpx2.Auth | None,
Field(
description='Either a string representing a Bearer token, the literal "oauth" to use OAuth authentication, or an httpx.Auth instance for custom authentication.',
description='Either a string representing a Bearer token, the literal "oauth" to use OAuth authentication, or an httpx2.Auth instance for custom authentication.',
),
] = None

View file

@ -2,6 +2,7 @@ import sys
from .function_resource import FunctionResource, resource
from .base import Resource, ResourceContent, ResourceResult
from .security import ResourceSecurity
from .template import ResourceTemplate
from .types import (
BinaryResource,
@ -20,6 +21,7 @@ __all__ = [
"Resource",
"ResourceContent",
"ResourceResult",
"ResourceSecurity",
"ResourceTemplate",
"TextResource",
"resource",

View file

@ -22,6 +22,11 @@ from pydantic import AnyUrl
from pydantic.json_schema import SkipJsonSchema
from fastmcp.resources.base import Resource, ResourceResult
from fastmcp.resources.security import (
INHERIT_SECURITY,
InheritSecurity,
ResourceSecurity,
)
from fastmcp.utilities.async_utils import (
call_sync_fn_in_threadpool,
is_coroutine_function,
@ -64,6 +69,7 @@ class ResourceMeta:
task: bool | TaskConfig | None = None
auth: AuthCheck | list[AuthCheck] | None = None
enabled: bool = True
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY
class FunctionResource(Resource):
@ -255,6 +261,7 @@ def resource(
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> Callable[[F], F]:
"""Standalone decorator to mark a function as an MCP resource.
@ -284,6 +291,7 @@ def resource(
meta=meta,
task=task,
auth=auth,
security=security,
)
target = fn.__func__ if isinstance(fn, staticmethod | MethodType) else fn
cast(Any, target).__fastmcp__ = metadata

View file

@ -0,0 +1,162 @@
"""Path-safety policy for templated resource parameters.
Templated resources (`@mcp.resource("file:///{path}")`-style) extract
parameter values straight out of the request URI and hand them to the
resource function. When those values flow into filesystem or URI
construction, a malicious client can smuggle path-traversal payloads
(`../`, absolute paths, null bytes) through the template.
`ResourceSecurity` screens extracted parameter values *before* the
resource handler runs. It is applied by default to every templated
read, mirroring the posture of the underlying MCP SDK's
`ResourceSecurity` (traversal, absolute paths, and null bytes rejected).
The screening reuses the SDK's component-based traversal check, so a
value that merely *contains* dots (e.g. `HEAD~3..HEAD`, `v1..v2`,
`file.tar.gz`) is not rejected only an actual `..` path segment is.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping, Set
from dataclasses import dataclass, field
from functools import cache
from typing import Any
from pydantic import GetCoreSchemaHandler
from pydantic_core import core_schema
__all__ = ["ResourceSecurity"]
@cache
def _path_checks() -> tuple[Callable[[str], bool], Callable[[str], bool]]:
"""Lazily load the SDK's path-safety helpers.
The screening logic lives in the `mcp` SDK, which is an optional
dependency of `fastmcp-slim`. Importing it at module top would make
`from fastmcp.resources import Resource` require the SDK, so the
import is deferred to the point of first use (and cached).
"""
from mcp.shared.path_security import (
contains_path_traversal,
is_absolute_path,
)
return contains_path_traversal, is_absolute_path
class InheritSecurity:
"""Sentinel type: inherit the server-wide resource-security default.
Distinguishes "no per-component policy was set" (inherit whatever the
server configured) from an explicit ``None`` (screening disabled for
this component).
"""
__slots__ = ()
def __repr__(self) -> str: # pragma: no cover - debug aid
return "INHERIT_SECURITY"
@classmethod
def __get_pydantic_core_schema__(
cls, source_type: Any, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
# Accept the singleton sentinel as-is; it is an internal, excluded
# field value, so no serialization support is needed.
return core_schema.is_instance_schema(cls)
INHERIT_SECURITY = InheritSecurity()
"""Sentinel instance signalling a template should inherit the server default."""
@dataclass(frozen=True)
class ResourceSecurity:
"""Security policy applied to extracted resource template parameters.
These checks run after a URI has matched a template and its
parameter values have been extracted and percent-decoded. They catch
path-traversal and absolute-path injection regardless of how the
value was encoded in the URI (literal, `%2F`, `%5C`, `%2E%2E`).
All checks default on. Screen a value like `HEAD~3..HEAD` (dots
inside a single segment) passes only a standalone `..` segment is
treated as traversal.
Example:
Opt a parameter out of screening (e.g. a git ref that may
legitimately contain `..`):
```python
from fastmcp.resources import ResourceSecurity
@mcp.resource(
"git://diff/{ref}",
security=ResourceSecurity(exempt_params={"ref"}),
)
def git_diff(ref: str) -> str: ...
```
"""
reject_path_traversal: bool = True
"""Reject values containing `..` as a path component."""
reject_absolute_paths: bool = True
"""Reject values that look like absolute filesystem paths."""
reject_null_bytes: bool = True
"""Reject values containing NUL (`\\x00`). Null bytes defeat string
comparisons (`"..\\x00" != ".."`) and can cause truncation in C
extensions or subprocess calls."""
exempt_params: Set[str] = field(default_factory=frozenset)
"""Parameter names to skip all checks for. Hyphenated URI-template
spellings are accepted: `{git-ref}` is extracted as `git_ref`, and an
exemption written either way matches it."""
def _exempt(self, name: str) -> bool:
"""True if `name` is exempted under either its extracted or its
URI-template spelling (hyphens normalize to underscores on
extraction, so `exempt_params={"git-ref"}` must match `git_ref`)."""
if name in self.exempt_params:
return True
return any(exempt.replace("-", "_") == name for exempt in self.exempt_params)
def validate(self, params: Mapping[str, object]) -> str | None:
"""Check all parameter values against the configured policy.
String values (and lists of strings, from wildcard `{path*}`
parameters that span multiple segments) are screened; non-string
values are ignored, since traversal is a string-path concern.
Args:
params: Extracted template parameters.
Returns:
The name of the first parameter that fails, or `None` if all
values pass.
"""
contains_path_traversal, is_absolute_path = _path_checks()
for name, value in params.items():
if self._exempt(name):
continue
if isinstance(value, str):
candidates = [value]
elif isinstance(value, (list, tuple)):
candidates = [v for v in value if isinstance(v, str)]
else:
continue
for candidate in candidates:
if self.reject_null_bytes and "\0" in candidate:
return name
if self.reject_path_traversal and contains_path_traversal(candidate):
return name
if self.reject_absolute_paths and is_absolute_path(candidate):
return name
return None
DEFAULT_RESOURCE_SECURITY = ResourceSecurity()
"""Secure-by-default policy: traversal, absolute paths, and null bytes rejected."""

View file

@ -24,6 +24,11 @@ from pydantic import (
)
from fastmcp.resources.base import Resource, ResourceResult
from fastmcp.resources.security import (
INHERIT_SECURITY,
InheritSecurity,
ResourceSecurity,
)
from fastmcp.utilities.authorization import AuthCheck
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.json_schema import compress_schema
@ -186,6 +191,29 @@ class ResourceTemplate(FastMCPComponent):
description="Authorization checks for this resource template",
exclude=True,
)
security: SkipJsonSchema[ResourceSecurity | None | InheritSecurity] = Field(
default=INHERIT_SECURITY,
description=(
"Path-safety policy for extracted parameters. INHERIT_SECURITY "
"(default) inherits the server-wide default; None disables "
"screening; a ResourceSecurity instance applies that explicit "
"policy."
),
exclude=True,
)
def resolve_security(
self, server_default: ResourceSecurity | None
) -> ResourceSecurity | None:
"""Resolve the effective security policy for this template.
A per-component ``security`` overrides the server default.
``INHERIT_SECURITY`` (the field default) inherits ``server_default``;
an explicit ``None`` disables screening for this template.
"""
if isinstance(self.security, InheritSecurity):
return server_default
return self.security
def __repr__(self) -> str:
return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
@ -205,6 +233,7 @@ class ResourceTemplate(FastMCPComponent):
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> FunctionResourceTemplate:
return FunctionResourceTemplate.from_function(
fn=fn,
@ -220,6 +249,7 @@ class ResourceTemplate(FastMCPComponent):
meta=meta,
task=task,
auth=auth,
security=security,
)
@field_validator("mime_type", mode="before")
@ -544,6 +574,7 @@ class FunctionResourceTemplate(ResourceTemplate):
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> FunctionResourceTemplate:
"""Create a template from a function."""
@ -683,4 +714,5 @@ class FunctionResourceTemplate(ResourceTemplate):
meta=meta,
task_config=task_config,
auth=auth,
security=security,
)

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import json
from pathlib import Path
import httpx
import httpx2
import pydantic.json
from anyio import Path as AsyncPath
from pydantic import Field, ValidationInfo
@ -121,7 +121,7 @@ class HttpResource(Resource):
@override
async def read(self) -> ResourceResult:
"""Read the HTTP content."""
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
response = await client.get(self.url)
_ = response.raise_for_status()
return ResourceResult(

View file

@ -29,9 +29,8 @@ from typing import Any, Literal
from urllib.parse import urlencode, urlparse, urlunparse
import anyio
import httpx
import httpx2
from authlib.common.security import generate_token
from authlib.integrations.httpx_client import AsyncOAuth2Client
from cryptography.fernet import Fernet
from key_value.aio.adapters.pydantic import PydanticAdapter
from key_value.aio.protocols import AsyncKeyValue
@ -92,6 +91,7 @@ from fastmcp.server.auth.oauth_proxy.models import (
_hash_token,
)
from fastmcp.server.auth.oauth_proxy.ui import create_error_html
from fastmcp.server.auth.oauth_proxy.upstream import AsyncOAuth2Client
from fastmcp.server.auth.redirect_validation import validate_redirect_uri
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
@ -197,7 +197,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
- Clean up one-time use authorization code
5. Token Refresh:
- Forward refresh requests to upstream using authlib
- Forward refresh requests to upstream
- Handle token rotation if upstream issues new refresh token
- Update local token mappings
@ -313,7 +313,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
Disable only if upstream provider doesn't support PKCE.
token_endpoint_auth_method: Token endpoint authentication method for upstream server.
Common values: "client_secret_basic", "client_secret_post", "none".
If None, authlib will use its default (typically "client_secret_basic").
Defaults to "client_secret_basic".
extra_authorize_params: Additional parameters to forward to the upstream authorization endpoint.
Useful for provider-specific parameters like Auth0's "audience".
Example: {"audience": "https://api.example.com"}
@ -1967,7 +1967,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
# Attempt upstream revocation if endpoint is configured
if self._upstream_revocation_endpoint:
try:
async with httpx.AsyncClient(
async with httpx2.AsyncClient(
timeout=HTTP_TIMEOUT_SECONDS
) as http_client:
revocation_data: dict[str, str] = {"token": token.token}
@ -2272,8 +2272,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
)
# Build token exchange parameters
token_params = {
"url": self._upstream_token_endpoint,
token_params: dict[str, Any] = {
"code": idp_code,
"redirect_uri": idp_redirect_uri,
}
@ -2305,8 +2304,12 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
# Exchange IdP code for tokens (server-side)
async with self._upstream_oauth_client() as oauth_client:
# url is passed by keyword: the _create_upstream_oauth_client
# override point is duck-typed, and alternative clients may
# declare it keyword-only (the refresh_token sites already
# call by keyword).
idp_tokens: dict[str, Any] = await oauth_client.fetch_token(
**token_params
url=self._upstream_token_endpoint, **token_params
)
logger.debug(

View file

@ -0,0 +1,147 @@
"""httpx2-based upstream OAuth2 token client.
Replaces `authlib.integrations.httpx_client.AsyncOAuth2Client` for the OAuth
proxy's upstream token-endpoint calls. authlib's httpx integration imports the
legacy `httpx` package which authlib does not declare as a dependency and
FastMCP no longer ships so importing it on a clean install fails.
This module reimplements the narrow surface the proxy uses (`fetch_token`,
`refresh_token`, `client_secret`, `aclose`) on `httpx2.AsyncClient`, preserving
authlib's wire behavior exactly:
- form-encoded POST token requests with authlib's default headers
- `client_secret_basic` (latin-1 basic auth, authlib-style), `client_secret_post`,
and `none` client authentication methods
- falsy parameters dropped from the request body
- `expires_at` computed onto the returned token dict
- the previous refresh token injected into the response when the server does
not rotate it
- `OAuthError` (authlib's httpx-free core error class) raised for RFC 6749
error responses, and 5xx responses raised as HTTP status errors
"""
from __future__ import annotations
import base64
import time
from typing import Any
import httpx2
from authlib.integrations.base_client import OAuthError
__all__ = ["AsyncOAuth2Client", "OAuthError"]
_DEFAULT_TOKEN_HEADERS = {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
}
class AsyncOAuth2Client:
"""Minimal async OAuth2 client for upstream token-endpoint interactions.
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).
"""
def __init__(
self,
client_id: str,
client_secret: str | None = None,
token_endpoint_auth_method: str | None = None,
timeout: float | None = None,
) -> None:
self.client_id = client_id
self.client_secret = client_secret
self.token_endpoint_auth_method = (
token_endpoint_auth_method or "client_secret_basic"
)
self._client = httpx2.AsyncClient(timeout=timeout)
async def aclose(self) -> None:
await self._client.aclose()
def _apply_client_auth(self, data: dict[str, Any], headers: dict[str, str]) -> None:
"""Attach client credentials per the configured auth method (RFC 6749 §2.3)."""
method = self.token_endpoint_auth_method
if method == "client_secret_basic":
text = f"{self.client_id}:{self.client_secret}"
credential = base64.b64encode(text.encode("latin1")).decode("ascii")
headers["Authorization"] = f"Basic {credential}"
elif method == "client_secret_post":
data["client_id"] = self.client_id
data["client_secret"] = self.client_secret or ""
elif method == "none":
data["client_id"] = self.client_id
else:
raise ValueError(
f"Unsupported token_endpoint_auth_method: {method!r}. "
"Supported methods: client_secret_basic, client_secret_post, none."
)
async def _request_token(self, url: str, data: dict[str, Any]) -> dict[str, Any]:
headers = dict(_DEFAULT_TOKEN_HEADERS)
self._apply_client_auth(data, headers)
response = await self._client.post(url, data=data, headers=headers)
if response.status_code >= 500:
response.raise_for_status()
token: dict[str, Any] = response.json()
if "error" in token:
raise OAuthError(
error=token["error"], description=token.get("error_description")
)
# Mirror authlib's OAuth2Token: derive expires_at from expires_in so
# the stored raw token data keeps the same shape as before.
if token.get("expires_at") is not None:
try:
token["expires_at"] = int(token["expires_at"])
except ValueError:
if token.get("expires_in"):
token["expires_at"] = int(time.time()) + int(token["expires_in"])
elif token.get("expires_in"):
token["expires_at"] = int(time.time()) + int(token["expires_in"])
return token
async def fetch_token(
self,
url: str,
*,
grant_type: str = "authorization_code",
**params: Any,
) -> dict[str, Any]:
"""Exchange an authorization grant for tokens at the token endpoint.
Falsy parameters are dropped from the request body, matching authlib.
"""
data: dict[str, Any] = {"grant_type": grant_type}
data.update({key: value for key, value in params.items() if value})
return await self._request_token(url, data)
async def refresh_token(
self,
url: str,
*,
refresh_token: str | None = None,
**params: Any,
) -> dict[str, Any]:
"""Fetch a new access token using a refresh token.
If the server does not rotate the refresh token, the previous one is
injected into the returned dict, matching authlib.
"""
data: dict[str, Any] = {"grant_type": "refresh_token"}
if refresh_token:
data["refresh_token"] = refresh_token
data.update({key: value for key, value in params.items() if value})
token = await self._request_token(url, data)
if "refresh_token" not in token:
token["refresh_token"] = refresh_token
return token

View file

@ -12,7 +12,7 @@ This implementation is based on:
from collections.abc import Sequence
from typing import Any, Literal
import httpx
import httpx2
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl, BaseModel, model_validator
from typing_extensions import Self
@ -162,7 +162,7 @@ class OIDCConfiguration(BaseModel):
get_kwargs["timeout"] = timeout_seconds
try:
response = httpx.get(str(config_url), **get_kwargs)
response = httpx2.get(str(config_url), **get_kwargs)
response.raise_for_status()
config_data = response.json()
@ -289,7 +289,7 @@ class OIDCProxy(OAuthProxy):
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
token_endpoint_auth_method: Token endpoint authentication method for upstream server.
Common values: "client_secret_basic", "client_secret_post", "none".
If None, authlib will use its default (typically "client_secret_basic").
Defaults to "client_secret_basic".
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.

View file

@ -10,7 +10,7 @@ import hashlib
from collections import OrderedDict
from typing import TYPE_CHECKING, Any, Literal, cast
import httpx
import httpx2
from key_value.aio.protocols import AsyncKeyValue
from fastmcp.dependencies import Dependency
@ -120,7 +120,7 @@ class AzureProvider(OAuthProxy):
token_expiry_threshold_seconds: int = 0,
base_authority: str = "login.microsoftonline.com",
token_issuer: str | None = None,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
enable_cimd: bool = True,
) -> None:
"""Initialize Azure OAuth provider.
@ -176,7 +176,7 @@ class AzureProvider(OAuthProxy):
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
http_client: Optional httpx.AsyncClient for connection pooling in JWKS fetches.
http_client: Optional httpx2.AsyncClient for connection pooling in JWKS fetches.
When provided, the client is reused for JWT key fetches and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per fetch.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
@ -881,13 +881,13 @@ def EntraOBOToken(scopes: list[str]) -> str:
Example:
```python
from fastmcp.server.auth.providers.azure import EntraOBOToken
import httpx
import httpx2
@mcp.tool()
async def get_my_emails(
graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"])
):
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
resp = await client.get(
"https://graph.microsoft.com/v1.0/me/messages",
headers={"Authorization": f"Bearer {graph_token}"}

View file

@ -31,7 +31,7 @@ from __future__ import annotations
import contextlib
from typing import Literal
import httpx
import httpx2
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
@ -67,7 +67,7 @@ class ClerkTokenVerifier(TokenVerifier):
client_secret: str | None = None,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
):
"""Initialize the Clerk token verifier.
@ -77,7 +77,7 @@ class ClerkTokenVerifier(TokenVerifier):
client_secret: Clerk OAuth client secret, used for introspection endpoint authentication
required_scopes: Required OAuth scopes (e.g., ["openid", "email", "profile"])
timeout_seconds: HTTP request timeout
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
@ -107,7 +107,7 @@ class ClerkTokenVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
else httpx.AsyncClient(timeout=self.timeout_seconds)
else httpx2.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Step 1: Validate token via introspection (RFC 7662).
# Security-critical checks (active, audience, scopes) come first.
@ -229,7 +229,7 @@ class ClerkTokenVerifier(TokenVerifier):
logger.debug("Clerk token verified successfully for sub=%s", sub)
return access_token
except httpx.RequestError as e:
except httpx2.RequestError as e:
logger.debug("Failed to verify Clerk token: %s", e)
return None
except Exception as e:
@ -293,7 +293,7 @@ class ClerkProvider(OAuthProxy):
fastmcp_access_token_expiry_seconds: int | None = None,
token_expiry_threshold_seconds: int = 0,
extra_authorize_params: dict[str, str] | None = None,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize Clerk OAuth provider.
@ -331,7 +331,7 @@ class ClerkProvider(OAuthProxy):
consent_csp_policy: Custom CSP policy for the consent page.
extra_authorize_params: Additional parameters to forward to Clerk's authorization
endpoint. Example: {"prompt": "login"} to force re-authentication.
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
http_client: Optional httpx2.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created
per call.

View file

@ -7,11 +7,16 @@ for seamless MCP client authentication.
from __future__ import annotations
import asyncio
from urllib.parse import urlparse
import httpx
import httpx2
from mcp.server.auth.json_response import PydanticJSONResponse
from mcp.server.auth.routes import build_resource_metadata_url, cors_middleware
from mcp.shared.auth import ProtectedResourceMetadata
from pydantic import AnyHttpUrl
from starlette.responses import JSONResponse
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
@ -21,43 +26,87 @@ from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
_OPENID_WK = "/.well-known/openid-configuration"
_OAUTH_WK = "/.well-known/oauth-authorization-server"
def _parse_descope_config_url(config_url: str) -> tuple[str, str, str, str]:
openid_url = config_url.rstrip("/")
if not openid_url.endswith(_OPENID_WK):
openid_url = f"{openid_url}{_OPENID_WK}"
issuer_url = openid_url[: -len(_OPENID_WK)]
parsed = urlparse(issuer_url)
parts = parsed.path.strip("/").split("/")
descope_base_url = f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
if "agentic" in parts:
index = parts.index("agentic") + 1
project_id = parts[index] if index < len(parts) else ""
elif "apps" in parts:
index = parts.index("apps") + 1
project_id = parts[index] if index < len(parts) else ""
if project_id == "agentic":
project_id = ""
else:
project_id = ""
if not project_id:
raise ValueError(f"Could not extract project_id from config_url: {issuer_url}")
return descope_base_url, project_id, issuer_url, openid_url
async def _discover_scopes(openid_configuration_url: str) -> list[str] | None:
try:
async with httpx2.AsyncClient() as client:
response = await client.get(openid_configuration_url, timeout=10.0)
response.raise_for_status()
scopes = response.json().get("scopes_supported")
if isinstance(scopes, list):
parsed = [scope for scope in scopes if isinstance(scope, str)]
if not scopes or parsed:
return parsed
except Exception:
logger.warning(
"Failed to fetch Descope OpenID configuration from %s",
openid_configuration_url,
exc_info=True,
)
return None
class DescopeProvider(RemoteAuthProvider):
"""Descope metadata provider for DCR (Dynamic Client Registration).
"""Descope metadata provider for Dynamic Client Registration (DCR).
This provider implements Descope integration using metadata forwarding.
This is the recommended approach for Descope DCR
as it allows Descope to handle the OAuth flow directly while FastMCP acts
as a resource server.
The provider accepts either a resource-specific Descope MCP Server URL such
as `/v1/apps/agentic/P.../M.../.well-known/openid-configuration` or a
project-level inbound app URL such as
`/v1/apps/P.../.well-known/openid-configuration`.
IMPORTANT SETUP REQUIREMENTS:
1. Create an MCP Server in Descope Console:
- Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console
- Create a new MCP Server
- Ensure that **Dynamic Client Registration (DCR)** is enabled
- Note your Well-Known URL
2. Note your Well-Known URL:
- Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers)
- Format: ``https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration``
For detailed setup instructions, see:
https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr
When neither `scopes_supported` nor `required_scopes` is provided, advertised
scopes are discovered lazily from the OpenID configuration. Use
`scopes_supported` and `required_scopes` together when the scopes clients
should request differ from the scopes enforced during token validation.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.descope import DescopeProvider
# Create Descope metadata provider (JWT verifier created automatically)
descope_auth = DescopeProvider(
config_url="https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration",
auth = DescopeProvider(
config_url=(
"https://api.descope.com/v1/apps/P.../"
".well-known/openid-configuration"
),
base_url="https://your-fastmcp-server.com",
)
# Use with FastMCP
mcp = FastMCP("My App", auth=descope_auth)
mcp = FastMCP("My App", auth=auth)
```
See [Descope's inbound app documentation](https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr)
for DCR setup instructions.
"""
def __init__(
@ -73,121 +122,182 @@ class DescopeProvider(RemoteAuthProvider):
resource_documentation: AnyHttpUrl | None = None,
token_verifier: TokenVerifier | None = None,
):
"""Initialize Descope metadata provider.
"""Initialize the Descope provider.
Args:
base_url: Public URL of this FastMCP server
config_url: Your Descope Well-Known URL (e.g., "https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration")
This is the new recommended way. If provided, project_id and descope_base_url are ignored.
project_id: Your Descope Project ID (e.g., "P2abc123"). Used with descope_base_url for backwards compatibility.
descope_base_url: Your Descope base URL (e.g., "https://api.descope.com"). Used with project_id for backwards compatibility.
required_scopes: Optional list of scopes that must be present in validated tokens.
These scopes will be included in the protected resource metadata.
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
If None, uses required_scopes. Use this when the scopes clients should
request differ from the scopes enforced on tokens.
resource_name: Optional name for the protected resource metadata.
resource_documentation: Optional documentation URL for the protected resource.
token_verifier: Optional token verifier. If None, creates JWT verifier for Descope
base_url: Public URL of this FastMCP server.
config_url: A resource-specific or project-level Descope OpenID
configuration URL. When provided, `project_id` and
`descope_base_url` are ignored.
project_id: Descope project ID. Used with `descope_base_url` for
backwards compatibility.
descope_base_url: Descope API base URL. Used with `project_id` for
backwards compatibility.
required_scopes: Scopes required during token validation. When
`scopes_supported` is omitted, these are also advertised to clients.
scopes_supported: Scopes advertised to OAuth clients. When both this
and `required_scopes` are omitted, scopes are discovered lazily
from `config_url`.
resource_name: Optional protected resource name.
resource_documentation: Optional protected resource documentation URL.
token_verifier: Optional custom token verifier. A Descope JWT verifier
is created when omitted.
"""
self.base_url = AnyHttpUrl(str(base_url).rstrip("/"))
# Parse scopes if provided as string
parsed_scopes = (
parsed_required_scopes = (
parse_scopes(required_scopes) if required_scopes is not None else None
)
parsed_scopes_supported = (
parse_scopes(scopes_supported) if scopes_supported is not None else None
)
# Determine which API is being used
if config_url is not None:
# New API: use config_url
# Strip /.well-known/openid-configuration from config_url if present
issuer_url = str(config_url)
if issuer_url.endswith("/.well-known/openid-configuration"):
issuer_url = issuer_url[: -len("/.well-known/openid-configuration")]
# Parse the issuer URL to extract descope_base_url and project_id for other uses
parsed_url = urlparse(issuer_url)
path_parts = parsed_url.path.strip("/").split("/")
# Extract project_id from path (format: /v1/apps/agentic/P.../M...)
if "agentic" in path_parts:
agentic_index = path_parts.index("agentic")
if agentic_index + 1 < len(path_parts):
self.project_id = path_parts[agentic_index + 1]
else:
raise ValueError(
f"Could not extract project_id from config_url: {issuer_url}"
)
else:
raise ValueError(
f"Could not find 'agentic' in config_url path: {issuer_url}"
)
# Extract descope_base_url (scheme + netloc)
self.descope_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}".rstrip(
"/"
)
(
self.descope_base_url,
self.project_id,
issuer_url,
self.openid_configuration_url,
) = _parse_descope_config_url(str(config_url))
elif project_id is not None and descope_base_url is not None:
# Old API: use project_id and descope_base_url
self.project_id = project_id
descope_base_url_str = str(descope_base_url).rstrip("/")
# Ensure descope_base_url has a scheme
if not descope_base_url_str.startswith(("http://", "https://")):
descope_base_url_str = f"https://{descope_base_url_str}"
self.descope_base_url = descope_base_url_str
# Old issuer format
issuer_url = f"{self.descope_base_url}/v1/apps/{self.project_id}"
self.openid_configuration_url = f"{issuer_url}{_OPENID_WK}"
else:
raise ValueError(
"Either config_url (new API) or both project_id and descope_base_url (old API) must be provided"
)
# Create default JWT verifier if none provided
self.oauth_authorization_server_metadata_url = (
self.openid_configuration_url.replace(_OPENID_WK, _OAUTH_WK)
)
# Advertised scopes are discovered from Descope's OpenID configuration
# only when the caller supplied neither explicit advertised scopes nor
# required scopes. Discovery is deferred to the first protected resource
# metadata request (see get_routes) so construction never performs I/O
# and a transient failure can be retried instead of being frozen for the
# provider's lifetime.
custom_verifier_scopes = (
token_verifier.scopes_supported if token_verifier is not None else []
)
self._scopes_discovery_enabled = (
parsed_scopes_supported is None
and parsed_required_scopes is None
and not custom_verifier_scopes
)
self._discovered_scopes: list[str] | None = None
self._scopes_discovered = False
self._scopes_discovery_lock = asyncio.Lock()
if token_verifier is None:
token_verifier = JWTVerifier(
jwks_uri=f"{self.descope_base_url}/{self.project_id}/.well-known/jwks.json",
issuer=issuer_url,
algorithm="RS256",
audience=self.project_id,
required_scopes=parsed_scopes,
required_scopes=parsed_required_scopes,
)
# Initialize RemoteAuthProvider with Descope as the authorization server
super().__init__(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl(issuer_url)],
base_url=self.base_url,
scopes_supported=scopes_supported,
scopes_supported=parsed_scopes_supported,
resource_name=resource_name,
resource_documentation=resource_documentation,
)
async def _get_scopes_supported(self) -> list[str] | None:
"""Return the advertised scopes, discovering them lazily if enabled.
The result of a successful discovery is cached for the provider's
lifetime. Transient failures return ``None`` without caching, so the
next protected resource metadata request retries discovery.
"""
if self._scopes_discovered:
return self._discovered_scopes
async with self._scopes_discovery_lock:
if self._scopes_discovered:
return self._discovered_scopes
scopes = await _discover_scopes(self.openid_configuration_url)
if scopes is not None:
self._discovered_scopes = scopes
self._scopes_discovered = True
return scopes
def _create_protected_resource_route(self, resource_url: AnyHttpUrl) -> Route:
"""Build a protected resource metadata route that discovers scopes lazily.
Mirrors ``create_protected_resource_routes`` (RFC 9728) but resolves
``scopes_supported`` per request so the value can be discovered from
Descope after construction.
"""
async def protected_resource_metadata(request: Request) -> Response:
scopes_supported = await self._get_scopes_supported()
metadata = ProtectedResourceMetadata(
resource=resource_url,
authorization_servers=self.authorization_servers,
scopes_supported=scopes_supported,
resource_name=self.resource_name,
resource_documentation=self.resource_documentation,
)
cache_control = (
"public, max-age=3600" if self._scopes_discovered else "no-store"
)
return PydanticJSONResponse(
content=metadata,
headers={"Cache-Control": cache_control},
)
well_known_path = urlparse(str(build_resource_metadata_url(resource_url))).path
return Route(
well_known_path,
endpoint=cors_middleware(protected_resource_metadata, ["GET", "OPTIONS"]),
methods=["GET", "OPTIONS"],
)
def get_routes(
self,
mcp_path: str | None = None,
) -> list[Route]:
"""Get OAuth routes including Descope authorization server metadata forwarding.
This returns the standard protected resource routes plus an authorization server
metadata endpoint that forwards Descope's OAuth metadata to clients.
Args:
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
This is used to advertise the resource URL in metadata.
"""
# Get the standard protected resource routes from RemoteAuthProvider
routes = super().get_routes(mcp_path)
if self._scopes_discovery_enabled:
# Serve protected resource metadata from an async handler that
# discovers scopes_supported lazily. The parent's static route would
# freeze the (as-yet-unknown) scopes at startup.
self.set_mcp_path(mcp_path)
routes = []
resource_url = self._get_resource_url(mcp_path)
if resource_url:
routes.append(self._create_protected_resource_route(resource_url))
else:
# Advertised scopes are already known; the parent builds the static
# protected resource metadata route with no network access.
routes = super().get_routes(mcp_path)
async def oauth_authorization_server_metadata(request):
"""Forward Descope OAuth authorization server metadata with FastMCP customizations."""
metadata_urls = [self.oauth_authorization_server_metadata_url]
project_metadata_url = (
f"{self.descope_base_url}/v1/apps/{self.project_id}{_OAUTH_WK}"
)
if project_metadata_url != self.oauth_authorization_server_metadata_url:
metadata_urls.append(project_metadata_url)
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.descope_base_url}/v1/apps/{self.project_id}/.well-known/oauth-authorization-server"
)
response.raise_for_status()
metadata = response.json()
return JSONResponse(metadata)
async with httpx2.AsyncClient() as client:
for metadata_url in metadata_urls:
try:
response = await client.get(metadata_url)
response.raise_for_status()
return JSONResponse(response.json())
except Exception:
continue
except Exception as e:
return JSONResponse(
{
@ -197,7 +307,14 @@ class DescopeProvider(RemoteAuthProvider):
status_code=500,
)
# Add Descope authorization server metadata forwarding
return JSONResponse(
{
"error": "server_error",
"error_description": "Failed to fetch Descope metadata",
},
status_code=500,
)
routes.append(
Route(
"/.well-known/oauth-authorization-server",

View file

@ -26,7 +26,7 @@ import time
from datetime import datetime
from typing import Literal
import httpx
import httpx2
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
@ -52,7 +52,7 @@ class DiscordTokenVerifier(TokenVerifier):
expected_client_id: str,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
):
"""Initialize the Discord token verifier.
@ -60,7 +60,7 @@ class DiscordTokenVerifier(TokenVerifier):
expected_client_id: Expected Discord OAuth client ID for audience binding
required_scopes: Required OAuth scopes (e.g., ['email'])
timeout_seconds: HTTP request timeout
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
@ -75,7 +75,7 @@ class DiscordTokenVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
else httpx.AsyncClient(timeout=self.timeout_seconds)
else httpx2.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Use Discord's tokeninfo endpoint to validate the token
headers = {
@ -154,7 +154,7 @@ class DiscordTokenVerifier(TokenVerifier):
logger.debug("Discord token verified successfully")
return access_token
except httpx.RequestError as e:
except httpx2.RequestError as e:
logger.debug("Failed to verify Discord token: %s", e)
return None
except Exception as e:
@ -210,7 +210,7 @@ class DiscordProvider(OAuthProxy):
fallback_refresh_token_expiry_seconds: int | None = None,
fastmcp_access_token_expiry_seconds: int | None = None,
token_expiry_threshold_seconds: int = 0,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize Discord OAuth provider.
@ -243,7 +243,7 @@ class DiscordProvider(OAuthProxy):
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
http_client: Optional httpx2.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per call.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based

View file

@ -24,7 +24,7 @@ from __future__ import annotations
import contextlib
from typing import Literal
import httpx
import httpx2
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
@ -56,7 +56,7 @@ class GitHubTokenVerifier(TokenVerifier):
timeout_seconds: int = 10,
cache_ttl_seconds: int | None = None,
max_cache_size: int | None = None,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
):
"""Initialize the GitHub token verifier.
@ -67,7 +67,7 @@ class GitHubTokenVerifier(TokenVerifier):
Caching is disabled by default (None). Set to a positive integer
to enable (e.g., 300 for 5 minutes).
max_cache_size: Maximum number of tokens to cache. Default: 10 000.
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
@ -90,7 +90,7 @@ class GitHubTokenVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
else httpx.AsyncClient(timeout=self.timeout_seconds)
else httpx2.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Get token info from GitHub API
response = await client.get(
@ -167,7 +167,7 @@ class GitHubTokenVerifier(TokenVerifier):
self._cache.set(token, result)
return result
except httpx.RequestError as e:
except httpx2.RequestError as e:
logger.debug("Failed to verify GitHub token: %s", e)
return None
except Exception as e:
@ -225,7 +225,7 @@ class GitHubProvider(OAuthProxy):
fallback_refresh_token_expiry_seconds: int | None = None,
fastmcp_access_token_expiry_seconds: int | None = None,
token_expiry_threshold_seconds: int = 0,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize GitHub OAuth provider.
@ -259,7 +259,7 @@ class GitHubProvider(OAuthProxy):
When "external", the built-in consent screen is skipped but no warning is
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
SECURITY WARNING: Only set to False for local development or testing environments.
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
http_client: Optional httpx2.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per call.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based

View file

@ -25,7 +25,7 @@ import contextlib
import time
from typing import Literal
import httpx
import httpx2
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
@ -69,14 +69,14 @@ class GoogleTokenVerifier(TokenVerifier):
*,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
):
"""Initialize the Google token verifier.
Args:
required_scopes: Required OAuth scopes (e.g., ['openid', 'https://www.googleapis.com/auth/userinfo.email'])
timeout_seconds: HTTP request timeout
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
@ -101,7 +101,7 @@ class GoogleTokenVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
else httpx.AsyncClient(timeout=self.timeout_seconds)
else httpx2.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Step 1: Verify token via tokeninfo endpoint.
# Returns aud (OAuth app ID), scope (space-separated), expires_in, sub, email.
@ -193,7 +193,7 @@ class GoogleTokenVerifier(TokenVerifier):
logger.debug("Google token verified successfully")
return access_token
except httpx.RequestError as e:
except httpx2.RequestError as e:
logger.debug("Failed to verify Google token: %s", e)
return None
except Exception as e:
@ -251,7 +251,7 @@ class GoogleProvider(OAuthProxy):
fastmcp_access_token_expiry_seconds: int | None = None,
token_expiry_threshold_seconds: int = 0,
extra_authorize_params: dict[str, str] | None = None,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize Google OAuth provider.
@ -296,7 +296,7 @@ class GoogleProvider(OAuthProxy):
By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure
refresh tokens are returned. You can override these defaults or add additional parameters.
Example: {"prompt": "select_account"} to let users choose their Google account.
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
http_client: Optional httpx2.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per call.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based

View file

@ -7,7 +7,7 @@ from collections.abc import Mapping
from json import JSONDecodeError
from typing import Any, Literal
import httpx
import httpx2
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
@ -65,7 +65,7 @@ class HuggingFaceTokenVerifier(TokenVerifier):
*,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
):
super().__init__(required_scopes=required_scopes)
self.timeout_seconds = timeout_seconds
@ -77,7 +77,7 @@ class HuggingFaceTokenVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
else httpx.AsyncClient(timeout=self.timeout_seconds)
else httpx2.AsyncClient(timeout=self.timeout_seconds)
) as client:
userinfo_response = await client.get(
HUGGINGFACE_USERINFO_ENDPOINT,
@ -148,7 +148,7 @@ class HuggingFaceTokenVerifier(TokenVerifier):
},
)
except httpx.RequestError as e:
except httpx2.RequestError as e:
logger.debug("Failed to verify Hugging Face token: %s", e)
return None
except JSONDecodeError as e:
@ -156,7 +156,7 @@ class HuggingFaceTokenVerifier(TokenVerifier):
return None
async def _fetch_whoami(
self, client: httpx.AsyncClient, token: str
self, client: httpx2.AsyncClient, token: str
) -> dict[str, Any] | None:
response = await client.get(
HUGGINGFACE_WHOAMI_ENDPOINT,
@ -197,7 +197,7 @@ class HuggingFaceProvider(OAuthProxy):
token_expiry_threshold_seconds: int = 0,
extra_authorize_params: dict[str, str] | None = None,
extra_token_params: dict[str, str] | None = None,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize Hugging Face OAuth provider.

View file

@ -28,7 +28,7 @@ import contextlib
import time
from typing import Any, Literal, get_args
import httpx
import httpx2
from pydantic import AnyHttpUrl, SecretStr
from fastmcp.server.auth import AccessToken, TokenVerifier
@ -89,7 +89,7 @@ class IntrospectionTokenVerifier(TokenVerifier):
base_url: AnyHttpUrl | str | None = None,
cache_ttl_seconds: int | None = None,
max_cache_size: int | None = None,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
):
"""
Initialize the introspection token verifier.
@ -109,7 +109,7 @@ class IntrospectionTokenVerifier(TokenVerifier):
(e.g., 300 for 5 minutes).
max_cache_size: Maximum number of tokens to cache when caching is
enabled. Default: 10000.
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
@ -203,7 +203,7 @@ class IntrospectionTokenVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
else httpx.AsyncClient(timeout=self.timeout_seconds)
else httpx2.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Prepare introspection request per RFC 7662
# Build request data with token and token_type_hint
@ -292,12 +292,12 @@ class IntrospectionTokenVerifier(TokenVerifier):
self._cache.set(token, result)
return result
except httpx.TimeoutException:
except httpx2.TimeoutException:
self.logger.debug(
"Token introspection timed out after %d seconds", self.timeout_seconds
)
return None
except httpx.RequestError as e:
except httpx2.RequestError as e:
self.logger.debug("Token introspection request failed: %s", e)
return None
except Exception as e:

View file

@ -8,7 +8,7 @@ import time
from dataclasses import dataclass
from typing import Any, TypeAlias, cast
import httpx
import httpx2
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from joserfc import jwk, jwt
@ -222,7 +222,7 @@ class JWTVerifier(TokenVerifier):
required_scopes: list[str] | None = None,
base_url: AnyHttpUrl | str | None = None,
ssrf_safe: bool = False,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
):
"""
Initialize a JWTVerifier configured to validate JWTs using either a static key or a JWKS endpoint.
@ -239,7 +239,7 @@ class JWTVerifier(TokenVerifier):
public IPs, DNS pinning). Enable when the JWKS URI comes from
untrusted input (e.g. CIMD documents). Defaults to False so
operator-configured JWKS URIs (including localhost) work normally.
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused for JWKS fetches and the caller is responsible for
its lifecycle. When None (default), a fresh client is created per fetch.
Cannot be used with ssrf_safe=True.
@ -347,11 +347,26 @@ class JWTVerifier(TokenVerifier):
try:
jwks_data = await self._fetch_jwks()
# Cache all keys
# Cache all usable keys. A key that cannot be converted (e.g. an
# unsupported kty like OKP/Ed25519) is skipped rather than failing
# the whole set — per RFC 7517 §5, clients should ignore JWKs they
# don't understand. Otherwise one exotic key published by the
# authorization server would reject every token, including ones
# signed by supported keys in the same set (#4515).
self._jwks_cache = {}
skipped_kids: set[str] = set()
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")
public_key = _jwk_to_pem(key_data)
try:
public_key = _jwk_to_pem(key_data)
except (JoseError, TypeError, KeyError, ValueError) as e:
self.logger.debug("Skipping unusable JWKS key %r: %s", key_kid, e)
if key_kid:
skipped_kids.add(key_kid)
continue
if key_kid:
self._jwks_cache[key_kid] = public_key
@ -364,6 +379,16 @@ class JWTVerifier(TokenVerifier):
# Select the appropriate key
if kid:
if kid not in self._jwks_cache:
if kid in skipped_kids:
self.logger.debug(
"JWKS key lookup failed: key ID '%s' is present "
"but its key type is unsupported",
kid,
)
raise ValueError(
f"Key ID '{kid}' found in JWKS but its key type "
"is unsupported"
)
self.logger.debug(
"JWKS key lookup failed: key ID '%s' not found", kid
)
@ -383,7 +408,7 @@ class JWTVerifier(TokenVerifier):
except (SSRFError, SSRFFetchError) as e:
self.logger.debug("JWKS fetch blocked by SSRF protection: %s", e)
raise ValueError(f"Failed to fetch JWKS: {e}") from e
except httpx.HTTPError as e:
except httpx2.HTTPError as e:
raise ValueError(f"Failed to fetch JWKS: {e}") from e
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JWKS JSON: {e}") from e
@ -408,7 +433,7 @@ class JWTVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
else httpx.AsyncClient(timeout=httpx.Timeout(10.0))
else httpx2.AsyncClient(timeout=httpx2.Timeout(10.0))
) as client:
response = await client.get(self.jwks_uri)
response.raise_for_status()

View file

@ -21,7 +21,7 @@ from __future__ import annotations
from typing import TypedDict
import httpx
import httpx2
from pydantic import AnyHttpUrl, SecretStr
from starlette.responses import JSONResponse
from starlette.routing import Route
@ -37,7 +37,7 @@ class PropelAuthTokenIntrospectionOverrides(TypedDict, total=False):
timeout_seconds: int
cache_ttl_seconds: int | None
max_cache_size: int | None
http_client: httpx.AsyncClient | None
http_client: httpx2.AsyncClient | None
class PropelAuthProvider(RemoteAuthProvider):
@ -156,7 +156,7 @@ class PropelAuthProvider(RemoteAuthProvider):
async def oauth_authorization_server_metadata(request):
"""Forward PropelAuth OAuth authorization server metadata"""
try:
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
response = await client.get(
f"{self._normalized_auth_url}/.well-known/oauth-authorization-server/oauth/2.1"
)

View file

@ -7,7 +7,7 @@ authentication for seamless MCP client authentication.
from __future__ import annotations
import httpx
import httpx2
from pydantic import AnyHttpUrl
from starlette.responses import JSONResponse
from starlette.routing import Route
@ -181,7 +181,7 @@ class ScalekitProvider(RemoteAuthProvider):
logger.debug(
"Fetching Scalekit OAuth metadata: metadata_url=%s", metadata_url
)
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
response = await client.get(metadata_url)
response.raise_for_status()
metadata = response.json()

View file

@ -9,7 +9,7 @@ from __future__ import annotations
from typing import Literal
import httpx
import httpx2
from pydantic import AnyHttpUrl
from starlette.responses import JSONResponse
from starlette.routing import Route
@ -153,7 +153,7 @@ class SupabaseProvider(RemoteAuthProvider):
async def oauth_authorization_server_metadata(request):
"""Forward Supabase OAuth authorization server metadata with FastMCP customizations."""
try:
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
response = await client.get(
f"{self.project_url}/{self.auth_route}/.well-known/oauth-authorization-server"
)

View file

@ -13,7 +13,7 @@ from __future__ import annotations
import contextlib
from typing import Literal
import httpx
import httpx2
from key_value.aio.protocols import AsyncKeyValue
from pydantic import AnyHttpUrl
from starlette.responses import JSONResponse
@ -41,7 +41,7 @@ class WorkOSTokenVerifier(TokenVerifier):
authkit_domain: str,
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
):
"""Initialize the WorkOS token verifier.
@ -49,7 +49,7 @@ class WorkOSTokenVerifier(TokenVerifier):
authkit_domain: WorkOS AuthKit domain (e.g., "https://your-app.authkit.app")
required_scopes: Required OAuth scopes
timeout_seconds: HTTP request timeout
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
http_client: Optional httpx2.AsyncClient for connection pooling. When provided,
the client is reused across calls and the caller is responsible for its
lifecycle. When None (default), a fresh client is created per call.
"""
@ -64,7 +64,7 @@ class WorkOSTokenVerifier(TokenVerifier):
async with (
contextlib.nullcontext(self._http_client)
if self._http_client is not None
else httpx.AsyncClient(timeout=self.timeout_seconds)
else httpx2.AsyncClient(timeout=self.timeout_seconds)
) as client:
# Use WorkOS AuthKit userinfo endpoint to validate token
response = await client.get(
@ -115,7 +115,7 @@ class WorkOSTokenVerifier(TokenVerifier):
},
)
except httpx.RequestError as e:
except httpx2.RequestError as e:
logger.debug("Failed to verify WorkOS token: %s", e)
return None
except Exception as e:
@ -180,7 +180,7 @@ class WorkOSProvider(OAuthProxy):
fastmcp_access_token_expiry_seconds: int | None = None,
token_expiry_threshold_seconds: int = 0,
extra_authorize_params: dict[str, str] | None = None,
http_client: httpx.AsyncClient | None = None,
http_client: httpx2.AsyncClient | None = None,
enable_cimd: bool = True,
):
"""Initialize WorkOS OAuth provider.
@ -230,7 +230,7 @@ class WorkOSProvider(OAuthProxy):
token_expiry_threshold_seconds: Number of seconds before actual expiry to consider
a token as expired (default 0). Prevents race conditions where a token
passes the expiry check but expires before the next operation completes.
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
http_client: Optional httpx2.AsyncClient for connection pooling in token verification.
When provided, the client is reused across verify_token calls and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per call.
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
@ -432,7 +432,7 @@ class AuthKitProvider(RemoteAuthProvider):
async def oauth_authorization_server_metadata(request):
"""Forward AuthKit OAuth authorization server metadata with FastMCP customizations."""
try:
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
response = await client.get(
f"{self.authkit_domain}/.well-known/oauth-authorization-server"
)

View file

@ -16,7 +16,7 @@ from collections.abc import Mapping
from dataclasses import dataclass
from urllib.parse import urlparse
import httpx
import httpx2
from fastmcp.utilities.logging import get_logger
@ -333,8 +333,8 @@ async def ssrf_safe_fetch_response(
try:
# Use httpx with streaming to enforce size limit during download
async with (
httpx.AsyncClient(
timeout=httpx.Timeout(
httpx2.AsyncClient(
timeout=httpx2.Timeout(
connect=min(timeout, remaining),
read=min(timeout, remaining),
write=min(timeout, remaining),
@ -387,15 +387,15 @@ async def ssrf_safe_fetch_response(
headers=dict(response.headers),
)
except httpx.TimeoutException as e:
except httpx2.TimeoutException as e:
last_error = e
continue
except httpx.RequestError as e:
except httpx2.RequestError as e:
last_error = e
continue
if last_error is not None:
if isinstance(last_error, httpx.TimeoutException):
if isinstance(last_error, httpx2.TimeoutException):
raise SSRFFetchError(f"Timeout fetching {url}") from last_error
raise SSRFFetchError(f"Error fetching {url}: {last_error}") from last_error

View file

@ -378,6 +378,13 @@ def get_elicitation_schema(response_type: type[T]) -> dict[str, Any]:
)
schema = compress_schema(schema)
# Pydantic emits the internal wrapper class name as a top-level title for
# ScalarElicitationType schemas. That value is not meaningful on the wire and
# breaks strict clients (e.g. Codex) that reject unknown top-level fields.
origin = get_origin(response_type)
if origin is ScalarElicitationType or response_type is ScalarElicitationType:
schema.pop("title", None)
# Validate the schema to ensure it follows MCP elicitation requirements
validate_elicitation_json_schema(schema)

View file

@ -16,7 +16,7 @@ from key_value.aio.wrappers.statistics.wrapper import (
KVStoreCollectionStatistics,
)
from pydantic import Field
from typing_extensions import NotRequired, Self, override
from typing_extensions import NotRequired, Self, TypeVar, override
from fastmcp.prompts.base import Message, Prompt, PromptResult
from fastmcp.resources.base import Resource, ResourceContent, ResourceResult
@ -36,6 +36,18 @@ ONE_MB_IN_BYTES = 1024 * 1024
ANONYMOUS_AUTH_KEY = "__anonymous__"
BaseModelT = TypeVar("BaseModelT", bound=FastMCPBaseModel)
def _to_base_model(value: FastMCPBaseModel, model_type: type[BaseModelT]) -> BaseModelT:
"""Validate a component's public base fields without serializing its subclass."""
field_values = {
name: getattr(value, name)
for name, field in model_type.model_fields.items()
if not field.exclude
}
return model_type.model_validate(field_values)
class CachableResourceContent(FastMCPBaseModel):
"""A wrapper for ResourceContent that can be cached."""
@ -314,19 +326,7 @@ class ResponseCachingMiddleware(Middleware):
tools: Sequence[Tool] = await call_next(context)
# Turn any subclass of Tool into a Tool
cachable_tools: list[Tool] = [
Tool(
name=tool.name,
title=tool.title,
description=tool.description,
parameters=tool.parameters,
output_schema=tool.output_schema,
annotations=tool.annotations,
meta=tool.meta,
tags=tool.tags,
)
for tool in tools
]
cachable_tools = [_to_base_model(tool, Tool) for tool in tools]
await self._list_tools_cache.put(
key=cache_key,
@ -355,18 +355,8 @@ class ResponseCachingMiddleware(Middleware):
resources: Sequence[Resource] = await call_next(context)
# Turn any subclass of Resource into a Resource
cachable_resources: list[Resource] = [
Resource(
name=resource.name,
title=resource.title,
description=resource.description,
tags=resource.tags,
meta=resource.meta,
mime_type=resource.mime_type,
annotations=resource.annotations,
uri=resource.uri,
)
for resource in resources
cachable_resources = [
_to_base_model(resource, Resource) for resource in resources
]
await self._list_resources_cache.put(
@ -396,17 +386,7 @@ class ResponseCachingMiddleware(Middleware):
prompts: Sequence[Prompt] = await call_next(context)
# Turn any subclass of Prompt into a Prompt
cachable_prompts: list[Prompt] = [
Prompt(
name=prompt.name,
title=prompt.title,
description=prompt.description,
tags=prompt.tags,
meta=prompt.meta,
arguments=prompt.arguments,
)
for prompt in prompts
]
cachable_prompts = [_to_base_model(prompt, Prompt) for prompt in prompts]
await self._list_prompts_cache.put(
key=cache_key,

View file

@ -363,6 +363,7 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
meta=template.get_meta(),
title=template.title,
icons=template.icons,
security=template.security,
)
async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:

View file

@ -14,6 +14,11 @@ import mcp_types
from mcp_types import Annotations
from fastmcp.resources.base import Resource
from fastmcp.resources.security import (
INHERIT_SECURITY,
InheritSecurity,
ResourceSecurity,
)
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
@ -70,6 +75,7 @@ class ResourceDecoratorMixin:
meta=meta.meta,
task=resolved_task,
auth=meta.auth,
security=meta.security,
)
else:
resource = Resource.from_function(
@ -119,6 +125,7 @@ class ResourceDecoratorMixin:
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> Callable[[F], F]:
"""Decorator to register a function as a resource.
@ -202,6 +209,7 @@ class ResourceDecoratorMixin:
task=task,
auth=auth,
enabled=enabled,
security=security,
)
target = fn.__func__ if hasattr(fn, "__func__") else fn
target.__fastmcp__ = metadata # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]

View file

@ -6,9 +6,9 @@ Example:
```python
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import OpenAPIProvider
import httpx
import httpx2
client = httpx.AsyncClient(base_url="https://api.example.com")
client = httpx2.AsyncClient(base_url="https://api.example.com")
provider = OpenAPIProvider(openapi_spec=spec, client=client)
mcp = FastMCP("API Server", providers=[provider])
```

View file

@ -4,9 +4,9 @@ from __future__ import annotations
import json
import re
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
import httpx
import httpx2
from mcp_types import ToolAnnotations
from pydantic.networks import AnyUrl
@ -19,6 +19,11 @@ from fastmcp.resources import (
from fastmcp.server.dependencies import get_http_headers
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.exceptions import (
HTTP_STATUS_ERRORS,
REQUEST_ERRORS,
TIMEOUT_ERRORS,
)
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import HTTPRoute
from fastmcp.utilities.openapi.director import RequestDirector
@ -41,7 +46,7 @@ _SAFE_HEADERS = frozenset(
)
def _redact_headers(headers: httpx.Headers) -> dict[str, str]:
def _redact_headers(headers: httpx2.Headers) -> dict[str, str]:
return {k: v if k.lower() in _SAFE_HEADERS else "***" for k, v in headers.items()}
@ -138,7 +143,7 @@ class OpenAPITool(Tool):
def __init__(
self,
client: httpx.AsyncClient,
client: httpx2.AsyncClient,
route: HTTPRoute,
director: RequestDirector,
name: str,
@ -169,12 +174,24 @@ class OpenAPITool(Tool):
# not HTTP failures, so we catch them separately.
try:
base_url = str(self._client.base_url) or "http://localhost"
request = self._director.build(self._route, arguments, base_url)
directed_request = self._director.build(self._route, arguments, base_url)
if self._client.headers:
for key, value in self._client.headers.items():
if key not in request.headers:
request.headers[key] = value
# Rebuild through the user's client so the request object comes
# from whichever httpx library the client belongs to (a legacy
# httpx.AsyncClient cannot send an httpx2.Request). Primitive
# values (str/bytes/tuples) cross that boundary safely; client
# default headers merge in with directed headers taking priority,
# matching the previous manual merge.
request = self._client.build_request(
method=directed_request.method,
url=str(directed_request.url.copy_with(query=None)),
params=list(directed_request.url.params.multi_items()),
headers=list(directed_request.headers.raw),
# read() materializes streaming bodies (multipart files=)
# that .content would refuse with RequestNotRead; idempotent
# for plain byte bodies.
content=directed_request.read(),
)
mcp_headers = get_http_headers()
if mcp_headers:
@ -221,22 +238,24 @@ class OpenAPITool(Tool):
except json.JSONDecodeError:
return ToolResult(content=response.text)
except httpx.HTTPStatusError as e:
except HTTP_STATUS_ERRORS as e:
status_error = cast("httpx2.HTTPStatusError", e)
error_message = (
f"HTTP error {e.response.status_code}: {e.response.reason_phrase}"
f"HTTP error {status_error.response.status_code}: "
f"{status_error.response.reason_phrase}"
)
try:
error_data = e.response.json()
error_data = status_error.response.json()
error_message += f" - {error_data}"
except (json.JSONDecodeError, ValueError):
if e.response.text:
error_message += f" - {e.response.text}"
if status_error.response.text:
error_message += f" - {status_error.response.text}"
raise ValueError(error_message) from e
except httpx.TimeoutException as e:
except TIMEOUT_ERRORS as e:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
except httpx.RequestError as e:
except REQUEST_ERRORS as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
@ -247,7 +266,7 @@ class OpenAPIResource(Resource):
def __init__(
self,
client: httpx.AsyncClient,
client: httpx2.AsyncClient,
route: HTTPRoute,
director: RequestDirector,
uri: str,
@ -279,13 +298,17 @@ 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.
request = self._client.build_request(
method=directed_request.method,
url=directed_request.url.copy_with(query=None),
params=directed_request.url.params,
headers=directed_request.headers,
content=directed_request.content,
extensions=directed_request.extensions,
url=str(directed_request.url.copy_with(query=None)),
params=list(directed_request.url.params.multi_items()),
headers=list(directed_request.headers.raw),
# read() materializes streaming bodies (multipart files=)
# that .content would refuse with RequestNotRead; idempotent
# for plain byte bodies.
content=directed_request.read(),
)
mcp_headers = get_http_headers()
if mcp_headers:
@ -320,22 +343,24 @@ class OpenAPIResource(Resource):
]
)
except httpx.HTTPStatusError as e:
except HTTP_STATUS_ERRORS as e:
status_error = cast("httpx2.HTTPStatusError", e)
error_message = (
f"HTTP error {e.response.status_code}: {e.response.reason_phrase}"
f"HTTP error {status_error.response.status_code}: "
f"{status_error.response.reason_phrase}"
)
try:
error_data = e.response.json()
error_data = status_error.response.json()
error_message += f" - {error_data}"
except (json.JSONDecodeError, ValueError):
if e.response.text:
error_message += f" - {e.response.text}"
if status_error.response.text:
error_message += f" - {status_error.response.text}"
raise ValueError(error_message) from e
except httpx.TimeoutException as e:
except TIMEOUT_ERRORS as e:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
except httpx.RequestError as e:
except REQUEST_ERRORS as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
@ -353,7 +378,7 @@ class OpenAPIResourceTemplate(ResourceTemplate):
def __init__(
self,
client: httpx.AsyncClient,
client: httpx2.AsyncClient,
route: HTTPRoute,
director: RequestDirector,
uri_template: str,

View file

@ -7,7 +7,7 @@ from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from typing import Any, Literal, cast
import httpx
import httpx2
from jsonschema_path import SchemaPath
from fastmcp.prompts import Prompt
@ -58,9 +58,9 @@ class OpenAPIProvider(Provider):
```python
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import OpenAPIProvider
import httpx
import httpx2
client = httpx.AsyncClient(base_url="https://api.example.com")
client = httpx2.AsyncClient(base_url="https://api.example.com")
provider = OpenAPIProvider(openapi_spec=spec, client=client)
mcp = FastMCP("API Server")
@ -71,7 +71,7 @@ class OpenAPIProvider(Provider):
def __init__(
self,
openapi_spec: dict[str, Any],
client: httpx.AsyncClient | None = None,
client: httpx2.AsyncClient | None = None,
*,
route_maps: list[RouteMap] | None = None,
route_map_fn: RouteMapFn | None = None,
@ -166,19 +166,19 @@ class OpenAPIProvider(Provider):
logger.debug(f"Created OpenAPIProvider with {len(http_routes)} routes")
@classmethod
def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient:
def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx2.AsyncClient:
"""Create a default httpx client from the OpenAPI spec's server URL."""
servers = openapi_spec.get("servers", [])
if not servers or not servers[0].get("url"):
raise ValueError(
"No server URL found in OpenAPI spec. Either add a 'servers' "
"entry to the spec or provide an httpx.AsyncClient explicitly."
"entry to the spec or provide an httpx2.AsyncClient explicitly."
)
base_url = servers[0]["url"]
variables = servers[0].get("variables", {})
for name, var in variables.items():
base_url = base_url.replace(f"{{{name}}}", var.get("default", ""))
return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT)
return httpx2.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT)
@asynccontextmanager
async def lifespan(self) -> AsyncIterator[None]:

View file

@ -15,7 +15,7 @@ the app name + tool name). CSP on the resource is the tool's
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from fastmcp.server.providers.addressing import (
HASH_LENGTH,
@ -142,7 +142,7 @@ def _build_resource_for_tool(tool: Tool) -> Resource | None:
uri = f"ui://prefab/tool/{tool_hash}/renderer.html"
return TextResource(
uri=uri, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
uri=cast(Any, uri),
name=f"Prefab Renderer ({tool.name})",
text=get_renderer_html(),
mime_type=UI_MIME_TYPE,

View file

@ -14,7 +14,7 @@ from collections.abc import Awaitable, Callable, Sequence
from typing import TYPE_CHECKING, Any, cast
import anyio
import httpx
import httpx2
import mcp_types
from mcp.server.connection import Connection
from mcp.server.context import ServerRequestContext
@ -106,6 +106,7 @@ class ProxyInitializeMiddleware(Middleware):
],
) -> mcp_types.InitializeResult | None:
client = await self.proxy._get_client()
upstream_instructions: str | None = None
try:
if isinstance(client, ProxyClient):
ctx = context.fastmcp_context
@ -116,19 +117,37 @@ class ProxyInitializeMiddleware(Middleware):
)
async with client:
await client.initialize()
# Capture the upstream's instructions while the session is live;
# `initialize_result` clears once the client context exits.
init_result = client.initialize_result
if init_result is not None:
upstream_instructions = init_result.instructions
except MCPError:
raise
except (
RuntimeError,
TimeoutError,
httpx.HTTPError,
httpx2.HTTPError,
anyio.ClosedResourceError,
anyio.EndOfStream,
anyio.BrokenResourceError,
) as error:
raise _proxy_upstream_error(error) from error
return await call_next(context)
result = await call_next(context)
# Forward the upstream server's instructions unless the proxy defines its
# own. `instructions` is part of the MCP InitializeResult and is meant to
# steer the model, so a proxy that dropped it would silently degrade any
# downstream consumer relying on upstream guidance.
if (
result is not None
and self.proxy.instructions is None
and upstream_instructions is not None
):
result.instructions = upstream_instructions
return result
# -----------------------------------------------------------------------------

View file

@ -19,7 +19,7 @@ from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import httpx
import httpx2
import mcp_types
from key_value.aio.adapters.pydantic import PydanticAdapter
from key_value.aio.protocols import AsyncKeyValue
@ -49,6 +49,7 @@ from fastmcp.exceptions import (
NotFoundError,
PromptError,
ResourceError,
ResourceSecurityError,
ToolError,
ValidationError,
)
@ -57,6 +58,12 @@ from fastmcp.prompts import Prompt
from fastmcp.prompts.base import PromptResult
from fastmcp.prompts.function_prompt import FunctionPrompt
from fastmcp.resources.base import Resource, ResourceResult
from fastmcp.resources.security import (
DEFAULT_RESOURCE_SECURITY,
INHERIT_SECURITY,
InheritSecurity,
ResourceSecurity,
)
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.auth import AuthCheck, AuthContext, AuthProvider, run_auth_checks
from fastmcp.server.caching import build_cache_hints
@ -78,6 +85,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.logging import get_logger
from fastmcp.utilities.types import AnyFunction, FastMCPBaseModel, NotSet, NotSetT
from fastmcp.utilities.versions import (
@ -97,10 +105,15 @@ 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,
) -> dict[str, Any] | None:
) -> mcp_types.RequestParamsMeta | None:
if version is None:
return None
@ -120,9 +133,8 @@ def _version_request_meta(
if not version_value:
return None
# SDK v2: request `_meta` is a plain dict (the `Meta` type alias), not the
# old `RequestParams.Meta` nested model.
return {"fastmcp": {"version": version_value}}
# RequestParamsMeta does not declare application-specific extension keys.
return cast(mcp_types.RequestParamsMeta, {"fastmcp": {"version": version_value}})
# The MCP SDK warns "Tool X not listed, no validation will be performed"
@ -330,6 +342,7 @@ class FastMCP(
dereference_schemas: bool = True,
strict_input_validation: bool | None = None,
list_page_size: int | None = None,
resource_security: ResourceSecurity | None = DEFAULT_RESOURCE_SECURITY,
cache_ttl: int | None = None,
cache_scope: Literal["public", "private"] | None = None,
tasks: bool | None = None,
@ -390,6 +403,13 @@ class FastMCP(
raise ValueError("list_page_size must be a positive integer")
self._list_page_size: int | None = list_page_size
# Server-wide default path-security policy for templated resources.
# Applied before the handler runs to every templated read whose
# component does not override it. DEFAULT_RESOURCE_SECURITY screens
# traversal, absolute paths, and null bytes; None disables screening
# server-wide.
self._resource_security: ResourceSecurity | None = resource_security
# Server-level SEP-2549 cache hints, applied uniformly to every
# SDK-cacheable result by the low-level server's runner (raises on
# invalid ttl/scope).
@ -1230,9 +1250,7 @@ class FastMCP(
message=mcp_types.CallToolRequestParams(
name=name,
arguments=arguments or {},
# `_meta` carries the app-level `fastmcp` version key, which the
# reserved-key RequestParamsMeta TypedDict can't express statically.
_meta=_version_request_meta(version), # type: ignore[unknown-argument] # ty: ignore[invalid-argument-type]
_meta=_version_request_meta(version),
),
source="client",
type="request",
@ -1324,12 +1342,15 @@ 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, httpx.HTTPStatusError):
if e.response.status_code == 429:
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, httpx.TimeoutException):
if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
raise ToolError(
"Upstream request timed out, please retry"
) from e
@ -1400,9 +1421,7 @@ class FastMCP(
mw_context = MiddlewareContext(
message=mcp_types.ReadResourceRequestParams(
uri=str(uri),
# `_meta` carries the app-level `fastmcp` version key, which the
# reserved-key RequestParamsMeta TypedDict can't express statically.
_meta=_version_request_meta(version), # type: ignore[unknown-argument] # ty: ignore[invalid-argument-type]
_meta=_version_request_meta(version),
),
source="client",
type="request",
@ -1461,12 +1480,15 @@ class FastMCP(
except Exception as e:
logger.exception(f"Error reading resource {uri!r}")
# Handle actionable errors that should reach the LLM
if isinstance(e, httpx.HTTPStatusError):
if e.response.status_code == 429:
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, httpx.TimeoutException):
if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
raise ResourceError(
"Upstream request timed out, please retry"
) from e
@ -1490,6 +1512,24 @@ class FastMCP(
span.set_attributes(template.get_span_attributes())
params = template.matches(uri)
assert params is not None
# Path-security screening: reject traversal / absolute-path /
# null-byte payloads in extracted parameter values BEFORE the
# handler runs. This is the single chokepoint for every
# templated read (local decorator and provider-sourced), so
# enforcement lives here rather than in any decorator.
security = template.resolve_security(self._resource_security)
if security is not None:
failed = security.validate(params)
if failed is not None:
logger.debug(
"Rejected resource %r: parameter %r failed "
"path-security screening",
uri,
failed,
)
raise ResourceSecurityError(f"Unknown resource: {uri!r}")
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=template.key)
try:
@ -1505,12 +1545,15 @@ class FastMCP(
except Exception as e:
logger.exception(f"Error reading resource {uri!r}")
# Handle actionable errors that should reach the LLM
if isinstance(e, httpx.HTTPStatusError):
if e.response.status_code == 429:
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, httpx.TimeoutException):
if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS):
raise ResourceError(
"Upstream request timed out, please retry"
) from e
@ -1579,9 +1622,7 @@ class FastMCP(
message=mcp_types.GetPromptRequestParams(
name=name,
arguments=arguments,
# `_meta` carries the app-level `fastmcp` version key, which the
# reserved-key RequestParamsMeta TypedDict can't express statically.
_meta=_version_request_meta(version), # type: ignore[unknown-argument] # ty: ignore[invalid-argument-type]
_meta=_version_request_meta(version),
),
source="client",
type="request",
@ -1824,6 +1865,7 @@ class FastMCP(
app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY,
) -> Callable[[F], F]:
"""Decorator to register a function as a resource.
@ -1923,6 +1965,7 @@ class FastMCP(
meta=meta,
task=task if task is not None else self._support_tasks_by_default,
auth=auth,
security=security,
)
return inner_decorator
@ -2144,7 +2187,7 @@ class FastMCP(
def from_openapi(
cls,
openapi_spec: dict[str, Any],
client: httpx.AsyncClient | None = None,
client: httpx2.AsyncClient | None = None,
name: str = "OpenAPI Server",
route_maps: list[RouteMap] | None = None,
route_map_fn: OpenAPIRouteMapFn | None = None,
@ -2159,8 +2202,10 @@ class FastMCP(
Args:
openapi_spec: OpenAPI schema as a dictionary
client: Optional httpx AsyncClient for making HTTP requests.
If not provided, a default client is created using the first
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
server URL from the OpenAPI spec with a 30-second timeout.
name: Name for the MCP server
route_maps: Optional list of RouteMap objects defining route mappings
@ -2214,7 +2259,7 @@ class FastMCP(
route_map_fn: Optional callable for advanced route type mapping
mcp_component_fn: Optional callable for component customization
mcp_names: Optional dictionary mapping operationId to component names
httpx_client_kwargs: Optional kwargs passed to httpx.AsyncClient.
httpx_client_kwargs: Optional kwargs passed to httpx2.AsyncClient.
Use this to configure timeout and other client settings.
tags: Optional set of tags to add to all components
**settings: Additional settings passed to FastMCP
@ -2228,8 +2273,8 @@ class FastMCP(
httpx_client_kwargs = {}
httpx_client_kwargs.setdefault("base_url", "http://fastapi")
client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
client = httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=app),
**httpx_client_kwargs,
)

View file

@ -281,6 +281,13 @@ async def restore_task_snapshot(key: str = TaskKey()) -> None:
# process boundaries (see notifications.py and elicitation.py).
_task_sessions: dict[str, weakref.ref[ServerSession]] = {}
_TASK_SESSION_CONNECTION_REF = "_fastmcp_task_session_ref"
_TASK_SESSION_CLEANUP_REGISTERED = "_fastmcp_task_session_cleanup_registered"
def _remove_task_session(session_id: str, ref: weakref.ref[ServerSession]) -> None:
if _task_sessions.get(session_id) is ref:
_task_sessions.pop(session_id)
def register_task_session(session_id: str, session: ServerSession) -> None:
@ -290,7 +297,29 @@ def register_task_session(session_id: str, session: ServerSession) -> None:
stored as a weakref so it doesn't prevent garbage collection when the
client disconnects.
"""
_task_sessions[session_id] = weakref.ref(session)
session_ref = weakref.ref(
session, lambda ref: _remove_task_session(session_id, ref)
)
_task_sessions[session_id] = session_ref
connection = getattr(session, "_connection", None)
if connection is None:
return
state = connection.state
state[_TASK_SESSION_CONNECTION_REF] = (session_id, session_ref)
if state.get(_TASK_SESSION_CLEANUP_REGISTERED):
return
def remove_connection_session() -> None:
registered = state.pop(_TASK_SESSION_CONNECTION_REF, None)
if registered is not None:
registered_session_id, registered_ref = registered
_remove_task_session(registered_session_id, registered_ref)
connection.exit_stack.callback(remove_connection_session)
state[_TASK_SESSION_CLEANUP_REGISTERED] = True
def get_task_session(session_id: str) -> ServerSession | None:

View file

@ -58,6 +58,21 @@ def get_session_span_attributes() -> dict[str, str]:
return attrs
def get_protocol_span_attributes() -> dict[str, str]:
"""Get the negotiated MCP protocol version for the current request.
Mirrors the `mcp.protocol.version` attribute the SDK's own
`OpenTelemetryMiddleware` sets FastMCP drops that middleware to avoid a
duplicate SERVER span, so this restores the attribute on FastMCP's span.
"""
from fastmcp.server.dependencies import fastmcp_request_ctx
req_ctx = fastmcp_request_ctx.get()
if req_ctx is not None and req_ctx.protocol_version:
return {"mcp.protocol.version": req_ctx.protocol_version}
return {}
def _get_parent_trace_context() -> Context | None:
"""Get parent trace context from request meta for distributed tracing."""
from fastmcp.server.dependencies import fastmcp_request_ctx
@ -84,6 +99,7 @@ def _build_server_span_attrs(
"fastmcp.server.name": server_name,
"fastmcp.component.type": component_type,
"fastmcp.component.key": component_key,
**get_protocol_span_attributes(),
**get_auth_span_attributes(),
**get_session_span_attributes(),
}
@ -123,6 +139,7 @@ def seam_span(method: str, server_name: str) -> Generator[Span, None, None]:
SEAM_SPAN_MARKER: True,
"mcp.method.name": method,
"fastmcp.server.name": server_name,
**get_protocol_span_attributes(),
**get_auth_span_attributes(),
**get_session_span_attributes(),
}
@ -240,6 +257,7 @@ __all__ = [
"SEAM_SPAN_MARKER",
"delegate_span",
"get_auth_span_attributes",
"get_protocol_span_attributes",
"get_session_span_attributes",
"record_span_exception",
"seam_span",

View file

@ -210,6 +210,24 @@ class Settings(BaseSettings):
),
] = True
enable_telemetry: Annotated[
bool,
Field(
description=inspect.cleandoc(
"""
Whether FastMCP's native OpenTelemetry instrumentation is active.
Enabled by default: FastMCP uses only the OpenTelemetry API, so
span creation is a no-op with negligible overhead unless an
OpenTelemetry SDK and exporter are configured. Set to False to
turn instrumentation off entirely, in which case FastMCP's span
helpers become a transparent pass-through: no FastMCP spans are
created even when an SDK is configured, and the surrounding OTel
trace context is left untouched.
"""
)
),
] = True
deprecation_warnings: Annotated[
bool,
Field(

View file

@ -21,13 +21,24 @@ Example usage with SDK:
```
"""
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
from opentelemetry import context as otel_context
from opentelemetry import propagate, trace
from opentelemetry.context import Context
from opentelemetry.trace import Span, Status, StatusCode, Tracer
from opentelemetry.trace import (
INVALID_SPAN,
NoOpTracer,
Span,
SpanKind,
Status,
StatusCode,
Tracer,
)
from opentelemetry.trace import get_tracer as otel_get_tracer
from opentelemetry.util import types as otel_types
INSTRUMENTATION_NAME = "fastmcp"
@ -35,15 +46,60 @@ TRACE_PARENT_KEY = "traceparent"
TRACE_STATE_KEY = "tracestate"
class _DisabledTracer(NoOpTracer):
"""A tracer that neither records spans nor touches the OTel context.
When telemetry is disabled FastMCP must be fully transparent. The stock
`NoOpTracer.start_as_current_span` still *attaches* a `NonRecordingSpan` to
the current OTel context, so an enclosing application span (from ASGI/HTTP
instrumentation or a user-created span) is hidden while a FastMCP span
helper is active `trace.get_current_span()` inside a handler would then
return that non-recording span instead of the caller's span. This tracer
yields the invalid span *without* entering it as current, leaving the
surrounding trace context untouched.
"""
@contextmanager
def start_as_current_span(
self,
name: str,
context: Context | None = None,
kind: SpanKind = SpanKind.INTERNAL,
attributes: otel_types.Attributes = None,
links: Any = None,
start_time: int | None = None,
record_exception: bool = True,
set_status_on_exception: bool = True,
end_on_exit: bool = True,
) -> Iterator[Span]:
yield INVALID_SPAN
_DISABLED_TRACER = _DisabledTracer()
def get_tracer(version: str | None = None) -> Tracer:
"""Get the FastMCP tracer for creating spans.
Instrumentation is on by default. FastMCP uses only the OpenTelemetry API,
so span creation is a no-op with negligible overhead unless an OpenTelemetry
SDK and exporter are configured. Set `fastmcp.settings.enable_telemetry` to
False (env `FASTMCP_ENABLE_TELEMETRY=false`) to turn instrumentation off
entirely, in which case this returns a pass-through tracer that leaves the
current OTel context untouched even when an SDK is configured.
Args:
version: Optional version string for the instrumentation
Returns:
A tracer instance. Returns a no-op tracer if no SDK is configured.
A tracer instance. Returns a non-attaching pass-through tracer if
telemetry is disabled; span creation is otherwise a no-op unless an SDK
is configured.
"""
import fastmcp
if not fastmcp.settings.enable_telemetry:
return _DISABLED_TRACER
return otel_get_tracer(INSTRUMENTATION_NAME, version)

View file

@ -182,8 +182,16 @@ class ParsedFunction:
) -> ParsedFunction:
if validate:
sig = inspect.signature(fn)
# Reject functions with *args or **kwargs
# Reject signatures that cannot be represented by MCP's
# object-shaped tool arguments.
for param in sig.parameters.values():
if param.kind == inspect.Parameter.POSITIONAL_ONLY:
raise ValueError(
"Functions with positional-only parameters are not "
"supported as tools because MCP passes tool arguments by "
"name. Replace them with standard parameters that can be "
"passed as keywords."
)
if param.kind == inspect.Parameter.VAR_POSITIONAL:
raise ValueError("Functions with *args are not supported as tools")
if param.kind == inspect.Parameter.VAR_KEYWORD:

View file

@ -1,12 +1,37 @@
from collections.abc import Callable, Iterable, Mapping
from typing import Any
import httpx
import httpx2
from exceptiongroup import BaseExceptionGroup
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,
)
TIMEOUT_ERRORS: tuple[type[BaseException], ...] = (
httpx2.TimeoutException,
httpx.TimeoutException,
)
REQUEST_ERRORS: tuple[type[BaseException], ...] = (
httpx2.RequestError,
httpx.RequestError,
)
except ImportError:
HTTP_STATUS_ERRORS = (httpx2.HTTPStatusError,)
TIMEOUT_ERRORS = (httpx2.TimeoutException,)
REQUEST_ERRORS = (httpx2.RequestError,)
def iter_exc(group: BaseExceptionGroup):
for exc in group.exceptions:
@ -18,9 +43,9 @@ def iter_exc(group: BaseExceptionGroup):
def _exception_handler(group: BaseExceptionGroup):
for leaf in iter_exc(group):
if isinstance(leaf, httpx.ConnectTimeout):
if isinstance(leaf, httpx2.ConnectTimeout):
raise MCPError(
code=httpx.codes.REQUEST_TIMEOUT,
code=httpx2.codes.REQUEST_TIMEOUT,
message="Timed out while waiting for response.",
)
raise leaf

View file

@ -498,6 +498,11 @@ def _single_pass_optimize(
if not (prune_defs or prune_titles or prune_additional_properties):
return schema # Nothing to do
# Work on a copy so the caller's schema is never mutated (see docstring). The
# pruning phases below pop keys/$defs in place, which would otherwise corrupt a
# shared dict such as a live Tool.input_schema passed straight to compress_schema.
schema = copy.deepcopy(schema)
# Phase 1: Collect references and apply simple cleanups
# Track which $defs are referenced from the main schema and from other $defs
root_refs: set[str] = set() # $defs referenced directly from main schema

View file

@ -5,7 +5,7 @@ import json as _json
from typing import Any, ClassVar
from urllib.parse import quote, urljoin
import httpx
import httpx2
from jsonschema_path import SchemaPath
from fastmcp.utilities.logging import get_logger
@ -27,7 +27,7 @@ def _query_scalar_to_str(value: Any) -> str:
class RequestDirector:
"""Builds httpx.Request objects from HTTPRoute and arguments using openapi-core."""
"""Builds httpx2.Request objects from HTTPRoute and arguments using openapi-core."""
def __init__(self, spec: SchemaPath):
"""Initialize with a parsed SchemaPath object."""
@ -38,9 +38,9 @@ class RequestDirector:
route: HTTPRoute,
flat_args: dict[str, Any],
base_url: str = "http://localhost",
) -> httpx.Request:
) -> httpx2.Request:
"""
Constructs a final httpx.Request object, handling all OpenAPI serialization.
Constructs a final httpx2.Request object, handling all OpenAPI serialization.
Args:
route: HTTPRoute containing OpenAPI operation details
@ -48,7 +48,7 @@ class RequestDirector:
base_url: Base URL for the request
Returns:
httpx.Request: Properly formatted HTTP request
httpx2.Request: Properly formatted HTTP request
"""
logger.debug(
f"Building request for {route.method} {route.path} with args: {flat_args}"
@ -140,8 +140,8 @@ class RequestDirector:
else:
content = body
# Step 7: Create httpx.Request
return httpx.Request(
# Step 7: Create httpx2.Request
return httpx2.Request(
method=method,
url=url,
params=params,
@ -313,13 +313,13 @@ class RequestDirector:
if not value:
continue
if explode:
# form,explode=true on objects: each property becomes
# a separate query parameter.
# e.g. {"R": 100, "G": 200} → R=100&G=200
for k, v in value.items():
serialized[_query_scalar_to_str(k)] = _query_scalar_to_str(
v
)
# deepObject keeps the parent parameter name;
# form style emits each property as a bare key.
property_name = _query_scalar_to_str(k)
if param_info.style == "deepObject":
property_name = f"{key}[{property_name}]"
serialized[property_name] = _query_scalar_to_str(v)
else:
style = param_info.style or "form"
delimiter = self._STYLE_DELIMITERS.get(style, ",")

View file

@ -9,7 +9,7 @@ from contextlib import asynccontextmanager, contextmanager, suppress
from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import parse_qs, urlparse
import httpx
import httpx2
import uvicorn
from mcp.shared.auth import AuthorizationCodeResult
@ -238,7 +238,7 @@ class HeadlessOAuth(OAuth):
async def redirect_handler(self, authorization_url: str) -> None:
"""Make HTTP request to authorization URL and store response for callback handler."""
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
response = await client.get(authorization_url, follow_redirects=False)
self._stored_response = response

View file

@ -6,7 +6,7 @@ import json
import time
from pathlib import Path
import httpx
import httpx2
from packaging.version import Version
from fastmcp.utilities.logging import get_logger
@ -66,7 +66,7 @@ def _fetch_latest_version(include_prereleases: bool = False) -> str | None:
The latest version string, or None if the fetch failed.
"""
try:
response = httpx.get(PYPI_URL, timeout=REQUEST_TIMEOUT_SECONDS)
response = httpx2.get(PYPI_URL, timeout=REQUEST_TIMEOUT_SECONDS)
response.raise_for_status()
data = response.json()
@ -91,7 +91,7 @@ def _fetch_latest_version(include_prereleases: bool = False) -> str | None:
return str(max(versions))
except (httpx.HTTPError, json.JSONDecodeError, KeyError):
except (httpx2.HTTPError, json.JSONDecodeError, KeyError):
return None

View file

@ -4,7 +4,7 @@ dynamic = ["version", "optional-dependencies"]
description = "The dependency-slim FastMCP package."
authors = [{ name = "Jeremiah Lowin" }]
dependencies = [
"mcp-types==2.0.0b1",
"mcp-types==2.0.0b2",
"platformdirs>=4.0.0",
"pydantic[email]>=2.12.0",
"pydantic-settings>=2.0.0",
@ -78,8 +78,11 @@ code-mode = ["pydantic-monty==0.0.17"]
gemini = ["google-genai>=1.18.0", "jsonref>=1.1.0"]
mcp = [
"exceptiongroup>=1.2.2",
"httpx>=0.28.1,<1.0",
"mcp==2.0.0b1",
# FastMCP uses httpx2 exclusively: the MCP SDK boundary (client transports,
# client auth) requires it, and all FastMCP-owned HTTP (server auth provider
# upstream calls, OpenAPI provider, version check, etc.) uses it too.
"httpx2>=2.5.0",
"mcp==2.0.0b2",
"opentelemetry-api>=1.28.0",
# starlette floor: transitive via mcp (which only requires >=0.27).
# Pin past CVE-2026-48710, which was patched in 1.0.1.

View file

@ -72,7 +72,7 @@ members = ["fastmcp_slim", "fastmcp_remote"]
[tool.uv]
default-groups = ["dev"]
exclude-newer = "1 week"
exclude-newer-package = { prefab-ui = false, mcp = false, mcp-types = false }
exclude-newer-package = { prefab-ui = false, mcp = false, mcp-types = false, httpx2 = false, httpcore2 = false, truststore = false }
[dependency-groups]
dev = [
@ -92,7 +92,6 @@ dev = [
"pytest-cov>=6.1.1",
"pytest-env>=1.1.5",
"pytest-flakefinder>=1.1.0",
"pytest-httpx>=0.35.0",
"pytest-report>=0.2.1",
"pytest-retry>=1.7.0",
"pytest-timeout>=2.4.0",

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import warnings
import httpx
import httpx2
import pytest
from fastmcp.client.auth import OAuth
@ -108,7 +108,7 @@ class TestOAuthBind:
async def test_unbound_raises_runtime_error(self):
"""async_auth_flow should fail clearly when OAuth is not bound."""
oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
request = httpx.Request("GET", MCP_SERVER_URL)
request = httpx2.Request("GET", MCP_SERVER_URL)
with pytest.raises(RuntimeError, match="no server URL"):
async for _ in oauth.async_auth_flow(request):
pass

View file

@ -3,15 +3,19 @@ import time
from unittest.mock import patch
from urllib.parse import urlparse
import httpx
import httpx2
import pytest
from key_value.aio.stores.memory import MemoryStore
from mcp import MCPError
from mcp.shared.auth import OAuthClientInformationFull
from mcp_types import TextResourceContents
from pydantic import AnyUrl
import fastmcp.client.auth.oauth as oauth_module
import fastmcp.utilities.http as http_module
from fastmcp.client import Client
from fastmcp.client.auth import OAuth
from fastmcp.client.auth.oauth import TokenStorageAdapter
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server.auth.auth import ClientRegistrationOptions
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
@ -45,6 +49,23 @@ def fastmcp_server(issuer_url: str):
return server
class ExpiredFirstRegistrationProvider(InMemoryOAuthProvider):
def __init__(self, base_url: str):
super().__init__(
base_url=base_url,
client_registration_options=ClientRegistrationOptions(enabled=True),
)
self.registration_count = 0
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
self.registration_count += 1
if self.registration_count == 1:
client_info.client_secret = "expired-secret"
client_info.client_secret_expires_at = int(time.time()) - 1
client_info.token_endpoint_auth_method = "client_secret_post"
await super().register_client(client_info)
@pytest.fixture
async def streamable_http_server():
"""Start OAuth-enabled server."""
@ -72,7 +93,7 @@ async def test_unauthorized(client_unauthorized: Client):
"""Test that unauthenticated requests are rejected.
SDK v2 surfaces the server's 401 as an MCPError ("Server returned an error
response") rather than re-raising the raw httpx.HTTPStatusError.
response") rather than re-raising the raw httpx2.HTTPStatusError.
"""
with pytest.raises(MCPError, match="error response"):
async with client_unauthorized:
@ -123,7 +144,7 @@ async def test_oauth_server_metadata_discovery(streamable_http_server: str):
parsed_url = urlparse(streamable_http_server)
server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
# Test OAuth discovery endpoint
metadata_url = f"{server_base_url}/.well-known/oauth-authorization-server"
response = await client.get(metadata_url)
@ -139,6 +160,23 @@ async def test_oauth_server_metadata_discovery(streamable_http_server: str):
assert metadata["token_endpoint"].startswith(server_base_url)
async def test_expired_dynamic_registration_is_retried():
port = find_available_port()
base_url = f"http://127.0.0.1:{port}"
provider = ExpiredFirstRegistrationProvider(base_url)
server = FastMCP("TestServer", auth=provider)
async with run_server_async(server, port=port, transport="http") as url:
client = Client(
transport=StreamableHttpTransport(url),
auth=HeadlessOAuth(mcp_url=url),
)
async with client:
assert await client.ping()
assert provider.registration_count == 2
class TestOAuthClientUrlHandling:
"""Tests for OAuth client URL handling (issue #2573)."""
@ -305,13 +343,13 @@ class TestOAuthGeneratorCleanup:
if self._exhausted:
raise StopAsyncIteration
self._exhausted = True
return httpx.Request("GET", "https://example.com")
return httpx2.Request("GET", "https://example.com")
async def asend(self, value):
if self._exhausted:
raise StopAsyncIteration
self._exhausted = True
return httpx.Request("GET", "https://example.com")
return httpx2.Request("GET", "https://example.com")
async def athrow(self, exc_type, exc_val=None, exc_tb=None):
raise StopAsyncIteration
@ -326,12 +364,12 @@ class TestOAuthGeneratorCleanup:
OAuth.__bases__[0], "async_auth_flow", return_value=tracked_gen
):
# Drive the OAuth flow
flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com"))
flow = oauth.async_auth_flow(httpx2.Request("GET", "https://example.com"))
try:
# First asend(None) starts the generator per async generator protocol
await flow.asend(None) # ty: ignore[invalid-argument-type]
try:
await flow.asend(httpx.Response(200))
await flow.asend(httpx2.Response(200))
except StopAsyncIteration:
pass
except StopAsyncIteration:
@ -359,7 +397,7 @@ class TestOAuthGeneratorCleanup:
async def asend(self, value):
if self._first_call:
self._first_call = False
return httpx.Request("GET", "https://example.com")
return httpx2.Request("GET", "https://example.com")
raise ValueError("Simulated failure")
async def athrow(self, exc_type, exc_val=None, exc_tb=None):
@ -373,10 +411,10 @@ class TestOAuthGeneratorCleanup:
with patch.object(
OAuth.__bases__[0], "async_auth_flow", return_value=tracked_gen
):
flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com"))
flow = oauth.async_auth_flow(httpx2.Request("GET", "https://example.com"))
with pytest.raises(ValueError, match="Simulated failure"):
await flow.asend(None) # ty: ignore[invalid-argument-type]
await flow.asend(httpx.Response(200))
await flow.asend(httpx2.Response(200))
assert tracked_gen.aclose_called, (
"Generator aclose() was not called after exception"
@ -559,3 +597,59 @@ class TestTokenStorageTTL:
await adapter.clear()
assert await adapter.get_token_expiry() is None
class TestClientInfoStorageTTL:
async def test_expired_client_info_removes_stale_registration(self):
storage = MemoryStore()
adapter = TokenStorageAdapter(
async_key_value=storage, server_url="https://test"
)
current = OAuthClientInformationFull(
client_id="current-client",
client_secret="current-secret",
client_secret_expires_at=0,
redirect_uris=[AnyUrl("http://localhost/callback")],
)
await adapter.set_client_info(current)
assert await adapter.get_client_info() == current
expired = current.model_copy(
update={
"client_id": "expired-client",
"client_secret_expires_at": int(time.time()) - 1,
}
)
await adapter.set_client_info(expired)
assert await adapter.get_client_info() is None
async def test_never_expiring_client_info_is_stored(self):
adapter = TokenStorageAdapter(
async_key_value=MemoryStore(), server_url="https://test"
)
client_info = OAuthClientInformationFull(
client_id="never-expiring-client",
client_secret="secret",
client_secret_expires_at=0,
redirect_uris=[AnyUrl("http://localhost/callback")],
)
await adapter.set_client_info(client_info)
assert await adapter.get_client_info() == client_info
async def test_future_expiring_client_info_is_stored(self):
adapter = TokenStorageAdapter(
async_key_value=MemoryStore(), server_url="https://test"
)
client_info = OAuthClientInformationFull(
client_id="future-expiring-client",
client_secret="secret",
client_secret_expires_at=int(time.time()) + 60,
redirect_uris=[AnyUrl("http://localhost/callback")],
)
await adapter.set_client_info(client_info)
assert await adapter.get_client_info() == client_info

View file

@ -2,7 +2,7 @@
from unittest.mock import patch
import httpx
import httpx2
import pytest
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl
@ -146,7 +146,7 @@ class TestStaticClientRetryBehavior:
with patch.object(
OAuth.__bases__[0], "async_auth_flow", side_effect=failing_auth_flow
):
flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com"))
flow = oauth.async_auth_flow(httpx2.Request("GET", "https://example.com"))
with pytest.raises(ClientNotFoundError, match="static client credentials"):
await flow.__anext__()
@ -162,12 +162,12 @@ class TestStaticClientRetryBehavior:
if call_count == 1:
raise ClientNotFoundError("client not found")
# Second attempt succeeds
yield httpx.Request("GET", "https://example.com")
yield httpx2.Request("GET", "https://example.com")
with patch.object(
OAuth.__bases__[0], "async_auth_flow", side_effect=auth_flow_with_retry
):
flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com"))
flow = oauth.async_auth_flow(httpx2.Request("GET", "https://example.com"))
request = await flow.__anext__()
assert request is not None
assert call_count == 2

View file

@ -0,0 +1,279 @@
"""`KeyValueResponseCacheStore`: the AsyncKeyValue-backed client response cache store.
The SDK's client response cache (SEP-2549) reads and writes through a pluggable
`ResponseCacheStore`. `KeyValueResponseCacheStore` adapts that contract onto the
`AsyncKeyValue` abstraction FastMCP already uses for its other state surfaces, so
a fleet of clients can share one backend (memory, Redis, etc.).
These tests cover the store in isolation (round-trip, partition isolation,
clear, allowlist) and end-to-end: two independent `fastmcp.Client` instances
sharing one adapter-backed store, where the second client serves the first's
cached `tools/list` with zero wire calls.
"""
from __future__ import annotations
import json
import time
import pytest
from key_value.aio.stores.memory import MemoryStore
from mcp.client.caching import CacheConfig, CacheEntry, CacheKey
from mcp.server.caching import CacheHint
from mcp.server.mcpserver import MCPServer
from mcp_types import ListToolsResult, Tool
from fastmcp import Client, FastMCP
from fastmcp.client.caching import (
CACHEABLE_RESULT_MODELS,
KeyValueResponseCacheStore,
_CacheEnvelope,
)
def _tools_result() -> ListToolsResult:
return ListToolsResult(
tools=[Tool(name="add", input_schema={"type": "object"})],
ttl_ms=60000,
cache_scope="public",
)
def _key(
partition: str, *, method: str = "tools/list", params_key: str = ""
) -> CacheKey:
# The coordinator packs scope/version/arm into CacheKey.partition as a JSON
# array; mirror that shape so the derived string key is realistic.
arm = json.dumps(["public", "2026-07-28", "srv", partition])
return CacheKey(method, params_key, arm)
def _cached_server(ttl_ms: int = 60000) -> MCPServer:
"""An SDK MCPServer whose tools/list carries a positive ttlMs hint at 2026."""
server = MCPServer(
"cached", cache_hints={"tools/list": CacheHint(ttl_ms=ttl_ms, scope="public")}
)
@server.tool()
def add(a: int, b: int) -> int:
return a + b
return server
class TestRoundTrip:
async def test_set_get_reconstructs_model(self):
"""A stored entry round-trips back to an equal result model object."""
store = KeyValueResponseCacheStore()
result = _tools_result()
key = _key("p1")
await store.set(
key, CacheEntry(value=result, scope="public", expires_at=time.time() + 60)
)
got = await store.get(key)
assert got is not None
assert isinstance(got.value, ListToolsResult)
assert got.value == result
assert got.scope == "public"
async def test_get_miss_returns_none(self):
store = KeyValueResponseCacheStore()
assert await store.get(_key("p1")) is None
async def test_delete_removes_entry(self):
store = KeyValueResponseCacheStore()
key = _key("p1")
await store.set(
key,
CacheEntry(
value=_tools_result(), scope="public", expires_at=time.time() + 60
),
)
await store.delete(key)
assert await store.get(key) is None
async def test_private_scope_roundtrips(self):
store = KeyValueResponseCacheStore()
key = _key("p1")
result = ListToolsResult(
tools=[Tool(name="add", input_schema={"type": "object"})]
)
await store.set(
key, CacheEntry(value=result, scope="private", expires_at=time.time() + 60)
)
got = await store.get(key)
assert got is not None
assert got.scope == "private"
class TestPartitionIsolation:
async def test_two_partitions_do_not_bleed(self):
"""Entries written under different CacheKey.partition arms never collide."""
store = KeyValueResponseCacheStore()
result = _tools_result()
await store.set(
_key("tenant-a"),
CacheEntry(value=result, scope="public", expires_at=time.time() + 60),
)
# A different partition is a distinct key: a clean miss, not a shared hit.
assert await store.get(_key("tenant-b")) is None
assert await store.get(_key("tenant-a")) is not None
async def test_method_and_params_key_isolate(self):
"""Distinct method / params_key never collide in the derived string key."""
store = KeyValueResponseCacheStore()
result = _tools_result()
await store.set(
_key("p1", method="resources/read", params_key="file:///a"),
CacheEntry(value=result, scope="public", expires_at=time.time() + 60),
)
assert (
await store.get(_key("p1", method="resources/read", params_key="file:///b"))
is None
)
assert await store.get(_key("p1", method="tools/list")) is None
class TestClear:
async def test_clear_empties_and_keeps_collection_usable(self):
store = KeyValueResponseCacheStore()
key = _key("p1")
await store.set(
key,
CacheEntry(
value=_tools_result(), scope="public", expires_at=time.time() + 60
),
)
await store.clear()
assert await store.get(key) is None
# The collection remains usable for subsequent writes.
await store.set(
key,
CacheEntry(
value=_tools_result(), scope="public", expires_at=time.time() + 60
),
)
assert await store.get(key) is not None
async def test_clear_scoped_to_own_collection(self):
"""Two adapters over one backend clear independently."""
backend = MemoryStore()
store_a = KeyValueResponseCacheStore(backend, collection="cache_a")
store_b = KeyValueResponseCacheStore(backend, collection="cache_b")
key = _key("p1")
entry = CacheEntry(
value=_tools_result(), scope="public", expires_at=time.time() + 60
)
await store_a.set(key, entry)
await store_b.set(key, entry)
await store_a.clear()
assert await store_a.get(key) is None
assert await store_b.get(key) is not None # untouched
class TestAllowlist:
async def test_unknown_type_tag_is_a_miss(self):
"""A stored envelope naming a type outside the allowlist is a miss, never imported."""
store = KeyValueResponseCacheStore()
key = _key("p1")
forged = _CacheEnvelope(
type_tag="EvilResult",
value_json="{}",
scope="public",
expires_at=time.time() + 60,
)
await store._adapter.put(key=store._string_key(key), value=forged)
assert await store.get(key) is None
def test_allowlist_matches_cacheable_methods(self):
"""The allowlist covers exactly the SDK's cacheable result models."""
assert set(CACHEABLE_RESULT_MODELS) == {
"DiscoverResult",
"ListPromptsResult",
"ListResourceTemplatesResult",
"ListResourcesResult",
"ListToolsResult",
"ReadResourceResult",
}
class TestFastMCPConstruction:
def test_custom_store_without_target_id_raises(self):
"""FastMCP requires a target_id for a custom shared store on an in-memory transport."""
store = KeyValueResponseCacheStore()
with pytest.raises(ValueError, match="requires CacheConfig.target_id"):
Client(FastMCP("x"), cache=CacheConfig(store=store, partition="p"))
def test_custom_store_without_partition_raises(self):
"""The SDK requires an explicit partition for any custom store."""
with pytest.raises(ValueError, match="requires an explicit partition"):
CacheConfig(store=KeyValueResponseCacheStore(), target_id="srv")
def test_custom_store_builds_cache(self):
store = KeyValueResponseCacheStore()
config = CacheConfig(store=store, partition="p", target_id="srv")
client = Client(FastMCP("x"), mode="auto", cache=config)
assert client._response_cache is not None
class TestDistributedSharing:
async def test_second_client_serves_first_clients_cache(self):
"""Two independent Clients sharing one adapter-backed store: client B's first
list_tools is served from the entry client A populated, with zero wire calls."""
backend = MemoryStore()
store = KeyValueResponseCacheStore(backend)
def make_client() -> Client:
config = CacheConfig(store=store, partition="tenant-a", target_id="cached")
return Client(_cached_server(), mode="auto", cache=config)
async with make_client() as client_a:
first = await client_a.list_tools()
assert [t.name for t in first] == ["add"]
async with make_client() as client_b:
calls = {"n": 0}
original = client_b.session.list_tools
async def spy(**kwargs):
calls["n"] += 1
return await original(**kwargs)
client_b.session.list_tools = spy # type: ignore[method-assign]
served = await client_b.list_tools()
assert calls["n"] == 0 # served from the shared store, no wire round-trip
assert [t.name for t in served] == ["add"]
async def test_distinct_partitions_do_not_share(self):
"""Two clients on the same store but different partitions each hit the wire."""
backend = MemoryStore()
store = KeyValueResponseCacheStore(backend)
config_a = CacheConfig(store=store, partition="tenant-a", target_id="cached")
async with Client(_cached_server(), mode="auto", cache=config_a) as client_a:
await client_a.list_tools()
config_b = CacheConfig(store=store, partition="tenant-b", target_id="cached")
async with Client(_cached_server(), mode="auto", cache=config_b) as client_b:
calls = {"n": 0}
original = client_b.session.list_tools
async def spy(**kwargs):
calls["n"] += 1
return await original(**kwargs)
client_b.session.list_tools = spy # type: ignore[method-assign]
await client_b.list_tools()
assert calls["n"] == 1 # different partition -> not shared, hits the wire

View file

@ -0,0 +1,93 @@
"""Tests for client OpenTelemetry tracing on task operations."""
import asyncio
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
from opentelemetry.trace import SpanKind
from fastmcp import Client, FastMCP
def assert_propagating_client_span(
trace_exporter: InMemorySpanExporter,
method: str,
component_key: str,
) -> None:
all_spans = trace_exporter.get_finished_spans()
spans = [span for span in all_spans if span.name == method]
client_span = next(
span
for span in spans
if span.attributes is not None and "fastmcp.server.name" not in span.attributes
)
server_span = next(
span
for span in spans
if span.attributes is not None and "fastmcp.server.name" in span.attributes
)
assert client_span.kind == SpanKind.CLIENT
assert client_span.attributes is not None
assert client_span.attributes["mcp.method.name"] == method
assert client_span.attributes["fastmcp.component.key"] == component_key
assert server_span.parent is not None
assert server_span.context.trace_id == client_span.context.trace_id
spans_by_id = {span.context.span_id: span for span in all_spans}
current = server_span
while current.parent is not None:
parent = spans_by_id.get(current.parent.span_id)
assert parent is not None
if parent.context.span_id == client_span.context.span_id:
break
current = parent
else:
raise AssertionError("Server span should descend from the client span")
async def test_list_tasks_creates_propagating_client_span(
trace_exporter: InMemorySpanExporter,
):
server = FastMCP("test-server")
async with Client(server) as client:
await client.list_tasks()
assert_propagating_client_span(trace_exporter, "tasks/list", "")
async def test_task_id_operations_create_propagating_client_spans(
trace_exporter: InMemorySpanExporter,
):
started = asyncio.Event()
server = FastMCP("test-server")
@server.tool(task=True)
async def quick_tool() -> str:
return "done"
@server.tool(task=True)
async def slow_tool() -> str:
started.set()
await asyncio.sleep(10)
return "done"
async with Client(server) as client:
completed_task = await client.call_tool("quick_tool", task=True)
await completed_task.wait(timeout=2)
trace_exporter.clear()
await client.get_task_status(completed_task.task_id)
await client.get_task_result(completed_task.task_id)
running_task = await client.call_tool("slow_tool", task=True)
await asyncio.wait_for(started.wait(), timeout=2)
await client.cancel_task(running_task.task_id)
assert_propagating_client_span(trace_exporter, "tasks/get", completed_task.task_id)
assert_propagating_client_span(
trace_exporter, "tasks/result", completed_task.task_id
)
assert_propagating_client_span(trace_exporter, "tasks/cancel", running_task.task_id)

View file

@ -107,11 +107,9 @@ async def test_elicitation_handler_parameters():
await client.call_tool("test_tool", {})
assert captured_params["message"] == "Test message"
assert "ScalarElicitationType" in str(captured_params["response_type"])
assert captured_params["params"].requested_schema == {
"properties": {"value": {"title": "Value", "type": "integer"}},
"required": ["value"],
"title": "ScalarElicitationType",
"type": "object",
}
assert captured_params["ctx"] is not None

View file

@ -514,3 +514,13 @@ class TestElicitationDefaults:
assert "default" in props["string_field"]
assert "default" in props["integer_field"]
def test_scalar_elicitation_schema_omits_wrapper_title() -> None:
"""Scalar/list wrappers must not leak the internal class name on the wire."""
from fastmcp.server.elicitation import parse_elicit_response_type
schema = parse_elicit_response_type(["yes", "no"]).schema
assert "title" not in schema
assert schema["properties"]["value"]["enum"] == ["yes", "no"]

View file

@ -1,5 +1,5 @@
import anyio
import httpx
import httpx2
from fastmcp.client.oauth_callback import (
OAuthCallbackResult,
@ -24,7 +24,7 @@ async def test_oauth_callback_result_ignores_subsequent_callbacks():
await anyio.sleep(0.05)
async with httpx.AsyncClient() as client:
async with httpx2.AsyncClient() as client:
first = await client.get(
f"http://127.0.0.1:{port}/callback?code=good&state=s1"
)

View file

@ -234,9 +234,9 @@ async def test_elicitation_tool(streamable_http_server: str, request):
@pytest.mark.parametrize("streamable_http_server", [True], indirect=True)
async def test_stateless_http_rejects_get_sse(streamable_http_server: str):
"""Stateless servers should reject GET SSE requests with 405."""
import httpx
import httpx2
async with httpx.AsyncClient() as http_client:
async with httpx2.AsyncClient() as http_client:
response = await http_client.get(streamable_http_server)
assert response.status_code == 405

View file

@ -5,7 +5,7 @@ _redirect_headers mechanism. These tests verify that FastMCP's transports rely o
this behavior correctly and do not override it.
"""
import httpx
import httpx2
import pytest
from starlette.applications import Starlette
from starlette.requests import Request
@ -41,8 +41,8 @@ class TestHttpxBuiltinRedirectProtection:
)
# Use an httpx client with follow_redirects=True (as MCP does)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
async with httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=app),
follow_redirects=True,
) as client:
response = await client.get(
@ -76,8 +76,8 @@ class TestHttpxBuiltinRedirectProtection:
]
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
async with httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=app),
follow_redirects=True,
) as client:
response = await client.get(
@ -119,8 +119,8 @@ class TestHttpxBuiltinRedirectProtection:
]
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
async with httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=app),
follow_redirects=True,
) as client:
response = await client.get(
@ -159,8 +159,8 @@ class TestMcpHttpClientRedirectProtection:
# Use AsyncClient directly with ASGI transport rather than
# monkey-patching _transport on create_mcp_http_client, which
# breaks when proxy env vars are set.
client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
client = httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=app),
headers={"Authorization": "Bearer secret"},
follow_redirects=True,
)

View file

@ -4,7 +4,7 @@ from collections.abc import AsyncIterator
from ssl import VerifyMode
from typing import Any, cast
import httpx
import httpx2
import pytest
from mcp.shared._httpx_utils import McpHttpClientFactory
@ -44,7 +44,7 @@ class TestClientTransport:
async def test_oauth_uses_same_client_as_transport_streamable_http():
transport = StreamableHttpTransport(
"https://some.fake.url/",
httpx_client_factory=lambda *args, **kwargs: httpx.AsyncClient(
httpx_client_factory=lambda *args, **kwargs: httpx2.AsyncClient(
verify=False, *args, **kwargs
),
auth="oauth",
@ -62,7 +62,7 @@ async def test_oauth_uses_same_client_as_transport_streamable_http():
async def test_oauth_uses_same_client_as_transport_sse():
transport = SSETransport(
"https://some.fake.url/",
httpx_client_factory=lambda *args, **kwargs: httpx.AsyncClient(
httpx_client_factory=lambda *args, **kwargs: httpx2.AsyncClient(
verify=False, *args, **kwargs
),
auth="oauth",
@ -263,7 +263,7 @@ class TestSSLVerify:
async def test_oauth_custom_factory_preserved_with_verify(self):
custom_factory = cast(
McpHttpClientFactory,
lambda **kwargs: httpx.AsyncClient(verify=False, **kwargs),
lambda **kwargs: httpx2.AsyncClient(verify=False, **kwargs),
)
auth = OAuth(httpx_client_factory=custom_factory)
transport = StreamableHttpTransport(
@ -275,7 +275,7 @@ class TestSSLVerify:
assert transport.auth.httpx_client_factory is custom_factory
def test_warns_when_both_factory_and_verify_provided_streamable(self):
factory = cast(McpHttpClientFactory, httpx.AsyncClient)
factory = cast(McpHttpClientFactory, httpx2.AsyncClient)
with pytest.warns(UserWarning, match="httpx_client_factory.*takes precedence"):
StreamableHttpTransport(
"https://example.com/mcp",
@ -284,7 +284,7 @@ class TestSSLVerify:
)
def test_warns_when_both_factory_and_verify_provided_sse(self):
factory = cast(McpHttpClientFactory, httpx.AsyncClient)
factory = cast(McpHttpClientFactory, httpx2.AsyncClient)
with pytest.warns(UserWarning, match="httpx_client_factory.*takes precedence"):
SSETransport(
"https://example.com/sse",

View file

@ -15,6 +15,7 @@ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from fastmcp.utilities.tests import temporary_settings
from tests.utilities.httpx2_mock import httpx_mock as httpx_mock
# Use SelectorEventLoop on Windows to avoid ProactorEventLoop crashes
# See: https://github.com/python/cpython/issues/116773

View file

@ -17,7 +17,7 @@ import time
from collections.abc import AsyncGenerator
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
import httpx2
import pytest
from fastmcp import FastMCP
@ -216,7 +216,7 @@ async def test_github_oauth_authorization_redirect(github_server: str):
parsed = urlparse(github_server)
base_url = f"{parsed.scheme}://{parsed.netloc}"
async with httpx.AsyncClient() as http_client:
async with httpx2.AsyncClient() as http_client:
# Step 1: Register OAuth client (DCR)
register_response = await http_client.post(
f"{base_url}/register",
@ -311,13 +311,13 @@ async def test_github_oauth_server_metadata(github_server: str):
"""Test OAuth server metadata discovery."""
from urllib.parse import urlparse
import httpx
import httpx2
# Extract base URL from server URL
parsed = urlparse(github_server)
base_url = f"{parsed.scheme}://{parsed.netloc}"
async with httpx.AsyncClient() as http_client:
async with httpx2.AsyncClient() as http_client:
# Test OAuth authorization server metadata
metadata_response = await http_client.get(
f"{base_url}/.well-known/oauth-authorization-server"
@ -340,7 +340,7 @@ async def test_github_oauth_unauthorized_access(github_server: str):
"""Test that unauthenticated requests are rejected.
SDK v2 surfaces the server's 401 as an MCPError ("Server returned an error
response") rather than re-raising the raw httpx.HTTPStatusError.
response") rather than re-raising the raw httpx2.HTTPStatusError.
"""
from mcp.shared.exceptions import MCPError
@ -375,13 +375,13 @@ async def test_github_oauth_mock_only_accepts_mock_tokens(github_server_with_moc
"""Test that the mock token verifier only accepts mock tokens, not real ones."""
from urllib.parse import urlparse
import httpx
import httpx2
# Extract base URL
parsed = urlparse(github_server_with_mock)
base_url = f"{parsed.scheme}://{parsed.netloc}"
async with httpx.AsyncClient() as http_client:
async with httpx2.AsyncClient() as http_client:
# Test that a fake "real" GitHub token is rejected
fake_real_token = "gho_real_token_should_be_rejected"

View file

@ -3,7 +3,7 @@
import os
from unittest.mock import AsyncMock, Mock, patch
import httpx
import httpx2
import pytest
from fastmcp import FastMCP
@ -19,7 +19,7 @@ class TestKeycloakProviderIntegration:
async def test_oauth_discovery_endpoints_integration(self):
"""Test OAuth discovery endpoints work correctly together."""
with patch("httpx.get") as mock_get:
with patch("httpx2.get") as mock_get:
mock_response = Mock()
mock_response.json.return_value = {
"issuer": TEST_REALM_URL,
@ -40,8 +40,8 @@ class TestKeycloakProviderIntegration:
mcp = FastMCP("test-server", auth=provider)
mcp_http_app = mcp.http_app()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=mcp_http_app),
async with httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=mcp_http_app),
base_url=TEST_BASE_URL,
) as client:
# Test protected resource metadata
@ -73,8 +73,8 @@ class TestKeycloakProviderIntegration:
mcp = FastMCP("test-server", auth=provider)
mcp_http_app = mcp.http_app()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=mcp_http_app),
async with httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=mcp_http_app),
base_url=TEST_BASE_URL,
) as client:
response = await client.post(
@ -91,11 +91,11 @@ class TestKeycloakProviderIntegration:
async def test_authorization_server_metadata_forwards_keycloak(self):
"""Test that authorization server metadata is forwarded from Keycloak.
Note: This test is skipped because mocking httpx.AsyncClient conflicts with the
Note: This test is skipped because mocking httpx2.AsyncClient conflicts with the
ASGI transport used by the test client. The functionality has been verified to
work correctly in production (see user testing logs showing successful DCR proxy).
"""
with patch("httpx.get") as mock_get:
with patch("httpx2.get") as mock_get:
# Mock OIDC discovery
mock_discovery = Mock()
mock_discovery.json.return_value = {
@ -118,7 +118,7 @@ class TestKeycloakProviderIntegration:
mcp_http_app = mcp.http_app()
# Mock the metadata forwarding request
with patch("httpx.AsyncClient") as mock_client_class:
with patch("httpx2.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value.__aenter__.return_value = mock_client
@ -136,8 +136,8 @@ class TestKeycloakProviderIntegration:
mock_metadata_response.raise_for_status = Mock()
mock_client.get.return_value = mock_metadata_response
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=mcp_http_app),
async with httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=mcp_http_app),
base_url=TEST_BASE_URL,
) as client:
# Test authorization server metadata forwarding
@ -190,10 +190,10 @@ class TestKeycloakProviderIntegration:
async def test_metadata_forwarding_error_handling(self):
"""Test error handling when metadata forwarding fails.
Note: This test is skipped because mocking httpx.AsyncClient conflicts with the
Note: This test is skipped because mocking httpx2.AsyncClient conflicts with the
ASGI transport. Error handling code is present and follows standard patterns.
"""
with patch("httpx.get") as mock_get:
with patch("httpx2.get") as mock_get:
mock_response = Mock()
mock_response.json.return_value = {
"issuer": TEST_REALM_URL,
@ -212,15 +212,15 @@ class TestKeycloakProviderIntegration:
mcp = FastMCP("test-server", auth=provider)
mcp_http_app = mcp.http_app()
with patch("httpx.AsyncClient") as mock_client_class:
with patch("httpx2.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value.__aenter__.return_value = mock_client
# Simulate Keycloak error
mock_client.get.side_effect = httpx.RequestError("Connection failed")
mock_client.get.side_effect = httpx2.RequestError("Connection failed")
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=mcp_http_app),
async with httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=mcp_http_app),
base_url=TEST_BASE_URL,
) as client:
response = await client.get(
@ -247,7 +247,7 @@ class TestKeycloakProviderEnvironmentConfiguration:
with (
patch.dict(os.environ, env_vars),
patch("httpx.get") as mock_get,
patch("httpx2.get") as mock_get,
):
mock_response = Mock()
mock_response.json.return_value = {
@ -283,7 +283,7 @@ class TestKeycloakProviderEnvironmentConfiguration:
async def test_provider_works_in_production_like_environment(self):
"""Test provider configuration that mimics production deployment.
Note: This test is skipped because mocking httpx.AsyncClient conflicts with the
Note: This test is skipped because mocking httpx2.AsyncClient conflicts with the
ASGI transport used by the test client. The functionality has been verified to
work correctly in production (see user testing logs showing successful DCR proxy).
"""
@ -295,7 +295,7 @@ class TestKeycloakProviderEnvironmentConfiguration:
with (
patch.dict(os.environ, production_env),
patch("httpx.get") as mock_get,
patch("httpx2.get") as mock_get,
):
mock_response = Mock()
mock_response.json.return_value = {
@ -319,7 +319,7 @@ class TestKeycloakProviderEnvironmentConfiguration:
mcp = FastMCP("production-server", auth=provider)
mcp_http_app = mcp.http_app()
with patch("httpx.AsyncClient") as mock_client_class:
with patch("httpx2.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value.__aenter__.return_value = mock_client
@ -335,8 +335,8 @@ class TestKeycloakProviderEnvironmentConfiguration:
mock_metadata.raise_for_status = Mock()
mock_client.get.return_value = mock_metadata
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=mcp_http_app),
async with httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=mcp_http_app),
base_url="https://api.company.com",
) as client:
# Test discovery endpoints work

View file

@ -21,7 +21,7 @@ def _is_rate_limit_error(excinfo, report=None) -> bool:
if exc_type == "BrokenResourceError":
return True
# httpx.HTTPStatusError with 429 status
# httpx2.HTTPStatusError with 429 status
if exc_type == "HTTPStatusError":
try:
if hasattr(exc, "response") and exc.response.status_code == 429:

Some files were not shown because too many files have changed in this diff Show more