mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 04:54:17 +02:00
Merge remote-tracking branch 'origin/main' into codex/otel-on-start-attributes
# Conflicts: # fastmcp_slim/fastmcp/server/telemetry.py
This commit is contained in:
commit
d0afccb028
159 changed files with 4974 additions and 1029 deletions
73
.github/scripts/triage-label.sh
vendored
Executable file
73
.github/scripts/triage-label.sh
vendored
Executable 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
|
||||
19
.github/workflows/marvin-label-triage.yml
vendored
19
.github/workflows/marvin-label-triage.yml
vendored
|
|
@ -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 }}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
.github/workflows/require-issue-link.yml
vendored
23
.github/workflows/require-issue-link.yml
vendored
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}"},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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
|
|||
Here’s how you can add custom route maps to turn `GET` requests into `Resources` and `ResourceTemplates` (if they have path parameters):
|
||||
|
||||
```python api_server_with_resources.py {3, 37-42}
|
||||
import httpx
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
10
examples/testing_demo/uv.lock
generated
10
examples/testing_demo/uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
223
fastmcp_slim/fastmcp/client/caching.py
Normal file
223
fastmcp_slim/fastmcp/client/caching.py
Normal 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
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
162
fastmcp_slim/fastmcp/resources/security.py
Normal file
162
fastmcp_slim/fastmcp/resources/security.py
Normal 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."""
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
147
fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py
Normal file
147
fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}"}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, ",")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
279
tests/client/client/test_kv_response_cache.py
Normal file
279
tests/client/client/test_kv_response_cache.py
Normal 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
|
||||
93
tests/client/telemetry/test_client_task_tracing.py
Normal file
93
tests/client/telemetry/test_client_task_tracing.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue