`) rather than one list shared by all of
+# them: two renders in flight at once both build their Set-Cookie from the same
+# inbound Cookie header, so a shared list silently drops whichever entry was
+# written first. Separate names never collide.
+#
+# The unsuffixed name is the pre-upgrade flat list. It is read, never written,
+# so a consent page rendered before an upgrade can still be submitted after it.
+_CONSENT_STATE_COOKIE_BASE = "MCP_CONSENT_STATE"
+
logger = get_logger(__name__)
+def _consent_state_base_name(csrf_token: str) -> str:
+ """Base cookie name carrying a single issued CSRF token.
+
+ The token is hashed rather than used directly so the raw token does not end
+ up in a cookie name, which is far more likely to be logged than its value.
+ """
+ digest = hashlib.sha256(csrf_token.encode()).hexdigest()[:32]
+ return f"{_CONSENT_STATE_COOKIE_BASE}_{digest}"
+
+
class ConsentMixin:
"""Mixin class providing consent management functionality for OAuthProxy.
@@ -179,6 +213,115 @@ class ConsentMixin:
path="/",
)
+ def _read_consent_state_cookies(
+ self: OAuthProxy, request: Request
+ ) -> dict[str, tuple[str, float]]:
+ """Per-token consent-state cookies the browser sent, by cookie name.
+
+ Returns {cookie_name: (txn_id, issued_at)} for every cookie whose
+ signature verifies. Unsigned, tampered, or unparsable cookies are
+ skipped rather than raising, the same way the other cookie readers here
+ treat them.
+ """
+ prefix = self._cookie_name(f"{_CONSENT_STATE_COOKIE_BASE}_")
+ found: dict[str, tuple[str, float]] = {}
+ for name, raw in request.cookies.items():
+ if not name.startswith(prefix):
+ continue
+ payload = self._verify_cookie(raw)
+ if not payload:
+ logger.debug("Cookie signature verification failed for %s", name)
+ continue
+ try:
+ data = json.loads(base64.b64decode(payload.encode()).decode())
+ except Exception:
+ logger.debug("Failed to decode cookie %s; ignoring", name)
+ continue
+ if not isinstance(data, dict):
+ continue
+ txn_id = data.get("txn")
+ issued_at = data.get("iat")
+ if isinstance(txn_id, str) and isinstance(issued_at, int | float):
+ found[name] = (txn_id, float(issued_at))
+ return found
+
+ def _set_consent_state_cookie(
+ self: OAuthProxy,
+ response: HTMLResponse | RedirectResponse,
+ csrf_token: str,
+ txn_id: str,
+ issued_at: float,
+ ) -> None:
+ """Record that this browser received `csrf_token`, under its own name.
+
+ The cookie is what makes the double-submit check meaningful: the token
+ in the form has to match one this browser was actually handed. Writing
+ it under a name derived from the token keeps that property while making
+ the write independent of every other render's.
+ """
+ payload = base64.b64encode(
+ json.dumps(
+ {"txn": txn_id, "iat": issued_at}, separators=(",", ":")
+ ).encode()
+ ).decode()
+ response.set_cookie(
+ self._cookie_name(_consent_state_base_name(csrf_token)),
+ self._sign_cookie(payload),
+ max_age=15 * 60,
+ secure=self._is_https,
+ httponly=True,
+ samesite="lax",
+ path="/",
+ )
+
+ def _clear_consent_state_for_transaction(
+ self: OAuthProxy,
+ request: Request,
+ response: HTMLResponse | RedirectResponse,
+ txn_id: str,
+ *,
+ include_legacy: bool,
+ ) -> None:
+ """Expire the consent state belonging to one completed transaction.
+
+ Only this transaction's cookies are removed. Another consent flow the
+ same browser has open keeps its own state, which a single shared list
+ had no way to express — completing either flow wiped both.
+ """
+ for name, (cookie_txn, _issued_at) in self._read_consent_state_cookies(
+ request
+ ).items():
+ if hmac.compare_digest(cookie_txn, txn_id):
+ self._expire_cookie(response, name)
+
+ if include_legacy:
+ # The pre-upgrade cookie is a flat list with no transaction
+ # attached, so it can only be cleared wholesale. Reached only when
+ # the submitted token came from it, which means every flow sharing
+ # it was rendered before the upgrade too.
+ self._set_list_cookie(
+ response,
+ _CONSENT_STATE_COOKIE_BASE,
+ self._encode_list_cookie([]),
+ max_age=60,
+ )
+
+ def _expire_cookie(
+ self: OAuthProxy,
+ response: HTMLResponse | RedirectResponse,
+ name: str,
+ ) -> None:
+ """Expire one cookie by name, matching the attributes it was set with."""
+ response.set_cookie(
+ name,
+ "",
+ max_age=0,
+ secure=self._is_https,
+ httponly=True,
+ samesite="lax",
+ path="/",
+ )
+
def _read_consent_bindings(self: OAuthProxy, request: Request) -> dict[str, str]:
"""Read the consent binding map from the signed cookie.
@@ -369,20 +512,31 @@ class ConsentMixin:
sec_fetch_site,
)
- # Need consent: issue CSRF token and show HTML
+ # Need consent: issue CSRF token and show HTML.
+ #
+ # A transaction can be rendered more than once before it is submitted —
+ # a reload, a browser preload, an extension re-fetching the URL. Every
+ # render issues its own token, so that a token stays unique to the
+ # browser that received it and the double-submit cookie check keeps its
+ # meaning, and every token issued for the transaction stays valid until
+ # it expires. Dropping the earlier one kills the form the user is
+ # already looking at: they click Approve and get "Invalid or expired
+ # consent token" on a flow that never expired, with no way to recover.
+ #
+ # The token is stored under its own key rather than appended to a list
+ # on the transaction. Two renders in flight at once would both read the
+ # same transaction, each append their token, and the second write would
+ # drop the first — `AsyncKeyValue` has no compare-and-swap to prevent
+ # it. Independent keys make the writes commute, including across
+ # processes sharing one storage backend.
csrf_token = secrets.token_urlsafe(32)
- csrf_expires_at = time.time() + 15 * 60
-
- # Update transaction with CSRF token
- txn_model.csrf_token = csrf_token
- txn_model.csrf_expires_at = csrf_expires_at
- await self._transaction_store.put(
- key=txn_id, value=txn_model, ttl=15 * 60
- ) # Auto-expire after 15 minutes
-
- # Update dict for use in HTML generation
- txn["csrf_token"] = csrf_token
- txn["csrf_expires_at"] = csrf_expires_at
+ issued_at = time.time()
+ csrf_expires_at = issued_at + 15 * 60
+ await self._consent_csrf_store.put(
+ key=_hash_token(csrf_token),
+ value=ConsentCSRFToken(txn_id=txn_id, expires_at=csrf_expires_at),
+ ttl=15 * 60, # Auto-expire after 15 minutes
+ )
# Load client to get client_name and CIMD info if available
client = await self.get_client(txn["client_id"])
@@ -423,15 +577,19 @@ class ConsentMixin:
cimd_domain=cimd_domain,
)
response = create_secure_html_response(html)
- # Merge new CSRF token with any existing ones (supports concurrent flows)
- existing_tokens = self._decode_list_cookie(request, "MCP_CONSENT_STATE")
- existing_tokens.append(csrf_token)
- self._set_list_cookie(
- response,
- "MCP_CONSENT_STATE",
- self._encode_list_cookie(existing_tokens),
- max_age=15 * 60,
- )
+ self._set_consent_state_cookie(response, csrf_token, txn_id, issued_at)
+
+ # Keep the browser's consent state bounded. The cookie just set always
+ # survives; the oldest of the rest are expired to make room. Eviction
+ # is by issued-at from the cookie itself, so it does not depend on any
+ # server-side bookkeeping that renders would have to share.
+ others = self._read_consent_state_cookies(request)
+ others.pop(self._cookie_name(_consent_state_base_name(csrf_token)), None)
+ surplus = len(others) + 1 - _MAX_CSRF_TOKENS
+ if surplus > 0:
+ by_age = sorted(others.items(), key=lambda item: item[1][1])
+ for name, _entry in by_age[:surplus]:
+ self._expire_cookie(response, name)
return response
async def _submit_consent(
@@ -455,10 +613,34 @@ class ConsentMixin:
)
txn = txn_model.model_dump()
- expected_csrf = txn.get("csrf_token")
- expires_at = float(txn.get("csrf_expires_at") or 0)
- if not expected_csrf or csrf_token != expected_csrf or time.time() > expires_at:
+ # Look the token up by its own key. A record proves the token was
+ # issued by a render of THIS transaction; nothing else can have written
+ # it, and a concurrent render cannot have removed it.
+ csrf_record = (
+ await self._consent_csrf_store.get(key=_hash_token(csrf_token))
+ if csrf_token
+ else None
+ )
+ if csrf_record is not None:
+ legacy_csrf = False
+ csrf_valid = (
+ hmac.compare_digest(csrf_record.txn_id, txn_id)
+ and time.time() <= csrf_record.expires_at
+ )
+ else:
+ # No record: either the token is bogus, or the consent page was
+ # rendered by a version that kept the token on the transaction.
+ # Honouring the old location keeps a flow that was already open
+ # during an upgrade submittable instead of failing at Approve.
+ stored = txn_model.csrf_token
+ csrf_valid = bool(csrf_token and stored) and (
+ hmac.compare_digest(stored or "", csrf_token)
+ and time.time() <= (txn_model.csrf_expires_at or 0)
+ )
+ legacy_csrf = csrf_valid
+
+ if not csrf_valid:
return create_secure_html_response(
"Error
Invalid or expired consent token
", status_code=400
)
@@ -467,8 +649,16 @@ class ConsentMixin:
# Without this, an attacker who knows their own tx_id/csrf_token can
# CSRF the victim's browser into approving consent, bypassing the
# consent binding cookie protection.
- cookie_csrf_tokens = self._decode_list_cookie(request, "MCP_CONSENT_STATE")
- if csrf_token not in cookie_csrf_tokens:
+ if legacy_csrf:
+ cookie_ok = csrf_token in self._decode_list_cookie(
+ request, _CONSENT_STATE_COOKIE_BASE
+ )
+ else:
+ entry = self._read_consent_state_cookies(request).get(
+ self._cookie_name(_consent_state_base_name(csrf_token))
+ )
+ cookie_ok = entry is not None and hmac.compare_digest(entry[0], txn_id)
+ if not cookie_ok:
logger.warning(
"CSRF double-submit check failed for transaction %s "
"(possible cross-site consent forgery)",
@@ -509,9 +699,12 @@ class ConsentMixin:
max_age=365 * 24 * 3600,
)
- # Clear CSRF cookie by setting empty short-lived value
- self._set_list_cookie(
- response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60
+ # Retire this transaction's consent state, both halves of it: the
+ # stored token so it cannot be replayed, and the cookies that
+ # carried it. Other pending flows are left alone.
+ await self._consent_csrf_store.delete(key=_hash_token(csrf_token))
+ self._clear_consent_state_for_transaction(
+ request, response, txn_id, include_legacy=legacy_csrf
)
self._set_consent_binding_cookie(request, response, txn_id, consent_token)
return response
@@ -549,8 +742,9 @@ class ConsentMixin:
max_age=365 * 24 * 3600,
)
- self._set_list_cookie(
- response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60
+ await self._consent_csrf_store.delete(key=_hash_token(csrf_token))
+ self._clear_consent_state_for_transaction(
+ request, response, txn_id, include_legacy=legacy_csrf
)
return response
diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py
index 6d8770df4..5255ecad6 100644
--- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py
+++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py
@@ -58,11 +58,32 @@ class OAuthTransaction(BaseModel):
created_at: float
resource: str | None = None
proxy_code_verifier: str | None = None
+ # Deprecated: consent CSRF tokens are now stored under their own keys (see
+ # ConsentCSRFToken) so that concurrent renders cannot overwrite each other.
+ # These two fields are only read, never written, and only to keep a consent
+ # flow that started before the upgrade submittable after it.
csrf_token: str | None = None
csrf_expires_at: float | None = None
consent_token: str | None = None
+class ConsentCSRFToken(BaseModel):
+ """One CSRF token issued for one render of the consent page.
+
+ Stored under a key derived from the token itself rather than on the
+ transaction. Every render of a consent page issues its own token, and two
+ renders can be in flight at once (a reload, a browser preload, an extension
+ re-fetching the URL). Appending to a list on the transaction loses one of
+ them whenever that happens: `AsyncKeyValue` has no compare-and-swap, so two
+ handlers read the same transaction, each append their own token, and the
+ second write drops the first. Giving each token its own key makes the
+ writes independent, which holds across processes sharing one backend.
+ """
+
+ txn_id: str
+ expires_at: float
+
+
class ClientCode(BaseModel):
"""Client authorization code with PKCE and upstream tokens.
diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py
index 760552bcc..686ac6df3 100644
--- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py
+++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py
@@ -100,6 +100,7 @@ from fastmcp.server.auth.oauth_proxy.models import (
DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS,
HTTP_TIMEOUT_SECONDS,
ClientCode,
+ ConsentCSRFToken,
JTIMapping,
OAuthTransaction,
ProxyDCRClient,
@@ -649,6 +650,19 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
raise_on_validation_error=True,
)
+ # Consent CSRF tokens, keyed by a hash of the token rather than by
+ # transaction. Each render of a consent page writes its own key, so
+ # renders that overlap cannot overwrite one another the way appending
+ # to a list on the transaction would.
+ self._consent_csrf_store: PydanticAdapter[ConsentCSRFToken] = PydanticAdapter[
+ ConsentCSRFToken
+ ](
+ key_value=self._client_storage,
+ pydantic_model=ConsentCSRFToken,
+ default_collection="mcp-consent-csrf-tokens",
+ raise_on_validation_error=True,
+ )
+
self._code_store: PydanticAdapter[ClientCode] = PydanticAdapter[ClientCode](
key_value=self._client_storage,
pydantic_model=ClientCode,
diff --git a/tests/server/auth/test_oauth_consent_flow.py b/tests/server/auth/test_oauth_consent_flow.py
index fae84068f..51e57d90e 100644
--- a/tests/server/auth/test_oauth_consent_flow.py
+++ b/tests/server/auth/test_oauth_consent_flow.py
@@ -12,11 +12,13 @@ This test suite verifies:
9. Consent binding cookie prevents confused deputy attacks (GHSA-rww4-4w9c-7733)
"""
+import asyncio
import re
import secrets
import time
from urllib.parse import parse_qs, urlparse
+import httpx
import pytest
from key_value.aio.stores.memory import MemoryStore
from mcp.server.auth.provider import AuthorizationParams
@@ -27,6 +29,10 @@ from starlette.testclient import TestClient
from fastmcp.server.auth.auth import AccessToken, TokenVerifier
from fastmcp.server.auth.oauth_proxy import OAuthProxy
+from fastmcp.server.auth.oauth_proxy.consent import (
+ _CONSENT_STATE_COOKIE_BASE,
+ _MAX_CSRF_TOKENS,
+)
from fastmcp.server.auth.oauth_proxy.models import OAuthTransaction
@@ -167,6 +173,49 @@ def _extract_csrf(html: str) -> str | None:
return m.group(1) if m else None
+def _consent_state_cookie_names(client: httpx.AsyncClient | TestClient) -> list[str]:
+ """Names of the per-token consent-state cookies currently held."""
+ prefix = f"__Host-{_CONSENT_STATE_COOKIE_BASE}_"
+ return [c.name for c in client.cookies.jar if c.name.startswith(prefix)]
+
+
+class _RenderGate:
+ """Holds arrivals until `parties` of them are waiting, then releases all.
+
+ asyncio.Barrier would do this, but it is 3.11+ and fastmcp supports 3.10.
+ Single-threaded event loop, so the counter needs no lock.
+ """
+
+ def __init__(self, parties: int) -> None:
+ self._parties = parties
+ self._arrived = 0
+ self._opened = asyncio.Event()
+
+ async def wait(self) -> None:
+ self._arrived += 1
+ if self._arrived >= self._parties:
+ self._opened.set()
+ await self._opened.wait()
+
+
+def _gate_transaction_reads(proxy: OAuthProxy, gate: _RenderGate) -> None:
+ """Hold every transaction read open until the gate releases.
+
+ Parks concurrent consent renders in the window where each has read the
+ transaction and none has stored its token yet. That interleaving is what
+ loses a token when tokens are appended to the transaction: both handlers
+ read the same value, both append, and the second write wins.
+ """
+ original_get = proxy._transaction_store.get
+
+ async def gated_get(*args, **kwargs):
+ result = await original_get(*args, **kwargs)
+ await gate.wait()
+ return result
+
+ proxy._transaction_store.get = gated_get # ty: ignore[invalid-assignment]
+
+
class TestServerSideStorage:
"""Tests verifying OAuth state is stored in AsyncKeyValue storage."""
@@ -537,6 +586,248 @@ class TestCSRFDoubleSubmit:
assert response.status_code == 403
+class TestConcurrentConsentRenders:
+ """A transaction can be rendered more than once before it is submitted."""
+
+ async def test_earlier_render_still_submittable(self, oauth_proxy_with_storage):
+ """A second render must not invalidate the form from the first one.
+
+ A consent URL gets loaded twice more often than you would think: a
+ reload, a browser preload, an extension re-fetching it. Each render
+ issues a new CSRF token, and if that replaces the previous one, the
+ page the user is actually looking at is already dead when they click
+ Approve. They get "Invalid or expired consent token" on a transaction
+ that has not expired, and retrying does not help.
+ """
+ txn_id, _ = await _start_flow(
+ oauth_proxy_with_storage,
+ "concurrent-render-client",
+ "http://localhost:9090/callback",
+ )
+
+ app = Starlette(routes=oauth_proxy_with_storage.get_routes())
+ # https base_url so the Secure/__Host- consent cookie is retained
+ # between requests, the way it is in a browser.
+ with TestClient(app, base_url="https://myserver.com") as test_client:
+ first = _extract_csrf(test_client.get(f"/consent?txn_id={txn_id}").text)
+ second = _extract_csrf(test_client.get(f"/consent?txn_id={txn_id}").text)
+ assert first and second
+ # Each render gets its own token, so a token stays bound to the
+ # browser it was issued to and the double-submit check keeps working.
+ assert first != second
+
+ # The user submits the page they had open, which is the first one.
+ response = test_client.post(
+ "/consent",
+ data={"action": "approve", "txn_id": txn_id, "csrf_token": first},
+ follow_redirects=False,
+ )
+ assert response.status_code == 302
+
+ async def test_forged_token_still_rejected_without_cookie(
+ self, oauth_proxy_with_storage
+ ):
+ """Keeping older tokens valid must not weaken the double-submit check.
+
+ An attacker who renders the consent page for a transaction they started
+ learns a token that stays valid. It is still useless against a victim's
+ browser, because it never lands in the victim's cookie.
+ """
+ txn_id, _ = await _start_flow(
+ oauth_proxy_with_storage,
+ "concurrent-render-attacker",
+ "http://localhost:9090/callback",
+ )
+
+ app = Starlette(routes=oauth_proxy_with_storage.get_routes())
+ with TestClient(app, base_url="https://myserver.com") as attacker_client:
+ attacker_token = _extract_csrf(
+ attacker_client.get(f"/consent?txn_id={txn_id}").text
+ )
+ assert attacker_token
+
+ with TestClient(app, base_url="https://myserver.com") as victim_client:
+ victim_client.get(f"/consent?txn_id={txn_id}")
+ response = victim_client.post(
+ "/consent",
+ data={
+ "action": "approve",
+ "txn_id": txn_id,
+ "csrf_token": attacker_token,
+ },
+ follow_redirects=False,
+ )
+ assert response.status_code == 403
+
+ @pytest.mark.parametrize("submitted", [0, 1])
+ async def test_overlapping_renders_both_submittable(
+ self, oauth_proxy_with_storage, submitted
+ ):
+ """Two renders in flight at once must both survive.
+
+ Sequential renders are the common case, but nothing serialises them.
+ Two handlers can read the same transaction before either has stored its
+ token, and if tokens live in a list on the transaction the second write
+ drops the first — there is no compare-and-swap on `AsyncKeyValue` to
+ catch it, and it happens across processes sharing one backend too.
+
+ The gate forces exactly that interleaving. Both responses are applied to
+ one cookie jar, the way a browser applies them, and then whichever form
+ the user happened to be looking at is submitted.
+ """
+ txn_id, _ = await _start_flow(
+ oauth_proxy_with_storage,
+ "overlapping-render-client",
+ "http://localhost:9090/callback",
+ )
+
+ gate = _RenderGate(2)
+ _gate_transaction_reads(oauth_proxy_with_storage, gate)
+
+ app = Starlette(routes=oauth_proxy_with_storage.get_routes())
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app),
+ base_url="https://myserver.com",
+ ) as client:
+ first, second = await asyncio.gather(
+ client.get(f"/consent?txn_id={txn_id}"),
+ client.get(f"/consent?txn_id={txn_id}"),
+ )
+ tokens = [_extract_csrf(first.text), _extract_csrf(second.text)]
+ assert all(tokens)
+ # Each render still issues its own token, so a token stays unique
+ # to the browser it was handed to.
+ assert tokens[0] != tokens[1]
+ # Both responses left their own cookie; neither overwrote the other.
+ assert len(_consent_state_cookie_names(client)) == 2
+
+ response = await client.post(
+ "/consent",
+ data={
+ "action": "approve",
+ "txn_id": txn_id,
+ "csrf_token": tokens[submitted],
+ },
+ )
+ assert response.status_code == 302
+
+
+class TestConsentStateCookieScope:
+ """Consent state is per transaction and bounded."""
+
+ async def test_completing_one_flow_leaves_another_submittable(
+ self, oauth_proxy_with_storage
+ ):
+ """Approving one transaction must not clear a different pending one.
+
+ A browser can have two consent flows open — two clients connecting, or
+ one client retried in a second tab. Consent state held as a single flat
+ list cannot express that: completing either flow wipes both, and the
+ one still on screen fails the double-submit check on Approve.
+ """
+ first_txn, _ = await _start_flow(
+ oauth_proxy_with_storage, "pending-flow-a", "http://localhost:9090/callback"
+ )
+ second_txn, _ = await _start_flow(
+ oauth_proxy_with_storage, "pending-flow-b", "http://localhost:9090/callback"
+ )
+
+ app = Starlette(routes=oauth_proxy_with_storage.get_routes())
+ with TestClient(app, base_url="https://myserver.com") as client:
+ first_token = _extract_csrf(client.get(f"/consent?txn_id={first_txn}").text)
+ second_token = _extract_csrf(
+ client.get(f"/consent?txn_id={second_txn}").text
+ )
+ assert first_token and second_token
+
+ completed = client.post(
+ "/consent",
+ data={
+ "action": "approve",
+ "txn_id": first_txn,
+ "csrf_token": first_token,
+ },
+ follow_redirects=False,
+ )
+ assert completed.status_code == 302
+ # The flow still on screen is unaffected.
+ still_open = client.post(
+ "/consent",
+ data={
+ "action": "approve",
+ "txn_id": second_txn,
+ "csrf_token": second_token,
+ },
+ follow_redirects=False,
+ )
+ assert still_open.status_code == 302
+
+ # Both flows are done, so nothing is left behind either.
+ assert _consent_state_cookie_names(client) == []
+
+ async def test_completing_a_flow_removes_only_its_own_state(
+ self, oauth_proxy_with_storage
+ ):
+ """Approve clears the transaction it completed and nothing else."""
+ first_txn, _ = await _start_flow(
+ oauth_proxy_with_storage, "scoped-flow-a", "http://localhost:9090/callback"
+ )
+ second_txn, _ = await _start_flow(
+ oauth_proxy_with_storage, "scoped-flow-b", "http://localhost:9090/callback"
+ )
+
+ app = Starlette(routes=oauth_proxy_with_storage.get_routes())
+ with TestClient(app, base_url="https://myserver.com") as client:
+ first_token = _extract_csrf(client.get(f"/consent?txn_id={first_txn}").text)
+ client.get(f"/consent?txn_id={second_txn}")
+ assert first_token
+ before = set(_consent_state_cookie_names(client))
+ assert len(before) == 2
+
+ client.post(
+ "/consent",
+ data={
+ "action": "approve",
+ "txn_id": first_txn,
+ "csrf_token": first_token,
+ },
+ follow_redirects=False,
+ )
+
+ after = set(_consent_state_cookie_names(client))
+ assert len(after) == 1
+ assert after < before
+
+ async def test_consent_state_cookies_are_bounded(self, oauth_proxy_with_storage):
+ """Repeated renders must not grow the Cookie header without limit.
+
+ One cookie per issued token is what keeps concurrent renders from
+ overwriting each other, so the count has to be capped somewhere. The
+ newest render always survives the cull.
+ """
+ txn_id, _ = await _start_flow(
+ oauth_proxy_with_storage,
+ "bounded-render-client",
+ "http://localhost:9090/callback",
+ )
+
+ app = Starlette(routes=oauth_proxy_with_storage.get_routes())
+ with TestClient(app, base_url="https://myserver.com") as client:
+ newest = None
+ for _ in range(_MAX_CSRF_TOKENS + 3):
+ newest = _extract_csrf(client.get(f"/consent?txn_id={txn_id}").text)
+ assert newest
+
+ assert len(_consent_state_cookie_names(client)) <= _MAX_CSRF_TOKENS
+
+ response = client.post(
+ "/consent",
+ data={"action": "approve", "txn_id": txn_id, "csrf_token": newest},
+ follow_redirects=False,
+ )
+ assert response.status_code == 302
+
+
class TestStoragePersistence:
"""Tests for state persistence across storage backends."""
From 822c82c93f0615eb39645ff6164ffb231e0bbb3f Mon Sep 17 00:00:00 2001
From: nate nowack
Date: Thu, 13 Aug 2026 14:59:16 -0500
Subject: [PATCH 09/27] Add audience pinning to GoogleTokenVerifier (#4827)
Co-authored-by: Claude Fable 5
---
docs/servers/auth/oauth-proxy.mdx | 4 +
.../fastmcp/server/auth/providers/github.py | 9 +++
.../fastmcp/server/auth/providers/google.py | 21 ++++++
tests/server/auth/providers/test_google.py | 73 +++++++++++++++++++
4 files changed, 107 insertions(+)
diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx
index 03727ec87..1e0f61578 100644
--- a/docs/servers/auth/oauth-proxy.mdx
+++ b/docs/servers/auth/oauth-proxy.mdx
@@ -389,6 +389,10 @@ The OAuth proxy requires a compatible `TokenVerifier` to validate tokens from yo
See the [Token Verification guide](/servers/auth/token-verification) for detailed setup instructions for your provider.
+
+Provider-specific verifiers like `GitHubTokenVerifier` and `GoogleTokenVerifier` confirm that a token is a valid credential for that provider — not that it was issued to *your* application. GitHub tokens carry no audience claim at all, so any valid GitHub credential (including a personal access token) will verify. Inside the OAuth proxy this is safe: the proxy issues its own tokens to clients and only runs the verifier against upstream tokens it obtained through its own OAuth flow. If you use one of these verifiers standalone, you are authenticating "any user of that provider" unless you constrain it — `GoogleTokenVerifier` accepts an `audience` parameter to pin tokens to your OAuth client ID.
+
+
### Scope Configuration
OAuth scopes control what permissions your application requests from users. They're configured through your `TokenVerifier` (required for the OAuth proxy to validate tokens from your provider). Set `required_scopes` to automatically request the permissions your application needs:
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/github.py b/fastmcp_slim/fastmcp/server/auth/providers/github.py
index 214d24c3b..9353c1726 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/github.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/github.py
@@ -44,6 +44,15 @@ class GitHubTokenVerifier(TokenVerifier):
GitHub OAuth tokens are opaque (not JWTs), so we verify them
by calling GitHub's API to check if they're valid and get user info.
+ Warning:
+ GitHub tokens carry no audience claim, so this verifier cannot tell
+ which OAuth app (if any) a token was issued for — any valid GitHub
+ credential, including a personal access token, will verify. Used
+ inside `GitHubProvider` this is safe, because the proxy only ever
+ checks tokens it obtained through its own OAuth flow. As a standalone
+ verifier it authenticates "some GitHub user", not "a user of your
+ app" — only use it that way if that is genuinely your access model.
+
Caching is disabled by default. Set ``cache_ttl_seconds`` to a positive
integer to cache successful verification results and avoid repeated
GitHub API calls for the same token.
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/google.py b/fastmcp_slim/fastmcp/server/auth/providers/google.py
index a8536b223..d15bb6df5 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/google.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/google.py
@@ -70,6 +70,7 @@ class GoogleTokenVerifier(TokenVerifier):
required_scopes: list[str] | None = None,
timeout_seconds: int = 10,
http_client: httpx2.AsyncClient | None = None,
+ audience: str | list[str] | None = None,
):
"""Initialize the Google token verifier.
@@ -79,6 +80,12 @@ class GoogleTokenVerifier(TokenVerifier):
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.
+ audience: Expected `aud` value (your Google OAuth client ID) or list of
+ allowed values. When set, tokens minted for any other OAuth client are
+ rejected. When None (default), any valid Google token is accepted
+ regardless of which OAuth client it was issued to — only appropriate
+ when the token's provenance is guaranteed elsewhere (as in
+ `GoogleProvider`, which obtains tokens through its own OAuth flow).
"""
normalized = (
[_normalize_google_scope(s) for s in required_scopes]
@@ -88,6 +95,7 @@ class GoogleTokenVerifier(TokenVerifier):
super().__init__(required_scopes=normalized)
self.timeout_seconds = timeout_seconds
self._http_client = http_client
+ self.audience = audience
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify a Google OAuth token using the tokeninfo endpoint.
@@ -126,6 +134,18 @@ class GoogleTokenVerifier(TokenVerifier):
logger.debug("Google tokeninfo missing 'aud' claim")
return None
+ if self.audience is not None:
+ allowed = (
+ self.audience
+ if isinstance(self.audience, list)
+ else [self.audience]
+ )
+ if aud not in allowed:
+ logger.debug(
+ "Google token 'aud' does not match expected audience"
+ )
+ return None
+
# sub is required (unique Google user ID)
sub = token_data.get("sub")
if not sub:
@@ -338,6 +358,7 @@ class GoogleProvider(OAuthProxy):
required_scopes=required_scopes_final,
timeout_seconds=timeout_seconds,
http_client=http_client,
+ audience=client_id,
)
# Set Google-specific defaults for extra authorize params
diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py
index 3deae3bcb..068185e1b 100644
--- a/tests/server/auth/providers/test_google.py
+++ b/tests/server/auth/providers/test_google.py
@@ -40,6 +40,20 @@ class TestGoogleProvider:
assert provider._upstream_client_secret.get_secret_value() == "GOCSPX-test123"
assert str(provider.base_url) == "https://myserver.com/"
+ def test_verifier_audience_pinned_to_client_id(self, memory_storage: MemoryStore):
+ """The provider's token verifier only accepts tokens minted for its own client."""
+ provider = GoogleProvider(
+ client_id="123456789.apps.googleusercontent.com",
+ client_secret="GOCSPX-test123",
+ base_url="https://myserver.com",
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ verifier = provider._token_validator
+ assert isinstance(verifier, GoogleTokenVerifier)
+ assert verifier.audience == "123456789.apps.googleusercontent.com"
+
def test_init_defaults(self, memory_storage: MemoryStore):
"""Test that default values are applied correctly."""
provider = GoogleProvider(
@@ -341,6 +355,65 @@ class TestGoogleTokenVerifier:
assert result is None
+ async def test_audience_match_accepted(self, httpx_mock: HTTPXMock):
+ """When audience is configured, a token with a matching 'aud' is accepted."""
+ httpx_mock.add_response(
+ url=_TOKENINFO_RE,
+ json={
+ "aud": "123.apps.googleusercontent.com",
+ "sub": "12345",
+ "scope": "openid",
+ "expires_in": "3600",
+ },
+ )
+ httpx_mock.add_response(url=_USERINFO_RE, json={"sub": "12345"})
+
+ verifier = GoogleTokenVerifier(audience="123.apps.googleusercontent.com")
+ result = await verifier.verify_token("valid-token")
+
+ assert result is not None
+ assert result.claims["aud"] == "123.apps.googleusercontent.com"
+
+ async def test_audience_mismatch_rejected(self, httpx_mock: HTTPXMock):
+ """A valid Google token minted for a different OAuth client is rejected."""
+ httpx_mock.add_response(
+ url=_TOKENINFO_RE,
+ json={
+ "aud": "attacker.apps.googleusercontent.com",
+ "sub": "12345",
+ "scope": "openid",
+ "expires_in": "3600",
+ },
+ )
+
+ verifier = GoogleTokenVerifier(audience="123.apps.googleusercontent.com")
+ result = await verifier.verify_token("foreign-client-token")
+
+ assert result is None
+
+ async def test_audience_list_match_accepted(self, httpx_mock: HTTPXMock):
+ """A list audience accepts any listed client ID and rejects others."""
+ httpx_mock.add_response(
+ url=_TOKENINFO_RE,
+ json={
+ "aud": "456.apps.googleusercontent.com",
+ "sub": "12345",
+ "scope": "openid",
+ "expires_in": "3600",
+ },
+ )
+ httpx_mock.add_response(url=_USERINFO_RE, json={"sub": "12345"})
+
+ verifier = GoogleTokenVerifier(
+ audience=[
+ "123.apps.googleusercontent.com",
+ "456.apps.googleusercontent.com",
+ ]
+ )
+ result = await verifier.verify_token("valid-token")
+
+ assert result is not None
+
async def test_missing_sub_returns_none(self, httpx_mock: HTTPXMock):
"""A 200 response without 'sub' is rejected."""
httpx_mock.add_response(
From 3dd0886156f3e0da19f8ec71861fbf997085cbba Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Thu, 13 Aug 2026 16:01:19 -0400
Subject: [PATCH 10/27] Fix StatefulProxyClient reconnection after session
failure (#4829)
---
.../fastmcp/server/providers/proxy.py | 10 ++++---
.../proxy/test_stateful_proxy_client.py | 27 +++++++++++++++++++
2 files changed, 33 insertions(+), 4 deletions(-)
diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py
index 931c49594..2d18f635d 100644
--- a/fastmcp_slim/fastmcp/server/providers/proxy.py
+++ b/fastmcp_slim/fastmcp/server/providers/proxy.py
@@ -1804,10 +1804,12 @@ class StatefulProxyClient(ProxyClient[ClientTransportT]):
return cast(StatefulProxyClient[ClientTransportT], super().new())
async def __aexit__(self, exc_type, exc_value, traceback) -> None: # type: ignore[override] # ty:ignore[invalid-method-override]
- """The stateful proxy client will be forced disconnected when the session is exited.
-
- So we do nothing here.
- """
+ """Release this context without disconnecting the persistent session."""
+ with anyio.CancelScope(shield=True):
+ async with self._session_state.lock:
+ self._session_state.nesting_counter = max(
+ 0, self._session_state.nesting_counter - 1
+ )
async def clear(self):
"""Clear all cached clients and force disconnect them."""
diff --git a/tests/server/providers/proxy/test_stateful_proxy_client.py b/tests/server/providers/proxy/test_stateful_proxy_client.py
index 0bf832440..19614ce91 100644
--- a/tests/server/providers/proxy/test_stateful_proxy_client.py
+++ b/tests/server/providers/proxy/test_stateful_proxy_client.py
@@ -1,4 +1,5 @@
import asyncio
+import contextlib
import weakref
from dataclasses import dataclass
from unittest.mock import MagicMock
@@ -89,6 +90,32 @@ async def stateless_server(stateful_proxy_server: FastMCP):
class TestStatefulProxyClient:
+ async def test_reconnects_after_persistent_session_ends(self):
+ """A completed request must not prevent a dead session from reconnecting."""
+ backend = FastMCP("backend")
+
+ @backend.tool
+ def echo(value: str) -> str:
+ return value
+
+ client = StatefulProxyClient(backend)
+ try:
+ async with client:
+ result = await client.call_tool("echo", {"value": "first"})
+ assert result.data == "first"
+
+ session_task = client._session_state.session_task
+ assert session_task is not None
+ session_task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await session_task
+
+ async with client:
+ result = await client.call_tool("echo", {"value": "second"})
+ assert result.data == "second"
+ finally:
+ await client.close()
+
async def test_concurrent_log_requests_no_mixing(
self, stateful_proxy_server: FastMCP
):
From fe93371d04340de524976a3514f1a5f5c186e9ee Mon Sep 17 00:00:00 2001
From: "marvin-context-protocol[bot]"
<225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Date: Thu, 13 Aug 2026 20:36:58 -0500
Subject: [PATCH 11/27] chore: Update SDK documentation (#4828)
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
---
docs/python-sdk-pages.json | 10 +
docs/python-sdk/fastmcp-resources-base.mdx | 196 +++++++++++++++
.../fastmcp-resources-function_resource.mdx | 81 +++++++
.../python-sdk/fastmcp-resources-security.mdx | 74 ++++++
.../python-sdk/fastmcp-resources-template.mdx | 224 ++++++++++++++++++
docs/python-sdk/fastmcp-resources-types.mdx | 134 +++++++++++
6 files changed, 719 insertions(+)
create mode 100644 docs/python-sdk/fastmcp-resources-base.mdx
create mode 100644 docs/python-sdk/fastmcp-resources-function_resource.mdx
create mode 100644 docs/python-sdk/fastmcp-resources-security.mdx
create mode 100644 docs/python-sdk/fastmcp-resources-template.mdx
create mode 100644 docs/python-sdk/fastmcp-resources-types.mdx
diff --git a/docs/python-sdk-pages.json b/docs/python-sdk-pages.json
index 32abc995c..b840660b3 100644
--- a/docs/python-sdk-pages.json
+++ b/docs/python-sdk-pages.json
@@ -31,6 +31,16 @@
}
]
},
+ {
+ "group": "fastmcp.resources",
+ "pages": [
+ "python-sdk/fastmcp-resources-base",
+ "python-sdk/fastmcp-resources-function_resource",
+ "python-sdk/fastmcp-resources-security",
+ "python-sdk/fastmcp-resources-template",
+ "python-sdk/fastmcp-resources-types"
+ ]
+ },
{
"group": "fastmcp.server",
"pages": [
diff --git a/docs/python-sdk/fastmcp-resources-base.mdx b/docs/python-sdk/fastmcp-resources-base.mdx
new file mode 100644
index 000000000..6df16ea9e
--- /dev/null
+++ b/docs/python-sdk/fastmcp-resources-base.mdx
@@ -0,0 +1,196 @@
+---
+title: base
+sidebarTitle: base
+---
+
+# `fastmcp.resources.base`
+
+
+Base classes and interfaces for FastMCP resources.
+
+## Functions
+
+### `convert_raw_to_resource_result`
+
+```python
+convert_raw_to_resource_result(raw_value: Any) -> ResourceResult
+```
+
+
+Wrap a user function's return value in a ResourceResult.
+
+Shared by `Resource` and `ResourceTemplate` so both honor the MIME type
+the component declares in listings. A component that advertises
+`text/csv` must not serve `text/plain` on read.
+
+**Args:**
+- `raw_value`: The value returned by the user's function.
+- `mime_type`: The component's declared MIME type, forwarded to content items.
+- `meta`: Component-level meta (e.g. `ui` metadata for MCP Apps CSP/permissions)
+propagated to each content item.
+
+
+## Classes
+
+### `ResourceContent`
+
+
+Wrapper for resource content with optional MIME type and metadata.
+
+Accepts any value for content - strings and bytes pass through directly,
+other types (dict, list, BaseModel, etc.) are automatically JSON-serialized.
+
+
+**Methods:**
+
+#### `to_mcp_resource_contents`
+
+```python
+to_mcp_resource_contents(self, uri: AnyUrl | str) -> mcp_types.TextResourceContents | mcp_types.BlobResourceContents
+```
+
+Convert to MCP resource contents type.
+
+**Args:**
+- `uri`: The URI of the resource (required by MCP types)
+
+**Returns:**
+- TextResourceContents for str content, BlobResourceContents for bytes
+
+
+### `ResourceResult`
+
+
+Canonical result type for resource reads.
+
+Provides explicit control over resource responses: multiple content items,
+per-item MIME types, and metadata at both the item and result level.
+
+
+**Methods:**
+
+#### `to_mcp_result`
+
+```python
+to_mcp_result(self, uri: AnyUrl | str) -> mcp_types.ReadResourceResult
+```
+
+Convert to MCP ReadResourceResult.
+
+**Args:**
+- `uri`: The URI of the resource (required by MCP types)
+
+**Returns:**
+- MCP ReadResourceResult with converted contents
+
+
+### `InputRequiredResourceResult`
+
+
+The full result of a single multi-round-trip resource read (SEP-2322).
+
+`InputRequiredResult` is a result type, not a `tools/call` feature: any
+request may resolve to one. When a resource or resource template returns an
+`InputRequiredResult` from its body to ask the client for input, that ask is
+the legitimate result of this `resources/read` — so FastMCP wraps it in this
+`ResourceResult` subclass, mirroring `InputRequiredToolResult` and
+`InputRequiredPromptResult`, and it flows through the middleware chain as an
+ordinary return value.
+
+Invariant: the wrapped `InputRequiredResult` is never serialized as resource
+contents. `contents` is always empty; the wire handler (`_on_read_resource`)
+reads `.input_required` and returns it to the runner.
+
+
+### `Resource`
+
+
+Base class for all resources.
+
+
+**Methods:**
+
+#### `from_function`
+
+```python
+from_function(cls, fn: Callable[..., Any], uri: str | AnyUrl) -> FunctionResource
+```
+
+#### `set_default_mime_type`
+
+```python
+set_default_mime_type(cls, mime_type: str | None) -> str
+```
+
+Set default MIME type if not provided.
+
+
+#### `set_default_name`
+
+```python
+set_default_name(self) -> Self
+```
+
+Set default name from URI if not provided.
+
+
+#### `read`
+
+```python
+read(self) -> str | bytes | ResourceResult
+```
+
+Read the resource content.
+
+Subclasses implement this to return resource data. Supported return types:
+ - str: Text content
+ - bytes: Binary content
+ - ResourceResult: Full control over contents and result-level meta
+
+
+#### `convert_result`
+
+```python
+convert_result(self, raw_value: Any) -> ResourceResult
+```
+
+Convert a raw result to ResourceResult.
+
+This is used in two contexts:
+1. In _read() to convert user function return values to ResourceResult
+2. In tasks_result_handler() to convert Docket task results to ResourceResult
+
+Handles ResourceResult passthrough and converts raw values using
+ResourceResult's normalization. When the raw value is a plain
+string or bytes, the resource's own ``mime_type`` is forwarded so
+that ``ui://`` resources (and others with non-default MIME types)
+don't fall back to ``text/plain``.
+
+The resource's component-level ``meta`` (e.g. ``ui`` metadata for
+MCP Apps CSP/permissions) is propagated to each content item so
+that hosts can read it from the ``resources/read`` response.
+
+
+#### `to_mcp_resource`
+
+```python
+to_mcp_resource(self, **overrides: Any) -> SDKResource
+```
+
+Convert the resource to an SDKResource.
+
+
+#### `key`
+
+```python
+key(self) -> str
+```
+
+The globally unique lookup key for this resource.
+
+
+#### `get_span_attributes`
+
+```python
+get_span_attributes(self) -> dict[str, Any]
+```
diff --git a/docs/python-sdk/fastmcp-resources-function_resource.mdx b/docs/python-sdk/fastmcp-resources-function_resource.mdx
new file mode 100644
index 000000000..1e34ae1a1
--- /dev/null
+++ b/docs/python-sdk/fastmcp-resources-function_resource.mdx
@@ -0,0 +1,81 @@
+---
+title: function_resource
+sidebarTitle: function_resource
+---
+
+# `fastmcp.resources.function_resource`
+
+
+Standalone @resource decorator for FastMCP.
+
+## Functions
+
+### `resource`
+
+```python
+resource(uri: str) -> Callable[[F], F]
+```
+
+
+Standalone decorator to mark a function as an MCP resource.
+
+Returns the original function with metadata attached. Register with a server
+using mcp.add_resource().
+
+
+## Classes
+
+### `DecoratedResource`
+
+
+Protocol for functions decorated with @resource.
+
+
+### `ResourceMeta`
+
+
+Metadata attached to functions by the @resource decorator.
+
+
+### `FunctionResource`
+
+
+A resource that defers data loading by wrapping a function.
+
+The function is only called when the resource is read, allowing for lazy loading
+of potentially expensive data. This is particularly useful when listing resources,
+as the function won't be called until the resource is actually accessed.
+
+The function can return:
+- str for text content (default)
+- bytes for binary content
+- other types will be converted to JSON
+
+
+**Methods:**
+
+#### `from_function`
+
+```python
+from_function(cls, fn: Callable[..., Any], uri: str | AnyUrl | None = None) -> FunctionResource
+```
+
+Create a FunctionResource from a function.
+
+**Args:**
+- `fn`: The function to wrap
+- `uri`: The URI for the resource (required if metadata not provided)
+- `metadata`: ResourceMeta object with all configuration. If provided,
+individual parameters must not be passed.
+- `name, title, etc.`: Individual parameters for backwards compatibility.
+Cannot be used together with metadata parameter.
+
+
+#### `read`
+
+```python
+read(self) -> str | bytes | ResourceResult
+```
+
+Read the resource by calling the wrapped function.
+
diff --git a/docs/python-sdk/fastmcp-resources-security.mdx b/docs/python-sdk/fastmcp-resources-security.mdx
new file mode 100644
index 000000000..453553de2
--- /dev/null
+++ b/docs/python-sdk/fastmcp-resources-security.mdx
@@ -0,0 +1,74 @@
+---
+title: security
+sidebarTitle: security
+---
+
+# `fastmcp.resources.security`
+
+
+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.
+
+
+## Classes
+
+### `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).
+
+
+### `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.
+
+
+**Methods:**
+
+#### `validate`
+
+```python
+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.
+
diff --git a/docs/python-sdk/fastmcp-resources-template.mdx b/docs/python-sdk/fastmcp-resources-template.mdx
new file mode 100644
index 000000000..8968fc441
--- /dev/null
+++ b/docs/python-sdk/fastmcp-resources-template.mdx
@@ -0,0 +1,224 @@
+---
+title: template
+sidebarTitle: template
+---
+
+# `fastmcp.resources.template`
+
+
+Resource template functionality.
+
+## Functions
+
+### `extract_query_params`
+
+```python
+extract_query_params(uri_template: str) -> set[str]
+```
+
+
+Extract query parameter names from RFC 6570 `{?param1,param2}` syntax.
+
+
+### `build_regex`
+
+```python
+build_regex(template: str) -> re.Pattern[str] | None
+```
+
+
+Build regex pattern for URI template, handling RFC 6570 syntax.
+
+Supports:
+- `{var}` - simple path parameter
+- `{var*}` - wildcard path parameter (captures multiple segments)
+- `{?var1,var2}` - query parameters (ignored in path matching)
+
+Hyphens in parameter names are normalized to underscores in regex group
+names so that matched groups are valid Python identifiers.
+
+Returns None if the template produces an invalid regex (e.g. parameter
+names with leading digits or duplicates from a remote server).
+
+
+### `match_uri_template`
+
+```python
+match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None
+```
+
+
+Match URI against template and extract both path and query parameters.
+
+Supports RFC 6570 URI templates:
+- Path params: `{var}`, `{var*}`
+- Query params: `{?var1,var2}`
+
+
+### `expand_uri_template`
+
+```python
+expand_uri_template(uri_template: str, params: dict[str, Any]) -> str
+```
+
+
+Expand a URI template with parameters — inverse of `match_uri_template`.
+
+Supports the same RFC 6570 subset:
+- Path params: `{var}`, `{var*}`
+- Query params: `{?var1,var2}`
+
+
+## Classes
+
+### `ResourceTemplate`
+
+
+A template for dynamically creating resources.
+
+
+**Methods:**
+
+#### `resolve_security`
+
+```python
+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.
+
+
+#### `from_function`
+
+```python
+from_function(fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, auth: AuthCheck | list[AuthCheck] | None = None, security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY) -> FunctionResourceTemplate
+```
+
+#### `set_default_mime_type`
+
+```python
+set_default_mime_type(cls, mime_type: str | None) -> str
+```
+
+Set default MIME type if not provided.
+
+
+#### `matches`
+
+```python
+matches(self, uri: str) -> dict[str, Any] | None
+```
+
+Check if URI matches template and extract parameters.
+
+
+#### `read`
+
+```python
+read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult
+```
+
+Read the resource content.
+
+
+#### `convert_result`
+
+```python
+convert_result(self, raw_value: Any) -> ResourceResult
+```
+
+Convert a raw result to ResourceResult.
+
+This is used in two contexts:
+1. In _read() to convert user function return values to ResourceResult
+2. In tasks_result_handler() to convert Docket task results to ResourceResult
+
+Handles ResourceResult passthrough and converts raw values using
+ResourceResult's normalization. The template's own ``mime_type`` is
+forwarded so that reads match the MIME type the template advertises
+in ``resources/templates/list``.
+
+
+#### `create_resource`
+
+```python
+create_resource(self, uri: str, params: dict[str, Any]) -> Resource
+```
+
+Create a resource from the template with the given parameters.
+
+The base implementation does not support background tasks.
+Use FunctionResourceTemplate for task support.
+
+
+#### `to_mcp_template`
+
+```python
+to_mcp_template(self, **overrides: Any) -> SDKResourceTemplate
+```
+
+Convert the resource template to an SDKResourceTemplate.
+
+
+#### `from_mcp_template`
+
+```python
+from_mcp_template(cls, mcp_template: SDKResourceTemplate) -> ResourceTemplate
+```
+
+Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object.
+
+
+#### `key`
+
+```python
+key(self) -> str
+```
+
+The globally unique lookup key for this template.
+
+
+#### `get_span_attributes`
+
+```python
+get_span_attributes(self) -> dict[str, Any]
+```
+
+### `FunctionResourceTemplate`
+
+
+A template for dynamically creating resources.
+
+
+**Methods:**
+
+#### `create_resource`
+
+```python
+create_resource(self, uri: str, params: dict[str, Any]) -> Resource
+```
+
+Create a resource from the template with the given parameters.
+
+
+#### `read`
+
+```python
+read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult
+```
+
+Read the resource content.
+
+
+#### `from_function`
+
+```python
+from_function(cls, fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, auth: AuthCheck | list[AuthCheck] | None = None, security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY) -> FunctionResourceTemplate
+```
+
+Create a template from a function.
+
diff --git a/docs/python-sdk/fastmcp-resources-types.mdx b/docs/python-sdk/fastmcp-resources-types.mdx
new file mode 100644
index 000000000..c47ce0d5a
--- /dev/null
+++ b/docs/python-sdk/fastmcp-resources-types.mdx
@@ -0,0 +1,134 @@
+---
+title: types
+sidebarTitle: types
+---
+
+# `fastmcp.resources.types`
+
+
+Concrete resource implementations.
+
+## Classes
+
+### `TextResource`
+
+
+A resource that reads from a string.
+
+
+**Methods:**
+
+#### `read`
+
+```python
+read(self) -> ResourceResult
+```
+
+Read the text content.
+
+
+### `BinaryResource`
+
+
+A resource that reads from bytes.
+
+
+**Methods:**
+
+#### `read`
+
+```python
+read(self) -> ResourceResult
+```
+
+Read the binary content.
+
+
+### `FileResource`
+
+
+A resource that reads from a file.
+
+Set is_binary=True to read file as binary data instead of text.
+
+
+**Methods:**
+
+#### `validate_absolute_path`
+
+```python
+validate_absolute_path(cls, path: Path) -> Path
+```
+
+Ensure path is absolute.
+
+
+#### `set_binary_from_mime_type`
+
+```python
+set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool
+```
+
+Set is_binary based on mime_type if not explicitly set.
+
+
+#### `read`
+
+```python
+read(self) -> ResourceResult
+```
+
+Read the file content.
+
+
+### `HttpResource`
+
+
+A resource that reads from an HTTP endpoint.
+
+
+**Methods:**
+
+#### `read`
+
+```python
+read(self) -> ResourceResult
+```
+
+Read the HTTP content.
+
+
+### `DirectoryResource`
+
+
+A resource that lists files in a directory.
+
+
+**Methods:**
+
+#### `validate_absolute_path`
+
+```python
+validate_absolute_path(cls, path: Path) -> Path
+```
+
+Ensure path is absolute.
+
+
+#### `list_files`
+
+```python
+list_files(self) -> list[Path]
+```
+
+List files in the directory.
+
+
+#### `read`
+
+```python
+read(self) -> ResourceResult
+```
+
+Read the directory listing.
+
From ba283ddb4c46789493e6fed5e2adc59c1ea76255 Mon Sep 17 00:00:00 2001
From: Chris Guidry
Date: Thu, 13 Aug 2026 23:19:17 -0400
Subject: [PATCH 12/27] Support CallArgument and Depends bindings from
uncalled-for 0.4.0 (#4802)
* Support CallArgument and Depends bindings from uncalled-for 0.4.0
uncalled-for 0.4.0 adds explicit argument references: CallArgument()
lets a dependency factory read an argument of the function it serves,
and Depends(factory, **bindings) supplies factory arguments at the
declaration site (https://github.com/chrisguidry/uncalled-for/pull/12).
FastMCP's resolver now opens a frame_scope() around dependency
resolution, with the sanitized user arguments as the frame's provided
values. A CallArgument can reference a tool call's public parameters,
but a caller-supplied value for a dependency parameter name is still
stripped before resolution. CallArgument and CycleError are re-exported
from fastmcp.dependencies, and the dependency-injection docs cover both
features.
Co-Authored-By: Claude Fable 5
* Raise the pydocket floor to 0.24.0 outside Windows
pydocket 0.24.0 resolves TaskArgument and CallArgument through
uncalled-for 0.4.0's call-scoped frames. Windows keeps the 0.20.0
floor: the burner-redis<0.1.7 pin there transitively caps pydocket to
<0.20.2, and burner-redis has shipped no fixed release yet.
Co-Authored-By: Claude Fable 5
* Bump the pydocket floor to 0.24.1 for reliable worker shutdown
docket 0.24.1 fixes a lost cancellation in worker shutdown on Python
3.10 and 3.11 (chrisguidry/docket#456): asyncio.wait_for swallowed a
cancellation delivered in the same event-loop tick that its inner future
completed, so cancelling run_forever during our lifespan teardown left
the worker running and hung the test session. That is what timed out the
Python 3.10 and lowest-direct jobs here. The floor stays platform-split;
Windows keeps >=0.20.0 under the burner-redis pin.
Co-Authored-By: Claude Fable 5
* Drop the Windows burner-redis pin and unify the pydocket floor at 0.24.1
The pin blamed the wrong package. The Windows "interpreter crash" that
motivated it (#4618) was pydocket 0.23.1 losing an external cancellation
during worker teardown; pytest-timeout's hard kill of the hung xdist
worker discarded its stdout and looked like a native fault. Capping
burner-redis also dragged pydocket below 0.20.2, so the two variables
were never separated. The repro matrix on prefectlabs/burner-redis#7
shows the July environment failing as resolved, passing with only
pydocket rolled back, and passing with pydocket 0.24.1 alongside
burner-redis 0.1.7 on Windows. pydocket 0.24.1 carries the fix
(chrisguidry/docket#456), so every platform now shares one floor.
Co-Authored-By: Claude Fable 5
---------
Co-authored-by: Claude Fable 5
Co-authored-by: nate nowack
---
docs/servers/dependency-injection.mdx | 53 ++++++
fastmcp_slim/fastmcp/dependencies.py | 4 +-
fastmcp_slim/fastmcp/server/dependencies.py | 65 ++++---
fastmcp_slim/pyproject.toml | 2 +-
fastmcp_tasks/pyproject.toml | 21 +--
pyproject.toml | 7 +-
tests/server/test_call_arguments.py | 189 ++++++++++++++++++++
uv.lock | 150 +++++++++-------
8 files changed, 380 insertions(+), 111 deletions(-)
create mode 100644 tests/server/test_call_arguments.py
diff --git a/docs/servers/dependency-injection.mdx b/docs/servers/dependency-injection.mdx
index c9ada083b..8d10b0ca0 100644
--- a/docs/servers/dependency-injection.mdx
+++ b/docs/servers/dependency-injection.mdx
@@ -430,4 +430,57 @@ async def call_api(endpoint: str, client: dict = Depends(get_api_client)) -> str
return f"Calling {client['base_url']}/{client['version']}/{endpoint}"
```
+### Call Arguments
+
+
+
+A dependency factory can read the arguments of the function it serves. Declare the reference with `CallArgument()`:
+
+```python
+from fastmcp import FastMCP
+from fastmcp.dependencies import CallArgument, Depends
+
+mcp = FastMCP("Call Arguments Demo")
+
+
+def get_account(user_id: str = CallArgument()) -> dict:
+ return {"id": user_id, "plan": "pro"}
+
+
+@mcp.tool
+async def show_account(user_id: str, account: dict = Depends(get_account)) -> str:
+ return f"{account['id']} is on {account['plan']}"
+```
+
+When a client calls `show_account`, the factory receives the same `user_id` value the tool receives. The bare form takes the name of the parameter it is declared on. `CallArgument("user_id")` names the parameter explicitly. The reference also sees a value that another dependency on the tool's signature produced. `CallArgument("tenant", optional=True)` yields `None` when the function has no such parameter. References that form a cycle raise `CycleError`, importable from `fastmcp.dependencies`.
+
+Clients still cannot override dependencies this way: an argument whose name collides with a dependency parameter is stripped before resolution, so a `CallArgument` reference to that parameter resolves the dependency itself.
+
+### Bindings
+
+
+
+`Depends()` accepts keyword bindings, so you can wire up a factory without changing it:
+
+```python
+from fastmcp import FastMCP
+from fastmcp.dependencies import CallArgument, Depends
+
+mcp = FastMCP("Bindings Demo")
+
+
+def get_account(user_id: str) -> dict:
+ return {"id": user_id, "plan": "pro"}
+
+
+@mcp.tool
+async def show_account(
+ owner: str,
+ account: dict = Depends(get_account, user_id=CallArgument("owner")),
+) -> str:
+ return f"{account['id']} is on {account['plan']}"
+```
+
+A binding that is a `Dependency`, such as `CallArgument(...)` or another `Depends(...)`, resolves first and the factory receives its value. Any other value passes through as it is. A binding replaces the default of the factory's own parameter, which is then never resolved. Two dependencies on the same factory share one cached result only when their bindings match. See the [Docket dependency documentation](https://docket.lol/en/latest/dependency-injection/) for more detail on call arguments and bindings.
+
For advanced dependency patterns—like `TaskArgument()` for accessing task parameters, or custom `Dependency` subclasses—see the [Docket dependency documentation](https://chrisguidry.github.io/docket/dependencies/).
diff --git a/fastmcp_slim/fastmcp/dependencies.py b/fastmcp_slim/fastmcp/dependencies.py
index 138486f88..2f0ac00eb 100644
--- a/fastmcp_slim/fastmcp/dependencies.py
+++ b/fastmcp_slim/fastmcp/dependencies.py
@@ -11,7 +11,7 @@ using the uncalled-for DI engine. The docket-specific dependencies
from typing import Any
-from uncalled_for import Dependency, Depends, Shared
+from uncalled_for import CallArgument, CycleError, Dependency, Depends, Shared
from fastmcp.server.dependencies import (
CurrentAccessToken,
@@ -25,11 +25,13 @@ from fastmcp.server.dependencies import (
)
__all__ = [
+ "CallArgument",
"CurrentAccessToken",
"CurrentContext",
"CurrentFastMCP",
"CurrentHeaders",
"CurrentRequest",
+ "CycleError",
"Dependency",
"Depends",
"Progress",
diff --git a/fastmcp_slim/fastmcp/server/dependencies.py b/fastmcp_slim/fastmcp/server/dependencies.py
index 32d8ba183..a6f06778b 100644
--- a/fastmcp_slim/fastmcp/server/dependencies.py
+++ b/fastmcp_slim/fastmcp/server/dependencies.py
@@ -30,7 +30,12 @@ from mcp.server.context import ServerRequestContext
from mcp.server.session import ServerSession
from packaging.version import Version
from starlette.requests import Request
-from uncalled_for import Dependency, get_dependency_parameters
+from uncalled_for import (
+ CycleError,
+ Dependency,
+ frame_scope,
+ get_dependency_parameters,
+)
from uncalled_for.resolution import _Depends
from fastmcp.exceptions import FastMCPError
@@ -751,11 +756,12 @@ def without_injected_parameters(
async def _resolve_fastmcp_dependencies(
fn: Callable[..., Any], arguments: dict[str, Any]
) -> AsyncGenerator[dict[str, Any], None]:
- """Resolve Docket dependencies for a FastMCP function.
+ """Resolve uncalled-for dependencies for a FastMCP function.
- Sets up the minimal context needed for Docket's Depends() to work:
+ Sets up the context that uncalled-for's Depends() needs:
- A cache for resolved dependencies
- An AsyncExitStack for managing context manager lifetimes
+ - A resolution frame, so CallArgument() can read the call's arguments
The Docket instance (for CurrentDocket dependency) is managed separately
by the server's lifespan and made available via ContextVar.
@@ -783,33 +789,35 @@ async def _resolve_fastmcp_dependencies(
async with AsyncExitStack() as stack:
stack_token = _Depends.stack.set(stack)
try:
- resolved: dict[str, Any] = {}
+ # The frame memoizes each parameter per call, so a
+ # CallArgument() that references a sibling dependency gets
+ # the same value the function receives for it.
+ with frame_scope(fn, arguments) as frame:
+ resolved: dict[str, Any] = {}
- for parameter, dependency in dependency_params.items():
- # If argument was explicitly provided, use that instead
- if parameter in arguments:
- resolved[parameter] = arguments[parameter]
- continue
+ for parameter in dependency_params:
+ # Resolve the dependency. The frame returns an
+ # explicitly provided argument as-is.
+ try:
+ resolved[parameter] = await frame.resolve(parameter)
+ except (FastMCPError, CycleError):
+ # Let FastMCPError subclasses (ToolError,
+ # ResourceError, etc.) propagate unchanged so they
+ # can be handled appropriately. CycleError already
+ # names the cyclic reference path, so wrapping it
+ # would only hide that.
+ raise
+ except Exception as error:
+ fn_name = getattr(fn, "__name__", repr(fn))
+ raise RuntimeError(
+ f"Failed to resolve dependency '{parameter}' "
+ f"for {fn_name}"
+ ) from error
- # Resolve the dependency
- try:
- resolved[parameter] = await stack.enter_async_context(
- dependency
- )
- except FastMCPError:
- # Let FastMCPError subclasses (ToolError, ResourceError, etc.)
- # propagate unchanged so they can be handled appropriately
- raise
- except Exception as error:
- fn_name = getattr(fn, "__name__", repr(fn))
- raise RuntimeError(
- f"Failed to resolve dependency '{parameter}' for {fn_name}"
- ) from error
+ # Merge resolved dependencies with provided arguments
+ final_arguments = {**arguments, **resolved}
- # Merge resolved dependencies with provided arguments
- final_arguments = {**arguments, **resolved}
-
- yield final_arguments
+ yield final_arguments
finally:
_Depends.stack.reset(stack_token)
finally:
@@ -828,6 +836,9 @@ async def resolve_dependencies(
The filtering prevents external callers from overriding injected parameters by
providing values for dependency parameter names. This is a security feature.
+ The filtered arguments also feed the resolution frame, so a CallArgument()
+ reference to a dependency parameter resolves the dependency and never a
+ caller-supplied value.
Note: Context injection is handled via transform_context_annotations() which
converts `ctx: Context` to `ctx: Context = Depends(get_context)` at registration
diff --git a/fastmcp_slim/pyproject.toml b/fastmcp_slim/pyproject.toml
index 6d56f3e93..ea6b7b6ac 100644
--- a/fastmcp_slim/pyproject.toml
+++ b/fastmcp_slim/pyproject.toml
@@ -103,7 +103,7 @@ server = [
"pyperclip>=1.9.0",
"python-multipart>=0.0.26",
"pyyaml>=6.0,<7.0",
- "uncalled-for>=0.2.0",
+ "uncalled-for>=0.4.0",
"uvicorn>=0.35",
"watchfiles>=1.0.0",
"websockets>=15.0.1",
diff --git a/fastmcp_tasks/pyproject.toml b/fastmcp_tasks/pyproject.toml
index 6a1de018f..04353f8b3 100644
--- a/fastmcp_tasks/pyproject.toml
+++ b/fastmcp_tasks/pyproject.toml
@@ -56,16 +56,13 @@ dependencies = [
# Fernet and the PBKDF2 key derivation behind FASTMCP_TASKS_ENCRYPTION_KEY,
# which encrypts task context snapshots at rest.
"cryptography>=43.0.0",
- "pydocket>=0.20.0",
- # burner-redis 0.1.7's Windows build crashes the interpreter (native fault,
- # no Python traceback) running the memory:// backend under pytest-xdist —
- # reproduced on GitHub Actions windows-latest, confirmed absent on
- # macOS/Linux with the same versions (full suite green there under the
- # identical upgraded dependencies). pydocket only floors it at >=0.1.6, so
- # capping pydocket alone is not enough: a resolver is free to pick the
- # newest burner-redis satisfying that floor regardless. Pin burner-redis
- # directly on Windows only (which in turn caps pydocket to <0.20.2 there,
- # the last release that doesn't itself require burner-redis>=0.1.7) until
- # upstream ships a fix — other platforms are unaffected and stay unpinned.
- "burner-redis<0.1.7; sys_platform == 'win32'",
+ # pydocket 0.24.1 resolves CallArgument references through uncalled-for's
+ # call-scoped frames and shuts its worker down reliably when run_forever
+ # is cancelled on Python 3.10 and 3.11, which our lifespan does on every
+ # server shutdown. Without that fix a worker cancelled during teardown
+ # hangs; on Windows, pytest-timeout's hard kill of the hung xdist worker
+ # was misread as a burner-redis 0.1.7 interpreter crash, which is why a
+ # burner-redis pin and a platform-split floor used to live here
+ # (prefectlabs/burner-redis#7 has the exoneration).
+ "pydocket>=0.24.1",
]
diff --git a/pyproject.toml b/pyproject.toml
index 27b3596b0..f1badb7c2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -73,10 +73,11 @@ members = ["fastmcp_slim", "fastmcp_remote", "fastmcp_tasks"]
default-groups = ["dev"]
exclude-newer = "1 week"
# The cooldown above refuses anything published in the last week. Exempt the
-# first-party packages, whose fresh releases we install deliberately, and the
+# first-party packages, whose fresh releases we install deliberately, the
# MCP SDK, where a new major is the only version satisfying our floor and so
-# has nothing older to fall back to.
-exclude-newer-package = { fastmcp = false, fastmcp-slim = false, fastmcp-remote = false, prefab-ui = false, mcp = false, mcp-types = false }
+# has nothing older to fall back to, and uncalled-for, the DI engine whose
+# releases we adopt deliberately.
+exclude-newer-package = { fastmcp = false, fastmcp-slim = false, fastmcp-remote = false, prefab-ui = false, mcp = false, mcp-types = false, uncalled-for = false, pydocket = false }
[dependency-groups]
dev = [
diff --git a/tests/server/test_call_arguments.py b/tests/server/test_call_arguments.py
new file mode 100644
index 000000000..6b1af08cb
--- /dev/null
+++ b/tests/server/test_call_arguments.py
@@ -0,0 +1,189 @@
+"""Tests for CallArgument and Depends bindings through FastMCP's resolution."""
+
+import pytest
+from mcp_types import TextContent
+
+from fastmcp import FastMCP
+from fastmcp.dependencies import CallArgument, CycleError, Depends
+from fastmcp.server.dependencies import resolve_dependencies
+
+
+@pytest.fixture
+def mcp():
+ """Create a FastMCP server for testing."""
+ return FastMCP("test-server")
+
+
+async def test_bare_call_argument_reads_tool_parameter(mcp: FastMCP):
+ """A bare CallArgument takes the value of the same-named tool parameter."""
+
+ def get_greeting(name: str = CallArgument()) -> str:
+ return f"Hello, {name}!"
+
+ @mcp.tool()
+ async def greet(name: str, greeting: str = Depends(get_greeting)) -> str:
+ return greeting
+
+ result = await mcp.call_tool("greet", {"name": "Alice"})
+ assert result.structured_content is not None
+ assert result.structured_content["result"] == "Hello, Alice!"
+
+
+async def test_named_call_argument_reads_tool_parameter(mcp: FastMCP):
+ """CallArgument("name") reads a tool parameter with a different name."""
+
+ def get_greeting(who: str = CallArgument("name")) -> str:
+ return f"Hello, {who}!"
+
+ @mcp.tool()
+ async def greet(name: str, greeting: str = Depends(get_greeting)) -> str:
+ return greeting
+
+ result = await mcp.call_tool("greet", {"name": "Bob"})
+ assert result.structured_content is not None
+ assert result.structured_content["result"] == "Hello, Bob!"
+
+
+async def test_call_argument_in_binding(mcp: FastMCP):
+ """A CallArgument binding wires a tool parameter to a factory parameter."""
+
+ def get_account(user_id: str) -> dict[str, str]:
+ return {"id": user_id, "plan": "pro"}
+
+ @mcp.tool()
+ async def show_account(
+ owner: str,
+ account: dict[str, str] = Depends(get_account, user_id=CallArgument("owner")),
+ ) -> str:
+ return f"{account['id']}:{account['plan']}"
+
+ result = await mcp.call_tool("show_account", {"owner": "alice"})
+ assert result.structured_content is not None
+ assert result.structured_content["result"] == "alice:pro"
+
+
+async def test_plain_value_binding(mcp: FastMCP):
+ """A binding that is not a Dependency passes through to the factory as-is."""
+
+ def get_url(scheme: str) -> str:
+ return f"{scheme}://example.com"
+
+ @mcp.tool()
+ async def fetch(path: str, url: str = Depends(get_url, scheme="https")) -> str:
+ return f"{url}/{path}"
+
+ result = await mcp.call_tool("fetch", {"path": "docs"})
+ assert result.structured_content is not None
+ assert result.structured_content["result"] == "https://example.com/docs"
+
+
+async def test_binding_replaces_factory_depends_default(mcp: FastMCP):
+ """A binding replaces the factory's own Depends default, which never runs."""
+
+ default_calls = 0
+
+ def get_default_region() -> str:
+ nonlocal default_calls
+ default_calls += 1
+ return "us-east-1"
+
+ def get_bucket(region: str = Depends(get_default_region)) -> str:
+ return f"bucket-{region}"
+
+ @mcp.tool()
+ async def store(
+ data: str, bucket: str = Depends(get_bucket, region="eu-west-1")
+ ) -> str:
+ return bucket
+
+ result = await mcp.call_tool("store", {"data": "payload"})
+ assert result.structured_content is not None
+ assert result.structured_content["result"] == "bucket-eu-west-1"
+ assert default_calls == 0
+
+
+async def test_optional_call_argument_yields_none(mcp: FastMCP):
+ """CallArgument(optional=True) yields None for a name the tool lacks."""
+
+ def get_note(tenant: str | None = CallArgument("tenant", optional=True)) -> str:
+ return f"tenant={tenant}"
+
+ @mcp.tool()
+ async def report(topic: str, note: str = Depends(get_note)) -> str:
+ return note
+
+ result = await mcp.call_tool("report", {"topic": "sales"})
+ assert result.structured_content is not None
+ assert result.structured_content["result"] == "tenant=None"
+
+
+async def test_sibling_dependency_resolves_once(mcp: FastMCP):
+ """A CallArgument reference to a dependency-backed sibling shares one value."""
+
+ session_calls = 0
+
+ def get_session() -> str:
+ nonlocal session_calls
+ session_calls += 1
+ return "session-1"
+
+ def audit(session: str = CallArgument()) -> str:
+ return f"audit:{session}"
+
+ @mcp.tool()
+ async def act(
+ step: str,
+ session: str = Depends(get_session),
+ log: str = Depends(audit),
+ ) -> str:
+ return f"{log}|{session}"
+
+ result = await mcp.call_tool("act", {"step": "one"})
+ assert result.structured_content is not None
+ assert result.structured_content["result"] == "audit:session-1|session-1"
+ assert session_calls == 1
+
+
+async def test_call_argument_cycle_raises_cycle_error():
+ """CallArgument references that form a cycle raise CycleError with the path."""
+
+ def get_a(b: str = CallArgument()) -> str:
+ return b
+
+ def get_b(a: str = CallArgument()) -> str:
+ return a
+
+ async def entangled(a: str = Depends(get_a), b: str = Depends(get_b)) -> str:
+ return f"{a}{b}"
+
+ with pytest.raises(CycleError, match="a -> b -> a"):
+ async with resolve_dependencies(entangled, {}):
+ pass
+
+
+async def test_colliding_argument_never_reaches_call_argument(mcp: FastMCP):
+ """A caller-supplied value for a dependency parameter name is stripped.
+
+ A CallArgument that references the dependency parameter resolves the
+ dependency itself, never the caller's value.
+ """
+
+ def get_role() -> str:
+ return "user"
+
+ def describe(role: str = CallArgument()) -> str:
+ return f"role={role}"
+
+ @mcp.prompt()
+ async def status(
+ topic: str,
+ role: str = Depends(get_role),
+ summary: str = Depends(describe),
+ ) -> str:
+ return f"{topic}: {summary}"
+
+ result = await mcp.render_prompt("status", {"topic": "audit", "role": "admin"})
+ content = result.messages[0].content
+ assert isinstance(content, TextContent)
+ assert "role=user" in content.text
+ assert "admin" not in content.text
diff --git a/uv.lock b/uv.lock
index a3d4ce109..c12f61615 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,20 +2,26 @@ version = 1
revision = 3
requires-python = ">=3.10"
resolution-markers = [
- "python_full_version >= '3.14'",
- "python_full_version == '3.13.*'",
- "python_full_version >= '3.11' and python_full_version < '3.13'",
- "python_full_version < '3.11'",
+ "python_full_version >= '3.14' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and sys_platform != 'win32'",
+ "python_full_version == '3.13.*' and sys_platform == 'win32'",
+ "python_full_version == '3.13.*' and sys_platform != 'win32'",
+ "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
+ "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'win32'",
+ "python_full_version < '3.11' and sys_platform == 'win32'",
+ "python_full_version < '3.11' and sys_platform != 'win32'",
]
[options]
-exclude-newer = "2026-07-21T14:39:05.08339Z"
+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
-fastmcp = false
prefab-ui = false
+pydocket = false
+uncalled-for = false
+fastmcp = false
mcp = false
fastmcp-remote = false
fastmcp-slim = false
@@ -33,10 +39,11 @@ name = "aiofile"
version = "3.9.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.11'",
+ "python_full_version < '3.11' and sys_platform == 'win32'",
+ "python_full_version < '3.11' and sys_platform != 'win32'",
]
dependencies = [
- { name = "caio", marker = "python_full_version < '3.11'" },
+ { name = "caio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" }
wheels = [
@@ -48,12 +55,15 @@ name = "aiofile"
version = "3.11.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version >= '3.14'",
- "python_full_version == '3.13.*'",
- "python_full_version >= '3.11' and python_full_version < '3.13'",
+ "python_full_version >= '3.14' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and sys_platform != 'win32'",
+ "python_full_version == '3.13.*' and sys_platform == 'win32'",
+ "python_full_version == '3.13.*' and sys_platform != 'win32'",
+ "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
+ "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'win32'",
]
dependencies = [
- { name = "caio", marker = "python_full_version >= '3.11'" },
+ { name = "caio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" }
wheels = [
@@ -253,18 +263,18 @@ wheels = [
[[package]]
name = "burner-redis"
-version = "0.1.6"
+version = "0.1.7"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c8/6f/ec3eeb9e3e9d7fedc51fcb56dd09da0f164495ab6fdf4caaa3754ceed659/burner_redis-0.1.6.tar.gz", hash = "sha256:362091d98c09953ef99be8bd026d75fad42599a0f153211e1a22d3e3029c7cfb", size = 843118, upload-time = "2026-04-27T17:11:41.879Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/52/89/54706febafc135095b2a9d797cfbd4eed2ab1ad7819808b99b587020471b/burner_redis-0.1.7.tar.gz", hash = "sha256:7474ff092669fd11ef765411572cdafcc3d89b8054aef4ca0617be6d6be4c680", size = 638644, upload-time = "2026-05-08T15:01:42.961Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d6/cc/061897380b88c637e4bea1f6715ffba851d10b16d6610f2832ab61fa15b5/burner_redis-0.1.6-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:5dc9c170b9994b8d57958041857f240d1b0b9ac1559d0d35473f03fb62386dea", size = 1275400, upload-time = "2026-04-27T17:11:27.07Z" },
- { url = "https://files.pythonhosted.org/packages/db/24/e4c6fb37d059b268c2a26b173d3f84b49547e837340983d3d018c08191c6/burner_redis-0.1.6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f29caae7f80fea2e47350df24264a049aeaa934454210cddcadc2336ee8b423a", size = 1223570, upload-time = "2026-04-27T17:11:28.782Z" },
- { url = "https://files.pythonhosted.org/packages/2e/be/718af7f42bbebbfbfd771ba43697526c922dff54d1b0d62654e21418b25e/burner_redis-0.1.6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ce82edea4ed1ec34448a8610c62665517b3d2030254f31af32828b070a92a8", size = 1325624, upload-time = "2026-04-27T17:11:30.648Z" },
- { url = "https://files.pythonhosted.org/packages/06/8a/4f72de7f967532d3739caa461625dc9122f0ca0d46faa883c153a10d0117/burner_redis-0.1.6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e3dff7d691ab0035468c17f51632e452b075065a30e026278ed1b297441ce93", size = 1356531, upload-time = "2026-04-27T17:11:32.201Z" },
- { url = "https://files.pythonhosted.org/packages/bd/22/369338d6372abd12dee51965566428c06f3badd95f668ff1c11680b99b30/burner_redis-0.1.6-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3b43f983b6e8fbc208734f04b0bae8cf95323fc43105f977d20f0993fc28c1b3", size = 1526049, upload-time = "2026-04-27T17:11:33.93Z" },
- { url = "https://files.pythonhosted.org/packages/b3/23/0651cf86bc5ed390fef09e30ff4a4664cc3c5b88ddf4c6b8d905acc0d60e/burner_redis-0.1.6-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b6ba097d910effff00a4610160a4a759503ad590e2c47a580bdcdac9a325823", size = 1579068, upload-time = "2026-04-27T17:11:35.964Z" },
- { url = "https://files.pythonhosted.org/packages/a0/8c/302638fdad4476d4760d477b0f3c6b96c0f88d3f278e5f14d06eb048f788/burner_redis-0.1.6-cp310-abi3-win_amd64.whl", hash = "sha256:98c6b6fc397617cd5a6778ac020e4ed9985c393ad204f9f5cf524b68ae16070b", size = 1103735, upload-time = "2026-04-27T17:11:38.38Z" },
- { url = "https://files.pythonhosted.org/packages/c0/ba/18668d92e18210150f7f93e2930264ad77a82e4d3e5f74ca1aecc002f78f/burner_redis-0.1.6-cp310-abi3-win_arm64.whl", hash = "sha256:c2583e98f9a3836ac2c6243ea0c8d56b40e7017b46617991e329bc807c544bad", size = 1029386, upload-time = "2026-04-27T17:11:40.266Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/5d/198bd1d22e504b3034353430703afbdb3efe6e25cb90bf52d896e1d266a7/burner_redis-0.1.7-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f80c866996e0455d584eb3c0f3b067e411c632fb0519eab454e0968edf01e62c", size = 1288888, upload-time = "2026-05-08T15:01:26.103Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/4e/ce5c91b884ac37fcd380756402536f8810964014097950900517ce8bd30c/burner_redis-0.1.7-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a3d9569a376b690fb5876d454e4904443332dc3ad5c0057e149fc2ad220bf599", size = 1234282, upload-time = "2026-05-08T15:01:28.286Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/c0/31c25cc88143eac2dddcc394151a0db627923d44c94376a83768552c9f13/burner_redis-0.1.7-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20eba1917e3bca9eea5957d5700ff8defcb5a209e57a7841d005549aa0151f44", size = 1337341, upload-time = "2026-05-08T15:01:30.397Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/32/95cfa1833316ca2b6b2e58150a4900bc1ad256043cdd36198f1887618ccc/burner_redis-0.1.7-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39111467059b8a28f15ea061d2414ec25c3e57c65759983f90f4d358e7d6a72d", size = 1366800, upload-time = "2026-05-08T15:01:32.891Z" },
+ { url = "https://files.pythonhosted.org/packages/34/ad/93c3916f053f89b7b5760da5bf855cd78b7885d480f9cfcc64f3732c1dc2/burner_redis-0.1.7-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9b5adfe99aeb8407f468078f3769b2a63e9168fea12f7709df5d2a3b152706e4", size = 1538160, upload-time = "2026-05-08T15:01:34.667Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/b9/19bae42cb124932d71168bc8e5bcb1da33aa62b908e5e632b3d298d7cb15/burner_redis-0.1.7-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:591a9d20685f9d6d22bf0c863b50b12dfcf328b06111b3f62c33cd3185d48ce0", size = 1591491, upload-time = "2026-05-08T15:01:36.708Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/30/207f47f406619a5b564355d2946c3171f84231a28b800709b5645b06a5ae/burner_redis-0.1.7-cp310-abi3-win_amd64.whl", hash = "sha256:f6cf4ac666766b32fd63940aad0c120847905fd3102c17e5b6b305f91a21d079", size = 1117564, upload-time = "2026-05-08T15:01:39.221Z" },
+ { url = "https://files.pythonhosted.org/packages/76/6f/e9beaf46c5e9fd10dfcdb889ebf7d3aa85142c650c0ab17ab284194f58e1/burner_redis-0.1.7-cp310-abi3-win_arm64.whl", hash = "sha256:458f88feeddfb40a586cc3fcbd8e9384bbdfd2a4512a695af4900e06052570d4", size = 1040407, upload-time = "2026-05-08T15:01:41.235Z" },
]
[[package]]
@@ -1077,7 +1087,7 @@ requires-dist = [
{ name = "starlette", marker = "extra == 'mcp'", specifier = ">=1.0.1" },
{ name = "starlette", marker = "extra == 'server'", specifier = ">=1.0.1" },
{ name = "typing-extensions", specifier = ">=4.0.0" },
- { name = "uncalled-for", marker = "extra == 'server'", specifier = ">=0.2.0" },
+ { name = "uncalled-for", marker = "extra == 'server'", specifier = ">=0.4.0" },
{ name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.35" },
{ name = "watchfiles", marker = "extra == 'server'", specifier = ">=1.0.0" },
{ name = "websockets", marker = "extra == 'server'", specifier = ">=15.0.1" },
@@ -1088,7 +1098,6 @@ provides-extras = ["anthropic", "apps", "azure", "client", "code-mode", "gemini"
name = "fastmcp-tasks"
source = { editable = "fastmcp_tasks" }
dependencies = [
- { name = "burner-redis", marker = "sys_platform == 'win32'" },
{ name = "cryptography" },
{ name = "fastmcp-slim", extra = ["server"] },
{ name = "pydocket" },
@@ -1096,10 +1105,9 @@ dependencies = [
[package.metadata]
requires-dist = [
- { name = "burner-redis", marker = "sys_platform == 'win32'", specifier = "<0.1.7" },
{ name = "cryptography", specifier = ">=43.0.0" },
{ name = "fastmcp-slim", extras = ["server"], editable = "fastmcp_slim" },
- { name = "pydocket", specifier = ">=0.20.0" },
+ { name = "pydocket", specifier = ">=0.24.1" },
]
[[package]]
@@ -1303,7 +1311,7 @@ name = "importlib-metadata"
version = "9.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "zipp", marker = "python_full_version < '3.13'" },
+ { name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
wheels = [
@@ -1346,20 +1354,21 @@ name = "ipython"
version = "8.39.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.11'",
+ "python_full_version < '3.11' and sys_platform == 'win32'",
+ "python_full_version < '3.11' and sys_platform != 'win32'",
]
dependencies = [
- { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
- { name = "decorator", marker = "python_full_version < '3.11'" },
- { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
- { name = "jedi", marker = "python_full_version < '3.11'" },
- { name = "matplotlib-inline", marker = "python_full_version < '3.11'" },
- { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
- { name = "prompt-toolkit", marker = "python_full_version < '3.11'" },
- { name = "pygments", marker = "python_full_version < '3.11'" },
- { name = "stack-data", marker = "python_full_version < '3.11'" },
- { name = "traitlets", marker = "python_full_version < '3.11'" },
- { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "decorator" },
+ { name = "exceptiongroup" },
+ { name = "jedi" },
+ { name = "matplotlib-inline" },
+ { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit" },
+ { name = "pygments" },
+ { name = "stack-data" },
+ { name = "traitlets" },
+ { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" }
wheels = [
@@ -1371,23 +1380,26 @@ name = "ipython"
version = "9.15.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version >= '3.14'",
- "python_full_version == '3.13.*'",
- "python_full_version >= '3.11' and python_full_version < '3.13'",
+ "python_full_version >= '3.14' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and sys_platform != 'win32'",
+ "python_full_version == '3.13.*' and sys_platform == 'win32'",
+ "python_full_version == '3.13.*' and sys_platform != 'win32'",
+ "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
+ "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'win32'",
]
dependencies = [
- { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" },
- { name = "decorator", marker = "python_full_version >= '3.11'" },
- { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" },
- { name = "jedi", marker = "python_full_version >= '3.11'" },
- { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" },
- { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
- { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" },
- { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" },
- { name = "pygments", marker = "python_full_version >= '3.11'" },
- { name = "stack-data", marker = "python_full_version >= '3.11'" },
- { name = "traitlets", marker = "python_full_version >= '3.11'" },
- { name = "typing-extensions", marker = "python_full_version == '3.11.*'" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "decorator" },
+ { name = "ipython-pygments-lexers" },
+ { name = "jedi" },
+ { name = "matplotlib-inline" },
+ { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit" },
+ { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" },
+ { name = "pygments" },
+ { name = "stack-data" },
+ { name = "traitlets" },
+ { name = "typing-extensions", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" }
wheels = [
@@ -1399,7 +1411,7 @@ name = "ipython-pygments-lexers"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pygments", marker = "python_full_version >= '3.11'" },
+ { name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
wheels = [
@@ -2367,7 +2379,7 @@ wheels = [
[[package]]
name = "pydocket"
-version = "0.20.1"
+version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "burner-redis" },
@@ -2386,9 +2398,9 @@ dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "uncalled-for" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/72/bf/7f1134e990855f373e5ee6ba316db8fe654a2d7dd852b41ab890fcfb91e3/pydocket-0.20.1.tar.gz", hash = "sha256:d72b3784e4b5069b39e5f49f599d54a891e1b6222c27a8bcfbd4dee0f57d4895", size = 361993, upload-time = "2026-05-06T14:06:25.956Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b7/6b/a87c6e3fd197807f630af4270aa2ce8f4c1fca4e43bba783f273d298d646/pydocket-0.24.1.tar.gz", hash = "sha256:477d77be1fcfd10ee0c2d0b8aa8c6e97851b9c7f39bb6f2b4e6d42e9b4d6e95a", size = 430759, upload-time = "2026-08-10T19:50:03.368Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9d/9d/1bd873a0ea480dec388c40ac1a7500c129efbb9d61e2fef6b97236703458/pydocket-0.20.1-py3-none-any.whl", hash = "sha256:c886ece90ac93018f069d1eef9443f888404081d7258955e16847752575c95ae", size = 102774, upload-time = "2026-05-06T14:06:24.548Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/05/4e3b902bc0ca407188aa5fe38af49be634487aec242a77287103242e11b2/pydocket-0.24.1-py3-none-any.whl", hash = "sha256:1faa6c3d566f1f0431e35dfa12db4ccee515d945b913b516ee3e6279afb9e789", size = 130249, upload-time = "2026-08-10T19:50:01.627Z" },
]
[[package]]
@@ -2869,7 +2881,8 @@ name = "rpds-py"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.11'",
+ "python_full_version < '3.11' and sys_platform == 'win32'",
+ "python_full_version < '3.11' and sys_platform != 'win32'",
]
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
wheels = [
@@ -2994,9 +3007,12 @@ name = "rpds-py"
version = "2026.6.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version >= '3.14'",
- "python_full_version == '3.13.*'",
- "python_full_version >= '3.11' and python_full_version < '3.13'",
+ "python_full_version >= '3.14' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and sys_platform != 'win32'",
+ "python_full_version == '3.13.*' and sys_platform == 'win32'",
+ "python_full_version == '3.13.*' and sys_platform != 'win32'",
+ "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32'",
+ "python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'win32'",
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
wheels = [
@@ -3218,8 +3234,8 @@ name = "taskgroup"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
- { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+ { name = "exceptiongroup" },
+ { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" }
wheels = [
@@ -3391,11 +3407,11 @@ wheels = [
[[package]]
name = "uncalled-for"
-version = "0.3.2"
+version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/5a/92ce0b3ea5481915f55da994c2c2c5f7a3c09949afde196ee89f8ab961aa/uncalled_for-0.4.0.tar.gz", hash = "sha256:335b95bd2422332ec210d518f314a16e4c640921c39fc8bf2ad095bd3538f4af", size = 56979, upload-time = "2026-08-10T14:51:46.247Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/40/97cec87c077eb3291fc7905e6633e08b7ca593c57d30238444bcb6bb3d53/uncalled_for-0.4.0-py3-none-any.whl", hash = "sha256:16c4bb3337532e4bd5569adc192285976f3ad5305402256d34c67a12b5c968bd", size = 15502, upload-time = "2026-08-10T14:51:45.068Z" },
]
[[package]]
From 38c054be1d888105db4cdf62e0fb023e3856e923 Mon Sep 17 00:00:00 2001
From: nate nowack
Date: Fri, 14 Aug 2026 12:09:44 -0500
Subject: [PATCH 13/27] Fix static analysis under newer ty releases (#4831)
Co-authored-by: Claude Fable 5
---
fastmcp_slim/fastmcp/client/roots.py | 3 +-
.../fastmcp/server/providers/proxy.py | 9 +++--
fastmcp_slim/fastmcp/utilities/json_schema.py | 6 +--
pyproject.toml | 2 +-
uv.lock | 40 +++++++++----------
5 files changed, 31 insertions(+), 29 deletions(-)
diff --git a/fastmcp_slim/fastmcp/client/roots.py b/fastmcp_slim/fastmcp/client/roots.py
index 7db4b9d60..93a86cb2c 100644
--- a/fastmcp_slim/fastmcp/client/roots.py
+++ b/fastmcp_slim/fastmcp/client/roots.py
@@ -35,8 +35,7 @@ def create_roots_callback(
handler: RootsList | RootsHandler,
) -> ListRootsFnT:
if isinstance(handler, list):
- # TODO(ty): remove when ty supports isinstance union narrowing
- return _create_roots_callback_from_roots(handler) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
+ return _create_roots_callback_from_roots(handler)
elif callable(handler):
return _create_roots_callback_from_fn(handler)
else:
diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py
index 2d18f635d..c6b9ef367 100644
--- a/fastmcp_slim/fastmcp/server/providers/proxy.py
+++ b/fastmcp_slim/fastmcp/server/providers/proxy.py
@@ -14,7 +14,7 @@ import warnings
from collections.abc import Awaitable, Callable, Sequence
from copy import deepcopy
from dataclasses import dataclass, replace
-from typing import TYPE_CHECKING, Any, Literal, cast
+from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast
import anyio
import httpx2
@@ -819,12 +819,15 @@ class ProxyPrompt(Prompt):
# -----------------------------------------------------------------------------
-class _CacheEntry:
+_ComponentT = TypeVar("_ComponentT")
+
+
+class _CacheEntry(Generic[_ComponentT]):
"""A cached sequence of components with a monotonic timestamp."""
__slots__ = ("items", "timestamp")
- def __init__(self, items: Sequence[Any], timestamp: float):
+ def __init__(self, items: Sequence[_ComponentT], timestamp: float):
self.items = items
self.timestamp = timestamp
diff --git a/fastmcp_slim/fastmcp/utilities/json_schema.py b/fastmcp_slim/fastmcp/utilities/json_schema.py
index 533a4a7bf..5bc83d184 100644
--- a/fastmcp_slim/fastmcp/utilities/json_schema.py
+++ b/fastmcp_slim/fastmcp/utilities/json_schema.py
@@ -610,19 +610,19 @@ def _single_pass_optimize(
if (
prune_titles
and "title" in node
- and isinstance(node["title"], str) # type: ignore
+ and isinstance(node["title"], str)
and (
any(k in node for k in _SCHEMA_KEYWORDS)
or all(k in _METADATA_KEYS for k in node)
)
):
- node.pop("title") # type: ignore
+ node.pop("title")
if (
prune_additional_properties
and node.get("additionalProperties") is False
):
- node.pop("additionalProperties") # type: ignore
+ node.pop("additionalProperties")
# Recursive traversal
for key, value in node.items():
diff --git a/pyproject.toml b/pyproject.toml
index f1badb7c2..ae4d56eb3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -102,7 +102,7 @@ dev = [
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.6.1",
"ruff>=0.12.8",
- "ty>=0.0.59",
+ "ty>=0.0.69",
"prek>=0.2.12",
"loq>=0.1.0a3",
"opentelemetry-exporter-otlp-proto-grpc>=1.39.0",
diff --git a/uv.lock b/uv.lock
index c12f61615..b18e8a3df 100644
--- a/uv.lock
+++ b/uv.lock
@@ -954,7 +954,7 @@ dev = [
{ name = "pytest-timeout", specifier = ">=2.4.0" },
{ name = "pytest-xdist", specifier = ">=3.6.1" },
{ name = "ruff", specifier = ">=0.12.8" },
- { name = "ty", specifier = ">=0.0.59" },
+ { name = "ty", specifier = ">=0.0.69" },
]
[[package]]
@@ -3337,27 +3337,27 @@ wheels = [
[[package]]
name = "ty"
-version = "0.0.61"
+version = "0.0.69"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/92/63/6944925d0fe9a4bb9cc744e6c045a42bbd2ee4654c103190674577a36c3f/ty-0.0.61.tar.gz", hash = "sha256:acbf0d914cc7e2e57ccc440036af36114819e2a604a5ffb554e72e4ca7dd65a2", size = 6234957, upload-time = "2026-07-18T01:39:54.696Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/5b/7a618632dfe9373b7df572ecd7a08c8f799d772fbc317da82dd3aa363207/ty-0.0.69.tar.gz", hash = "sha256:b65106e9ff24fa76e25e1142fb09c85244e815c40450e3021d2bf652c231bb43", size = 6565094, upload-time = "2026-08-06T10:04:25.667Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/cf/044f31523e2768e3e64b0ca2ec32f70b3a731d4a2caa6ea110baf26e251c/ty-0.0.61-py3-none-linux_armv6l.whl", hash = "sha256:148779b8675eac93f40ec58bd70037fe67537117f20a23272264f8f136d41336", size = 11891448, upload-time = "2026-07-18T01:39:18.449Z" },
- { url = "https://files.pythonhosted.org/packages/d2/55/558cfe76b65d91d1854bbfac336020bd42fd887caa632d845d13c0c539eb/ty-0.0.61-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:08217382b3385808ee7288501ea3214b32631b08d1fd091ece6799b0c95264c5", size = 11602442, upload-time = "2026-07-18T01:39:20.914Z" },
- { url = "https://files.pythonhosted.org/packages/27/be/78c0ae6634cd606a68e5b46b338db427a48a1800c96a749b2d2f7a702e03/ty-0.0.61-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d99c729011b47dec20e78a32ac9c8f6defd4cf62f7bb851bbccf70dde6cee50", size = 11125286, upload-time = "2026-07-18T01:39:22.893Z" },
- { url = "https://files.pythonhosted.org/packages/a4/18/a40793962f1b6337938ddb0bca7496b54e70879e23b4d2cc8dfd7e5d1af3/ty-0.0.61-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cda607978ae271b77e51c947663218bce635c3507e256865444b10c37cdb60d", size = 11663403, upload-time = "2026-07-18T01:39:25.017Z" },
- { url = "https://files.pythonhosted.org/packages/98/c1/7879244da5b30407dc368946d36be5024380073408b079f144ffe034030e/ty-0.0.61-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d78f160a0f9434d570cdcdbc4dafba1f6aac3c47a32f9f63995b3cb55ffe4b6", size = 11715250, upload-time = "2026-07-18T01:39:27.045Z" },
- { url = "https://files.pythonhosted.org/packages/35/c4/8a4637cd58abd37f315dd515e24c582986cb1bfdf2edc4786882f5a4f69a/ty-0.0.61-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09aeab4800b36e93e4ce918699004da642d74988cac920b7592a6a2b9be6611c", size = 12393876, upload-time = "2026-07-18T01:39:29.197Z" },
- { url = "https://files.pythonhosted.org/packages/27/4b/27e7c640b1272743503229aa17ae2167a538040c4716a2fa1777c2b34fea/ty-0.0.61-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dccc8136df44142a109953a168be17b4915c99876b047d0b6672c31dae939bdf", size = 12958187, upload-time = "2026-07-18T01:39:31.308Z" },
- { url = "https://files.pythonhosted.org/packages/3a/f5/70eaaefb6081fb0a8115cff66fbfaa20dafac8c646df2477adad95a59de2/ty-0.0.61-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:220760c2d13a887d027ee1093172c24ac35b6e634805329c93a30908ae4d3f5c", size = 12560101, upload-time = "2026-07-18T01:39:33.35Z" },
- { url = "https://files.pythonhosted.org/packages/e3/5a/17bae3b6429b5c479dc6c1e344d34e1f79efbc27531f15f3ee5b5da63745/ty-0.0.61-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:effefbb89da7128d18059529d1c2ea390fe7f1f3882690d257ca2143d49a0c34", size = 12225389, upload-time = "2026-07-18T01:39:35.436Z" },
- { url = "https://files.pythonhosted.org/packages/d2/0e/2ac380ba20d6395542c8df1d6fa4f00e2aead784c2e6aaefa1e02ed0610c/ty-0.0.61-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba8b28a5ef811d5bb6461e37d76110c06fd20487474865c323d3d18b08b972b2", size = 12548403, upload-time = "2026-07-18T01:39:37.556Z" },
- { url = "https://files.pythonhosted.org/packages/b0/e5/7da4b73e825e1a9808c26d68b0156e9a37aede1846191210dfffb8c64042/ty-0.0.61-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:88ecd6d9b05e8174b1860dac9bd3e188d6cef5702b0d3239fd9f94f6ac73a29d", size = 11621813, upload-time = "2026-07-18T01:39:39.919Z" },
- { url = "https://files.pythonhosted.org/packages/9b/3c/5b58015e998cd0d89b17a463b6321421457d86d987574e8dac65ddfceba3/ty-0.0.61-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb0cdfe4c48542ffb9a1139825dfa3d4aae49e96e966682ef7da762ab97831ff", size = 11734101, upload-time = "2026-07-18T01:39:42.097Z" },
- { url = "https://files.pythonhosted.org/packages/a6/21/294f4cc819b7b12ed659fd860e5cdfbd592d4c768c8f23596685dbc43e6b/ty-0.0.61-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dff03873c0c3d0b44738f8b6d403b0756a31cf54c65136397df7624c6159b1f0", size = 11988401, upload-time = "2026-07-18T01:39:44.183Z" },
- { url = "https://files.pythonhosted.org/packages/2e/26/0f96f79fdac118521a9771e9eef3f9b3f447d647b2c77953e80a1715c7e8/ty-0.0.61-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a9210e80e3d41c1dfc751e9e8e0980272f475031fafd0fb0f48aee233c78da03", size = 12330624, upload-time = "2026-07-18T01:39:46.662Z" },
- { url = "https://files.pythonhosted.org/packages/e3/08/1e62d1bca5c0cebdc7a34db1f4b61557aab85961cedd56953dd2c32d3e66/ty-0.0.61-py3-none-win32.whl", hash = "sha256:e3e1fe06f49a5492a922a5df2739834aa5ee978c7dd10414119dc8755cc40c9c", size = 11313991, upload-time = "2026-07-18T01:39:48.761Z" },
- { url = "https://files.pythonhosted.org/packages/26/f1/d8e33b3aeb36b73d81ae34d10e46ec4abf506d68f4e0a1491a76a593dd42/ty-0.0.61-py3-none-win_amd64.whl", hash = "sha256:25f2291169e0298fcdbba1b1fea64f8207a6c1908dddef32346fd5e3e6ac9221", size = 12311717, upload-time = "2026-07-18T01:39:50.881Z" },
- { url = "https://files.pythonhosted.org/packages/e1/14/7caec26d93a943c0e7d15eb7374644508d08cbd387d112b722b12d14e044/ty-0.0.61-py3-none-win_arm64.whl", hash = "sha256:3e496f7698bc4b5bbb1eb66d8b5799ba87596d88d36604ca359083893fa2fc49", size = 11693485, upload-time = "2026-07-18T01:39:52.73Z" },
+ { url = "https://files.pythonhosted.org/packages/06/60/6534092f4d2c15e2491807edd609c2e50d527c1fed957acf40b9f110b64a/ty-0.0.69-py3-none-linux_armv6l.whl", hash = "sha256:98bfd383b273540829af673e7f98b9c1c4bcc8547d12a1a3806cd0bec7f0e087", size = 12364185, upload-time = "2026-08-06T10:03:47.137Z" },
+ { url = "https://files.pythonhosted.org/packages/34/2b/5c29689bd4f74c2e3394d983d85e4011b629f2ce3730c9442553b8554bf8/ty-0.0.69-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:964621ddd05771660017c51b4e74078d861d9fc863c21ef2a500db1ab62c9ccf", size = 12042510, upload-time = "2026-08-06T10:03:49.481Z" },
+ { url = "https://files.pythonhosted.org/packages/09/46/fa085bde4d23516d7ef14b24736fc5dd7dc498f60f52b3d077e59ffdea20/ty-0.0.69-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3ffea4048dd0da4c9c97393b4be0901098a9065b06fa81be2477cbde65d8a151", size = 11549397, upload-time = "2026-08-06T10:03:51.747Z" },
+ { url = "https://files.pythonhosted.org/packages/25/cc/97b9efb2061dcab6fef1e94a4ad99df0bb45bd2cc15d4f5794c787ee0552/ty-0.0.69-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8684d4a70aadd1eab0f41bdba835e3288ef49db8402a8e6ca81bab52ed5d610", size = 12115567, upload-time = "2026-08-06T10:03:53.79Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/c1/a5e0404965093835f3e62544e661784ec0aa8ef0b006ed50af50b19c107e/ty-0.0.69-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:afaaba240ab4122e2069a796836d10be81b4ddb053ae268b3dff962a0b4ca5c7", size = 12149770, upload-time = "2026-08-06T10:03:55.993Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/39/8cad6b205a4abe8a044ca0c84aea71e8ccda29b07a75a5f090e310605580/ty-0.0.69-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11ea63ef07d4e33aeb1a775cf5f2c736b3ed22fa6f8b1b608591612c36795044", size = 12941278, upload-time = "2026-08-06T10:03:58.324Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/8b/8766d96b732c2a060d70dc8ccafcc4d6a54109a2a95f1deb0705de88892b/ty-0.0.69-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb3730b1268e92a2907d7aea3afe8dd1b360ae65862f0557080cf479d481b424", size = 13426509, upload-time = "2026-08-06T10:04:00.621Z" },
+ { url = "https://files.pythonhosted.org/packages/02/1f/e991b2cde953ea5b94d6a9a4c45c87937bd916bc09235f764407bf471c0a/ty-0.0.69-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a544ff57a752ef186ed40b5a2f44c17402af4cdefeb74a311ca02ebd57c4fca0", size = 13106582, upload-time = "2026-08-06T10:04:02.818Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/bb/73538f1b99e3558fd9db87b98698426f0f60fc8666da0b1efd0e70e275eb/ty-0.0.69-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87ed2cbca20caddfdf8e3e14d213ce91b67e75feed78900f4aaf3ef884954028", size = 12708931, upload-time = "2026-08-06T10:04:05.233Z" },
+ { url = "https://files.pythonhosted.org/packages/87/cd/484a5208d74c4ad1155933906295ccdce9aa81a257d8df2ab9e41bd60133/ty-0.0.69-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2684efcbce5b6fe45045faf610b377b50781b6d2aa7e61ea23ecf5b3d2bce421", size = 12985322, upload-time = "2026-08-06T10:04:07.587Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/81/b75003f0d4da9ab3bc8fd4f4802f836cb9921ff7e70f460604f7b769a0b5/ty-0.0.69-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da9aeb26fdac1d2214937542b59e0d4d1ba94ec7a3f45444f33c846de1eb1d63", size = 12063910, upload-time = "2026-08-06T10:04:09.835Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/76/088469f547ef63dceefc4a75826aedee5014f9371dc5171cde931896a82c/ty-0.0.69-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:00e7677cd14ede381f705f71104ea7b8ea0ce217a8634e19a89781953de0e9ad", size = 12166823, upload-time = "2026-08-06T10:04:12.114Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/c9/ce88a0bec0d46d8ae180b99c6ec014866fecc4cba1727b5feec8877b2765/ty-0.0.69-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d91965eb799649833d0d6042db09cd03d15289125245337cc46a2606effb7bda", size = 12483136, upload-time = "2026-08-06T10:04:14.33Z" },
+ { url = "https://files.pythonhosted.org/packages/63/9e/6fae0ff225a0012642cf72c077e20f8f448c0a80771bc3360e8178fe2f32/ty-0.0.69-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1f03359cd8e5c412aa0c181118fa9b9061a4dddaedbb61bac0a424fb0814d402", size = 12799025, upload-time = "2026-08-06T10:04:16.445Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/43/78a658d18b2a4ccf35b053392f2213bf12e3c63b2abea512d3b6751d1f4c/ty-0.0.69-py3-none-win32.whl", hash = "sha256:ec460e01586b1eb91894c4a8403bee3e045a47e7a4ada943cc27ce8e348e88cf", size = 11787774, upload-time = "2026-08-06T10:04:18.622Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/5e/88db1f674403f2b81316a853a44a81ed220621fa96f8f7ae586fb6ca7513/ty-0.0.69-py3-none-win_amd64.whl", hash = "sha256:18976ca26a4e28fc3249477f79a695d5502e670803f2e080d89ac905baef3c6e", size = 12864038, upload-time = "2026-08-06T10:04:20.748Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/7b/6fc6efd00c69103d70f2bdbe824343089cd70b17b3079170057d3e5a3ac0/ty-0.0.69-py3-none-win_arm64.whl", hash = "sha256:7d4ca3bb74d91cb9947ba3f3b4cb131ad6a2b3ecc76d34040c4ec6092d2e411d", size = 12196693, upload-time = "2026-08-06T10:04:22.902Z" },
]
[[package]]
From 6ccbb570cce00b917e04953d20219074dd6fa21c Mon Sep 17 00:00:00 2001
From: nate nowack
Date: Fri, 14 Aug 2026 12:14:36 -0500
Subject: [PATCH 14/27] Cover CallArgument resolution in background tasks
(#4833)
Co-authored-by: Claude Fable 5
---
tests/tasks/server/test_task_dependencies.py | 51 ++++++++++++++++++++
1 file changed, 51 insertions(+)
diff --git a/tests/tasks/server/test_task_dependencies.py b/tests/tasks/server/test_task_dependencies.py
index 847b902d5..27eb21e16 100644
--- a/tests/tasks/server/test_task_dependencies.py
+++ b/tests/tasks/server/test_task_dependencies.py
@@ -18,6 +18,7 @@ from fastmcp_tasks.dependencies import CurrentDocket
from uncalled_for import Depends
from fastmcp import Context, FastMCP
+from fastmcp.dependencies import CallArgument
from fastmcp.server.auth import AccessToken
from fastmcp.server.dependencies import CurrentFastMCP
from fastmcp.server.sessions import UserSession
@@ -274,3 +275,53 @@ async def test_ctx_session_state_works_in_background_task():
structured = final.result["structuredContent"]
assert structured["read_back"] == "hello"
assert isinstance(structured["session_id"], str) and structured["session_id"]
+
+
+async def test_background_tool_resolves_bare_call_argument():
+ """A bare CallArgument resolves from the tool's arguments in a worker.
+
+ Regression guard for the pydocket-floor split caught in #4802 review:
+ pydocket 0.20.1's task resolver did not establish an uncalled-for frame, so
+ CallArgument worked on foreground calls but raised in background tasks.
+ The unified pydocket>=0.24.1 floor resolves it in both paths; this pins the
+ background one.
+ """
+ mcp = FastMCP("call-argument-task")
+ mcp.add_extension(TasksExtension())
+
+ def get_greeting(name: str = CallArgument()) -> str:
+ return f"Hello, {name}!"
+
+ @mcp.tool(task=True)
+ async def greet(name: str, greeting: str = Depends(get_greeting)) -> str:
+ return greeting
+
+ async with running_task_server(mcp):
+ final = await run_task(mcp, "greet", {"name": "Alice"})
+
+ assert final.status == "completed"
+ assert final.result is not None
+ assert final.result["structuredContent"] == {"result": "Hello, Alice!"}
+
+
+async def test_background_tool_resolves_call_argument_binding():
+ """A Depends(..., param=CallArgument("name")) binding resolves in a worker."""
+ mcp = FastMCP("call-argument-binding-task")
+ mcp.add_extension(TasksExtension())
+
+ def get_account(user_id: str) -> dict[str, str]:
+ return {"id": user_id, "plan": "pro"}
+
+ @mcp.tool(task=True)
+ async def show_account(
+ owner: str,
+ account: dict[str, str] = Depends(get_account, user_id=CallArgument("owner")),
+ ) -> str:
+ return f"{account['id']}:{account['plan']}"
+
+ async with running_task_server(mcp):
+ final = await run_task(mcp, "show_account", {"owner": "alice"})
+
+ assert final.status == "completed"
+ assert final.result is not None
+ assert final.result["structuredContent"] == {"result": "alice:pro"}
From bab1073da2f2ebabce0cfdabc704df51e1f536d9 Mon Sep 17 00:00:00 2001
From: "marvin-context-protocol[bot]"
<225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Date: Fri, 14 Aug 2026 13:15:03 -0400
Subject: [PATCH 15/27] chore: Update SDK documentation (#4832)
---
.../fastmcp-server-dependencies.mdx | 89 ++++++++++---------
1 file changed, 46 insertions(+), 43 deletions(-)
diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx
index 1ab291fb1..42de8e80c 100644
--- a/docs/python-sdk/fastmcp-server-dependencies.mdx
+++ b/docs/python-sdk/fastmcp-server-dependencies.mdx
@@ -16,7 +16,7 @@ using the uncalled-for DI engine. The docket-specific dependencies
## Functions
-### `bind_request_context`
+### `bind_request_context`
```python
bind_request_context(ctx: ServerRequestContext) -> Generator[FastMCPRequestContext, None, None]
@@ -31,7 +31,7 @@ initialize middleware enters this so ``Context`` and dependency helpers can
read the active request from the ContextVar.
-### `extract_version_spec`
+### `extract_version_spec`
```python
extract_version_spec(meta: dict[str, Any] | None) -> str | None
@@ -41,7 +41,7 @@ extract_version_spec(meta: dict[str, Any] | None) -> str | None
Extract the FastMCP component version from a lifted ``_meta`` block.
-### `set_background_context_factory`
+### `set_background_context_factory`
```python
set_background_context_factory(factory: Callable[[], Awaitable[Context | None]] | None) -> None
@@ -56,7 +56,7 @@ no task context. Passing ``None`` restores core's no-worker-fallback
behavior.
-### `set_worker_server_resolver`
+### `set_worker_server_resolver`
```python
set_worker_server_resolver(resolver: Callable[[], FastMCP | None] | None) -> None
@@ -66,7 +66,7 @@ set_worker_server_resolver(resolver: Callable[[], FastMCP | None] | None) -> Non
Install (or clear) the worker-server resolver used by ``get_server()``.
-### `is_docket_available`
+### `is_docket_available`
```python
is_docket_available() -> bool
@@ -87,7 +87,7 @@ Any of those failing means we treat docket as unavailable and fall back
to the no-tasks code paths instead of crashing deep inside a request.
-### `transform_context_annotations`
+### `transform_context_annotations`
```python
transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]
@@ -114,7 +114,7 @@ allows them to have defaults in any order.
- Function with modified signature (same function object, updated __signature__)
-### `get_context`
+### `get_context`
```python
get_context() -> Context
@@ -124,7 +124,7 @@ get_context() -> Context
Get the current FastMCP Context instance directly.
-### `get_server`
+### `get_server`
```python
get_server() -> FastMCP
@@ -144,7 +144,7 @@ root that started the worker (#3571).
- `RuntimeError`: If no server in context
-### `get_session`
+### `get_session`
```python
get_session(session_id: str) -> Session
@@ -169,7 +169,7 @@ no foreground context — it works from a `task=True` tool's Docket worker as
well as a normal request.
-### `get_http_request`
+### `get_http_request`
```python
get_http_request() -> Request
@@ -181,7 +181,7 @@ Get the current HTTP request.
Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context.
-### `get_http_headers`
+### `get_http_headers`
```python
get_http_headers(include_all: bool = False, include: set[str] | None = None) -> dict[str, str]
@@ -202,7 +202,7 @@ normally be excluded. This is useful for proxy transports that need to forward
authorization headers to upstream MCP servers.
-### `get_access_token`
+### `get_access_token`
```python
get_access_token() -> AccessToken | None
@@ -220,7 +220,7 @@ request is available.
- The access token if an authenticated user is available, None otherwise.
-### `without_injected_parameters`
+### `without_injected_parameters`
```python
without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]
@@ -249,7 +249,7 @@ thread-affinity libraries (e.g. Windows COM). Ignored for async fns.
- Async wrapper function without injected parameters
-### `resolve_dependencies`
+### `resolve_dependencies`
```python
resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None]
@@ -264,6 +264,9 @@ This function:
The filtering prevents external callers from overriding injected parameters by
providing values for dependency parameter names. This is a security feature.
+The filtered arguments also feed the resolution frame, so a CallArgument()
+reference to a dependency parameter resolves the dependency and never a
+caller-supplied value.
Note: Context injection is handled via transform_context_annotations() which
converts `ctx: Context` to `ctx: Context = Depends(get_context)` at registration
@@ -275,7 +278,7 @@ time, so all injection goes through the unified DI system.
which will be filtered out)
-### `CurrentContext`
+### `CurrentContext`
```python
CurrentContext() -> Context
@@ -294,7 +297,7 @@ current MCP operation (tool/resource/prompt call).
- `RuntimeError`: If no active context found (during resolution)
-### `OptionalCurrentContext`
+### `OptionalCurrentContext`
```python
OptionalCurrentContext() -> Context | None
@@ -304,7 +307,7 @@ OptionalCurrentContext() -> Context | None
Get the current FastMCP Context, or None when no context is active.
-### `CurrentFastMCP`
+### `CurrentFastMCP`
```python
CurrentFastMCP() -> FastMCP
@@ -322,7 +325,7 @@ This dependency provides access to the active FastMCP server.
- `RuntimeError`: If no server in context (during resolution)
-### `CurrentRequest`
+### `CurrentRequest`
```python
CurrentRequest() -> Request
@@ -342,7 +345,7 @@ current HTTP request. Only available when running over HTTP transports
- `RuntimeError`: If no HTTP request in context (e.g., STDIO transport)
-### `CurrentHeaders`
+### `CurrentHeaders`
```python
CurrentHeaders() -> dict[str, str]
@@ -360,7 +363,7 @@ transport.
- A dependency that resolves to a dictionary of header name -> value
-### `CurrentAccessToken`
+### `CurrentAccessToken`
```python
CurrentAccessToken() -> AccessToken
@@ -379,7 +382,7 @@ authenticated request. Raises an error if no authentication is present.
- `RuntimeError`: If no authenticated user (use get_access_token() for optional)
-### `TokenClaim`
+### `TokenClaim`
```python
TokenClaim(name: str) -> str
@@ -404,7 +407,7 @@ without needing the full token object.
## Classes
-### `FastMCPRequestContext`
+### `FastMCPRequestContext`
FastMCP-owned wrapper around the SDK's per-request context.
@@ -422,7 +425,7 @@ distributed-trace parent. Those live in the raw params dict under ``_meta``,
which this wrapper lifts once so downstream consumers have a stable surface.
-### `ProgressLike`
+### `ProgressLike`
Protocol for progress tracking interface.
@@ -433,7 +436,7 @@ and Docket's Progress (worker context).
**Methods:**
-#### `current`
+#### `current`
```python
current(self) -> int | None
@@ -442,7 +445,7 @@ current(self) -> int | None
Current progress value.
-#### `total`
+#### `total`
```python
total(self) -> int
@@ -451,7 +454,7 @@ total(self) -> int
Total/target progress value.
-#### `message`
+#### `message`
```python
message(self) -> str | None
@@ -460,7 +463,7 @@ message(self) -> str | None
Current progress message.
-#### `set_total`
+#### `set_total`
```python
set_total(self, total: int) -> None
@@ -469,7 +472,7 @@ set_total(self, total: int) -> None
Set the total/target value for progress tracking.
-#### `increment`
+#### `increment`
```python
increment(self, amount: int = 1) -> None
@@ -478,7 +481,7 @@ increment(self, amount: int = 1) -> None
Atomically increment the current progress value.
-#### `set_message`
+#### `set_message`
```python
set_message(self, message: str | None) -> None
@@ -487,7 +490,7 @@ set_message(self, message: str | None) -> None
Update the progress status message.
-### `InMemoryProgress`
+### `InMemoryProgress`
In-memory progress tracker for immediate tool execution.
@@ -499,25 +502,25 @@ progress doesn't need to be observable across processes.
**Methods:**
-#### `current`
+#### `current`
```python
current(self) -> int | None
```
-#### `total`
+#### `total`
```python
total(self) -> int
```
-#### `message`
+#### `message`
```python
message(self) -> str | None
```
-#### `set_total`
+#### `set_total`
```python
set_total(self, total: int) -> None
@@ -526,7 +529,7 @@ set_total(self, total: int) -> None
Set the total/target value for progress tracking.
-#### `increment`
+#### `increment`
```python
increment(self, amount: int = 1) -> None
@@ -535,7 +538,7 @@ increment(self, amount: int = 1) -> None
Atomically increment the current progress value.
-#### `set_message`
+#### `set_message`
```python
set_message(self, message: str | None) -> None
@@ -544,7 +547,7 @@ set_message(self, message: str | None) -> None
Update the progress status message.
-### `Progress`
+### `Progress`
Progress dependency that works in both server and worker contexts.
@@ -559,7 +562,7 @@ share mutable state.
**Methods:**
-#### `current`
+#### `current`
```python
current(self) -> int | None
@@ -568,7 +571,7 @@ current(self) -> int | None
Current progress value.
-#### `total`
+#### `total`
```python
total(self) -> int
@@ -577,7 +580,7 @@ total(self) -> int
Total/target progress value.
-#### `message`
+#### `message`
```python
message(self) -> str | None
@@ -586,7 +589,7 @@ message(self) -> str | None
Current progress message.
-#### `set_total`
+#### `set_total`
```python
set_total(self, total: int) -> None
@@ -595,7 +598,7 @@ set_total(self, total: int) -> None
Set the total/target value for progress tracking.
-#### `increment`
+#### `increment`
```python
increment(self, amount: int = 1) -> None
@@ -604,7 +607,7 @@ increment(self, amount: int = 1) -> None
Atomically increment the current progress value.
-#### `set_message`
+#### `set_message`
```python
set_message(self, message: str | None) -> None
From addb8fa541d74b311dd0fa1bf99c3bf7b904c033 Mon Sep 17 00:00:00 2001
From: nate nowack
Date: Fri, 14 Aug 2026 12:16:22 -0500
Subject: [PATCH 16/27] Bump cryptography to 50.0.0 (#4836)
Co-authored-by: Claude Opus 5 (1M context)
---
uv.lock | 158 ++++++++++++++++++++++++++++----------------------------
1 file changed, 79 insertions(+), 79 deletions(-)
diff --git a/uv.lock b/uv.lock
index b18e8a3df..7408bec06 100644
--- a/uv.lock
+++ b/uv.lock
@@ -43,7 +43,7 @@ resolution-markers = [
"python_full_version < '3.11' and sys_platform != 'win32'",
]
dependencies = [
- { name = "caio" },
+ { name = "caio", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" }
wheels = [
@@ -63,7 +63,7 @@ resolution-markers = [
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'win32'",
]
dependencies = [
- { name = "caio" },
+ { name = "caio", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" }
wheels = [
@@ -664,59 +664,59 @@ wheels = [
[[package]]
name = "cryptography"
-version = "49.0.0"
+version = "50.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
- { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
- { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
- { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
- { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
- { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
- { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
- { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
- { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
- { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
- { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
- { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
- { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
- { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
- { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
- { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
- { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
- { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
- { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
- { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
- { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
- { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
- { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
- { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
- { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
- { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
- { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
- { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
- { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
- { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
- { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
- { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
- { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
- { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
- { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
- { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
- { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
- { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
- { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
- { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
- { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
- { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
- { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
- { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
- { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
+ { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
+ { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
+ { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
+ { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
+ { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
+ { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
+ { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
+ { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
+ { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
+ { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
+ { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
+ { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
+ { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
+ { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" },
+ { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
+ { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
+ { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
+ { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
+ { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
+ { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" },
+ { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" },
+ { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" },
+ { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" },
]
[[package]]
@@ -1311,7 +1311,7 @@ name = "importlib-metadata"
version = "9.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "zipp" },
+ { name = "zipp", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
wheels = [
@@ -1358,17 +1358,17 @@ resolution-markers = [
"python_full_version < '3.11' and sys_platform != 'win32'",
]
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "decorator" },
- { name = "exceptiongroup" },
- { name = "jedi" },
- { name = "matplotlib-inline" },
- { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
- { name = "prompt-toolkit" },
- { name = "pygments" },
- { name = "stack-data" },
- { name = "traitlets" },
- { name = "typing-extensions" },
+ { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
+ { name = "decorator", marker = "python_full_version < '3.11'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "jedi", marker = "python_full_version < '3.11'" },
+ { name = "matplotlib-inline", marker = "python_full_version < '3.11'" },
+ { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit", marker = "python_full_version < '3.11'" },
+ { name = "pygments", marker = "python_full_version < '3.11'" },
+ { name = "stack-data", marker = "python_full_version < '3.11'" },
+ { name = "traitlets", marker = "python_full_version < '3.11'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" }
wheels = [
@@ -1388,18 +1388,18 @@ resolution-markers = [
"python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'win32'",
]
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "decorator" },
- { name = "ipython-pygments-lexers" },
- { name = "jedi" },
- { name = "matplotlib-inline" },
- { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
- { name = "prompt-toolkit" },
- { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" },
- { name = "pygments" },
- { name = "stack-data" },
- { name = "traitlets" },
- { name = "typing-extensions", marker = "python_full_version < '3.12'" },
+ { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" },
+ { name = "decorator", marker = "python_full_version >= '3.11'" },
+ { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" },
+ { name = "jedi", marker = "python_full_version >= '3.11'" },
+ { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" },
+ { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" },
+ { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" },
+ { name = "pygments", marker = "python_full_version >= '3.11'" },
+ { name = "stack-data", marker = "python_full_version >= '3.11'" },
+ { name = "traitlets", marker = "python_full_version >= '3.11'" },
+ { name = "typing-extensions", marker = "python_full_version == '3.11.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" }
wheels = [
@@ -1411,7 +1411,7 @@ name = "ipython-pygments-lexers"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pygments" },
+ { name = "pygments", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
wheels = [
@@ -1952,7 +1952,7 @@ name = "pexpect"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "ptyprocess" },
+ { name = "ptyprocess", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
wheels = [
@@ -3163,8 +3163,8 @@ name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cryptography" },
- { name = "jeepney" },
+ { name = "cryptography", marker = "sys_platform != 'win32'" },
+ { name = "jeepney", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
@@ -3234,8 +3234,8 @@ name = "taskgroup"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "exceptiongroup" },
- { name = "typing-extensions" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" }
wheels = [
From 46399056dbd8821006b18bfc793359a7745498f7 Mon Sep 17 00:00:00 2001
From: Akshay Parihar
Date: Fri, 14 Aug 2026 22:51:54 +0530
Subject: [PATCH 17/27] Scalekit issuer updates backward compatibility (#4798)
---
.../fastmcp/server/auth/providers/scalekit.py | 11 +++-
tests/server/auth/providers/test_scalekit.py | 66 ++++++++++++++++++-
2 files changed, 72 insertions(+), 5 deletions(-)
diff --git a/fastmcp_slim/fastmcp/server/auth/providers/scalekit.py b/fastmcp_slim/fastmcp/server/auth/providers/scalekit.py
index 8e82be441..d7564e629 100644
--- a/fastmcp_slim/fastmcp/server/auth/providers/scalekit.py
+++ b/fastmcp_slim/fastmcp/server/auth/providers/scalekit.py
@@ -125,15 +125,22 @@ class ScalekitProvider(RemoteAuthProvider):
# Create default JWT verifier if none provided
if token_verifier is None:
+ # Scalekit is migrating the `iss` claim from the bare environment URL
+ # to a resource-scoped issuer. Accept both forms so tokens minted
+ # before and after the migration validate against the same provider.
+ expected_issuers = [
+ self.environment_url,
+ f"{self.environment_url}/resources/{self.resource_id}",
+ ]
logger.debug(
"Creating default JWTVerifier for Scalekit: jwks_uri=%s issuer=%s required_scopes=%s",
f"{self.environment_url}/keys",
- self.environment_url,
+ expected_issuers,
self.required_scopes,
)
token_verifier = JWTVerifier(
jwks_uri=f"{self.environment_url}/keys",
- issuer=self.environment_url,
+ issuer=expected_issuers,
algorithm="RS256",
audience=self.resource_id,
required_scopes=self.required_scopes or None,
diff --git a/tests/server/auth/providers/test_scalekit.py b/tests/server/auth/providers/test_scalekit.py
index 1833ed50d..a62db1664 100644
--- a/tests/server/auth/providers/test_scalekit.py
+++ b/tests/server/auth/providers/test_scalekit.py
@@ -6,7 +6,7 @@ from mcp import MCPError
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
-from fastmcp.server.auth.providers.jwt import JWTVerifier
+from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
from fastmcp.utilities.tests import HeadlessOAuth, run_server_async
@@ -91,10 +91,15 @@ class TestScalekitProvider:
base_url="https://myserver.com/",
)
- # Check that JWT verifier uses the correct endpoints
+ # Check that JWT verifier uses the correct endpoints. Both the bare
+ # environment URL and the resource-scoped issuer are accepted so tokens
+ # from before and after Scalekit's issuer migration validate.
assert isinstance(provider.token_verifier, JWTVerifier)
assert provider.token_verifier.jwks_uri == "https://my-env.scalekit.com/keys"
- assert provider.token_verifier.issuer == "https://my-env.scalekit.com"
+ assert provider.token_verifier.issuer == [
+ "https://my-env.scalekit.com",
+ "https://my-env.scalekit.com/resources/sk_resource_456",
+ ]
assert provider.token_verifier.audience == "sk_resource_456"
def test_required_scopes_hooks_into_verifier(self):
@@ -124,6 +129,61 @@ class TestScalekitProvider:
)
+class TestScalekitIssuerMigration:
+ """Scalekit is migrating the `iss` claim from the bare environment URL to a
+ resource-scoped issuer. Tokens minted before and after the migration must
+ both validate against the same provider.
+ """
+
+ ENV_URL = "https://my-env.scalekit.com"
+ RESOURCE_ID = "sk_resource_456"
+
+ @pytest.fixture
+ def key_pair(self) -> RSAKeyPair:
+ return RSAKeyPair.generate()
+
+ def _provider(self, key_pair: RSAKeyPair) -> ScalekitProvider:
+ provider = ScalekitProvider(
+ environment_url=self.ENV_URL,
+ resource_id=self.RESOURCE_ID,
+ base_url="https://myserver.com/",
+ )
+ # Verify against the test key instead of Scalekit's live JWKS endpoint.
+ assert isinstance(provider.token_verifier, JWTVerifier)
+ provider.token_verifier.public_key = key_pair.public_key
+ return provider
+
+ async def test_pre_migration_issuer_accepted(self, key_pair: RSAKeyPair):
+ """The bare environment URL issuer (pre-migration) validates."""
+ provider = self._provider(key_pair)
+ token = key_pair.create_token(
+ issuer=self.ENV_URL,
+ audience=self.RESOURCE_ID,
+ )
+
+ assert await provider.token_verifier.verify_token(token) is not None
+
+ async def test_post_migration_issuer_accepted(self, key_pair: RSAKeyPair):
+ """The resource-scoped issuer (post-migration) validates."""
+ provider = self._provider(key_pair)
+ token = key_pair.create_token(
+ issuer=f"{self.ENV_URL}/resources/{self.RESOURCE_ID}",
+ audience=self.RESOURCE_ID,
+ )
+
+ assert await provider.token_verifier.verify_token(token) is not None
+
+ async def test_unknown_issuer_rejected(self, key_pair: RSAKeyPair):
+ """An issuer outside the accepted set is still rejected."""
+ provider = self._provider(key_pair)
+ token = key_pair.create_token(
+ issuer="https://evil.example.com",
+ audience=self.RESOURCE_ID,
+ )
+
+ assert await provider.token_verifier.verify_token(token) is None
+
+
@pytest.fixture
async def mcp_server_url():
"""Start Scalekit server."""
From 59487837ee3c9f461d92ec9b7ab053e49c43c135 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Fri, 14 Aug 2026 13:33:22 -0400
Subject: [PATCH 18/27] docs: prepare FastMCP 4 beta 3 (#4840)
---
dev-docs/v4-notes/index.md | 9 ++++----
dev-docs/v4-notes/known-gaps.md | 21 +++++++++----------
docs/getting-started/installation.mdx | 6 +++---
.../upgrading/from-fastmcp-3.mdx | 14 ++++++-------
.../upgrading/from-low-level-sdk-v1.mdx | 8 +++----
.../upgrading/from-low-level-sdk-v2.mdx | 8 +++----
.../upgrading/from-mcp-sdk-v1.mdx | 8 +++----
.../upgrading/from-mcp-sdk-v2.mdx | 8 +++----
8 files changed, 36 insertions(+), 46 deletions(-)
diff --git a/dev-docs/v4-notes/index.md b/dev-docs/v4-notes/index.md
index 282c37af2..2c89bd849 100644
--- a/dev-docs/v4-notes/index.md
+++ b/dev-docs/v4-notes/index.md
@@ -20,9 +20,9 @@ FastMCP v4.0 is an engine swap. Three forces drive the major version:
## Release strategy
-The migration merges to `main` and development continues there with subsequent PRs. Releases follow the SDK's own beta timeline:
+The migration lives on `main`, which now depends on the stable MCP Python SDK 2.0 line. FastMCP continues cutting prereleases while the v4 APIs soak, then ships 4.0.0 from the same branch.
-- **`main` carries the beta pins.** While the SDK is on `mcp==2.0.0b1` / `mcp-types==2.0.0b1`, `main` cuts **pre-releases** (`4.0.0b1`, `4.0.0b2`, …). No stable PyPI release goes out until `mcp 2.0.0` reaches GA — at which point the pins swap to the stable SDK and `4.0.0` ships. The pin-swap is a tracked checklist item on the [Known Gaps](known-gaps.md) page.
+- **`main` owns FastMCP 4.** It carries stable `mcp>=2.0.0` and `mcp-types>=2.0.0` dependencies. Beta 3 is the current prerelease target; the [Known Gaps](known-gaps.md) page tracks the remaining decisions before 4.0.0.
- **`release/3.x` is the maintenance line.** A `release/3.x` branch is cut from pre-merge `main`. It stays on the SDK v1 line, receives upstream security patches, and serves users who cannot move to the SDK v2 beta yet.
### Release codenames
@@ -34,8 +34,9 @@ Following the pun-title convention (`v: `), the v4 line runs a sin
| `4.0.0a1` (alpha) | **Fourst Contact** | _first contact_ — the first, cautious look at the new engine |
| `4.0.0a2` (alpha) | **Back and Fourth** | _back and forth_ — the second pass, where background tasks and stateless state land |
| `4.0.0b1` (beta) | **Fourgone Conclusion** | _foregone conclusion_ — once the MCP SDK went v2, v4 was inevitable |
-| `4.0.0b2` (beta) | **Fourmidable** | _formidable_ — held in reserve for a second beta if one is needed |
-| `4.0.0` (stable) | **Fast Fourward** | _fast forward_ — full speed onto the new foundation |
+| `4.0.0b2` (beta) | **Four the Better** | _for the better_ — a hardening release focused on correctness, compatibility, and security |
+| `4.0.0b3` (beta) | **Fast Fourward** | _fast forward_ — the final beta carries the accumulated v4 work into its GA soak |
+| `4.0.0` (stable) | **Fourmidable** | _formidable_ — the stable release of the new protocol foundation |
## How to read the register
diff --git a/dev-docs/v4-notes/known-gaps.md b/dev-docs/v4-notes/known-gaps.md
index 0fb699029..92c8f7cdf 100644
--- a/dev-docs/v4-notes/known-gaps.md
+++ b/dev-docs/v4-notes/known-gaps.md
@@ -2,17 +2,17 @@
title: Known Gaps and Upstream Dependencies
---
-The migration ships with a set of deliberate gaps: temporary shims, xfailed tests, and pins that depend on the MCP Python SDK v2 reaching GA. Each is tracked here with its removal trigger. This page is the checklist for the beta-to-stable transition and the advisory relationship with the SDK team.
+The migration ships with a small set of deliberate compatibility boundaries and expected test gaps. FastMCP now depends on the stable MCP Python SDK 2.0 line; this page tracks what remains for the beta-to-stable transition and the advisory relationship with the SDK team.
## The xfail register
-Roughly forty `xfail` markers across the test tree name the SDK gaps and removed protocol surfaces they wait on. Re-running the suite against a new SDK beta surfaces which have closed (a strict xfail that starts passing fails the suite, prompting removal of the marker). They cluster in three areas — but the largest cluster is no longer a set of gaps to close.
+The unit suite has three expected xfails. Two are strict SDK compatibility checks, so an upstream fix turns them into failures and prompts us to remove the markers.
-**Task suite (`tests/server/tasks/`, `tests/client/tasks/`) — SEP-1686 wire layer being removed; engine rebuilt on SEP-2663.** The large majority. These cover the 2025 task protocol (SEP-1686), which left the core MCP spec and was reworked into the `io.modelcontextprotocol/tasks` extension (SEP-2663). FastMCP's SEP-1686 *wire* machinery (capability advertisement, the `tasks/get|result|list|cancel` handlers, the push notification/elicitation relay) is slated for removal, so the wire-protocol xfails disappear with the code they cover — they are not waiting on an SDK fix. The Docket/Redis *execution engine* underneath is not discarded: it is extracted into the planned `fastmcp-tasks` package and re-adapted to the SEP-2663 polling shape (see [Background Tasks (SEP-2663)](background-tasks.md)). The two SDK gaps these were originally filed against — **sdk-feedback #1** (SEP-1686 task result types omitted from the method registries) and **sdk-feedback #3** (no `task` field on `ReadResourceRequestParams` / `GetPromptRequestParams`) — are moot: they patched the SEP-1686 wire shape, which SEP-2663 replaces with a `CreateTaskResult` claimed on `tools/call`. The gap that matters for the rebuild is **sdk-feedback #2** (extensions capability stripped at pre-2026 negotiated versions) — it now gates a flagship feature and is escalated accordingly.
+**Stateless HTTP elicitation (`tests/client/test_streamable_http.py`).** One parametrized case exercises server-initiated elicitation over stateless HTTP. The sessionless protocol has no server-to-client back-channel, so the case is expected to xfail by construction. Guard-mode elicitation is the supported modern path.
-**Protocol eras (`tests/server/test_protocol_eras.py`).** One remaining strict xfail, and it too is task-related: the v2 SDK high-level client exposes no `task=` parameter on `call_tool`, so a SEP-1686 task-augmented `tools/call` cannot be submitted through it. It resolves with the SEP-1686 wire-layer removal above; the SEP-2663 rebuild submits tasks by advertising the extension capability and claiming a `CreateTaskResult`, not through a `task=` params field. The earlier strict xfail for the `ctx.elicit` / `ctx.sample` "Method not found" degradation (sdk-feedback #10) is **gone** — the era-gating shipped in #4448 flipped it to a passing test.
+**MCP Apps (`tests/test_apps.py`).** Two strict xfails track **sdk-feedback #2**: the SDK strips `capabilities.extensions` at pre-2026 negotiated versions, so the UI extension cannot be advertised to legacy-era clients. Modern clients receive the extension normally.
-**MCP Apps (`tests/test_apps.py`).** Two xfails tied to **sdk-feedback #2** — the `extensions` capability is stripped by the pre-2026 version sieve, so the UI extension can't be advertised to legacy-era clients.
+Credential-gated GitHub integration suites also use conditional xfail markers when their environment variables are absent. Those are test-environment controls rather than product gaps and are not part of the GA decision.
## Shims and their removal triggers
@@ -20,15 +20,12 @@ Every shim in the migration is temporary and carries a documented removal trigge
| Shim | Location | Removal trigger |
| --- | --- | --- |
-| `_sdk_patches.py` — task registry widening | `fastmcp_slim/fastmcp/_sdk_patches.py` | Removed with FastMCP's SEP-1686 wire machinery (`server/tasks/`), which is slated for removal now that the 2025 task protocol left the spec. The SEP-2663 rebuild does not need it — `CreateTaskResult` is claimed on `tools/call` through the extensions mechanism, which the SDK registries already admit. |
| `_compat.py` — camelCase field bridge | `fastmcp_slim/fastmcp/_compat.py` | User-migration aid; removed in a future release after users migrate reads to snake_case. Users can preview removal with `mcp_camelcase_compat = False`. |
| `FastMCPRequestContext` ContextVar | `fastmcp_slim/fastmcp/server/dependencies.py` | The SDK deliberately passes context as an argument with no ContextVar; FastMCP's public `get_context()` needs ambient access, and the shim also lifts `_meta`, which the SDK's `TypedDict` drops. No planned removal — this is a permanent boundary, not a beta gap. |
| `FastMCPServerMiddleware` | `fastmcp_slim/fastmcp/server/low_level.py` | Already the native SDK `ServerMiddleware` path; no cleaner hook exists. Permanent. |
| Client `get_session_id` header sniff | `fastmcp_slim/fastmcp/client/transports/http.py` | SDK exposes session id (or an `on_session_created` callback) from `streamable_http_client`, at parity with `sse_client` (sdk-feedback #5). |
| `_sdk_context_shim.py` — generic handler aliases | `fastmcp_slim/fastmcp/client/_sdk_context_shim.py` | The SDK's `ClientRequestContext` is not subscriptable, so FastMCP keeps the public generic `SamplingHandler`/`RootsHandler`/`ElicitationHandler` aliases. Permanent unless the SDK makes the context subscriptable (sdk-feedback #7). |
-The `TaskNotificationHandler` binding (sdk-feedback #8) is the client-side equivalent: it registers a `NotificationBinding` for the SEP-1686 `notifications/tasks/status` because the SDK no longer tees custom server notifications to the message handler. It goes away with the SEP-1686 wire machinery it serves; the `fastmcp-tasks` client half registers its own binding for the SEP-2663 `notifications/tasks` shape when it ships (push notifications are deferred to a later `fastmcp-tasks` version — v1 is polling-only).
-
## Statelessness on 2026-07-28
The `2026-07-28` era is stateless by protocol construction, and the recurring maintainer question is whether that statelessness has to be woven through FastMCP everywhere. It does not — but the honest accounting has three parts: features that are legacy-only because the protocol removed the mechanism, features that already work because they never relied on a session, and a short list of design holes where the current code *doesn't error* but also *doesn't work*. Everything below concerns `2026-07-28` connections only. Every client in the field today negotiates a handshake era, where all of this behaves exactly as it always has.
@@ -80,6 +77,8 @@ Separately, the [SDK delegation round two](feature-program.md#sdk-delegation-rou
The beta-to-stable transition is a small set of tracked steps:
-- **Swap the pins.** When `mcp 2.0.0` reaches GA, change `mcp-types==2.0.0b1` (core) and the `mcp` pin (the `[mcp]` extra) in `fastmcp_slim/pyproject.toml` from the beta to the stable release, and cut `4.0.0` instead of another pre-release.
-- **Re-run the xfail suite against the GA SDK.** Any strict xfail that starts passing means a gap closed — remove the marker and, where applicable, the corresponding shim.
-- **Confirm `release/3.x`** is cut from pre-merge `main` and receiving upstream security patches for users who stay on the SDK v1 line.
+- **Stable SDK dependencies — complete.** `fastmcp-slim` requires `mcp>=2.0.0,<3.0.0` and `mcp-types>=2.0.0,<3.0.0`; the lock resolves both to 2.0.0.
+- **Re-run the full suite before GA.** Confirm the three expected xfails above remain the complete set. If either strict Apps xfail starts passing, remove the marker and the corresponding compatibility note.
+- **Make the extension compatibility decision explicit.** GA can accept Apps and other extensions as modern-era capabilities, or wait for the SDK to preserve `capabilities.extensions` on legacy handshakes. Record that choice in the public protocol-support docs.
+- **Prepare the stable docs.** Remove prerelease installation guidance, add the `4.0.0: Fourmidable` changelog and update entries, and merge those changes to `main` before tagging so the stable docs publication PR contains them.
+- **Keep the 3.x maintenance line available — complete.** `release/3.x` is protected and continues receiving security and compatibility patches for SDK v1 users.
diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx
index 8c3167fb6..92b307d6c 100644
--- a/docs/getting-started/installation.mdx
+++ b/docs/getting-started/installation.mdx
@@ -18,7 +18,7 @@ pip install fastmcp
```
-**FastMCP 4 is in prerelease.** The commands above install the latest stable release, which is still 3.x. To get v4, pin the beta explicitly with `pip install "fastmcp==4.0.0b1"`, or see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for the uv constraint you'll need.
+**FastMCP 4 is in prerelease.** The commands above install the latest stable release, which is still 3.x. To get v4, pin the beta explicitly with `pip install "fastmcp==4.0.0b3"`, or see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for the uv constraint you'll need.
### Optional Dependencies
@@ -44,7 +44,7 @@ You should see output like the following:
```bash
$ fastmcp version
-FastMCP version: 4.0.0b1
+FastMCP version: 4.0.0b3
MCP version: 2.0.0
Python version: 3.12.2
Platform: macOS-15.3.1-arm64-arm-64bit
@@ -115,7 +115,7 @@ FastMCP follows semantic versioning with pragmatic adaptations for the rapidly e
For production use, always pin to exact versions:
```
-fastmcp==4.0.0b1 # Good - an exact version
+fastmcp==4.0.0b3 # Good - an exact version
fastmcp>=4.0.0 # Bad - may install breaking changes
```
diff --git a/docs/getting-started/upgrading/from-fastmcp-3.mdx b/docs/getting-started/upgrading/from-fastmcp-3.mdx
index 1cd484680..267b33736 100644
--- a/docs/getting-started/upgrading/from-fastmcp-3.mdx
+++ b/docs/getting-started/upgrading/from-fastmcp-3.mdx
@@ -16,17 +16,17 @@ The sections below cover what FastMCP handles for you, the changes you must make
While FastMCP 4 is in prerelease, pin the beta explicitly. The `fastmcp` package is a thin wrapper that depends on `fastmcp-slim` at the same version, so asking for a prerelease of one means asking for a prerelease of the other. pip infers that on its own:
```bash
-pip install "fastmcp==4.0.0b1"
+pip install "fastmcp==4.0.0b3"
```
uv is stricter: it allows prereleases only for packages you name, and `fastmcp-slim` arrives transitively. Constrain it alongside the requirement in `pyproject.toml`:
```toml
[project]
-dependencies = ["fastmcp==4.0.0b1"]
+dependencies = ["fastmcp==4.0.0b3"]
[tool.uv]
-constraint-dependencies = ["fastmcp-slim==4.0.0b1"]
+constraint-dependencies = ["fastmcp-slim==4.0.0b3"]
```
Then run `uv lock` or `uv sync` normally. Naming the one package keeps the rest of your graph on stable releases, where `--prerelease allow` would opt every dependency into prereleases. The MCP SDK needs no constraint at all now that it ships stable releases — pinning `mcp==2.0.0b2` here would in fact break the resolution, since a prerelease does not satisfy FastMCP's own `mcp>=2.0.0` requirement.
@@ -307,14 +307,12 @@ The extension ships in a separate package, so the pin from [Install the v4 Prere
```toml
[project]
-dependencies = ["fastmcp[tasks]==4.0.0b1"]
+dependencies = ["fastmcp[tasks]==4.0.0b3"]
[tool.uv]
constraint-dependencies = [
- "fastmcp-slim==4.0.0b1",
- "fastmcp-tasks==4.0.0b1",
- "mcp==2.0.0b2",
- "mcp-types==2.0.0b2",
+ "fastmcp-slim==4.0.0b3",
+ "fastmcp-tasks==4.0.0b3",
]
```
diff --git a/docs/getting-started/upgrading/from-low-level-sdk-v1.mdx b/docs/getting-started/upgrading/from-low-level-sdk-v1.mdx
index 35f5412bb..d40fa182a 100644
--- a/docs/getting-started/upgrading/from-low-level-sdk-v1.mdx
+++ b/docs/getting-started/upgrading/from-low-level-sdk-v1.mdx
@@ -81,15 +81,13 @@ For each item found, show the original code, say what it did, and give the FastM
## Install
-FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
+FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
```bash
-pip install "fastmcp==4.0.0b1"
-# or
-uv add "fastmcp==4.0.0b1"
+pip install "fastmcp==4.0.0b3"
```
-An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
+An exact pip pin installs even though it's a prerelease; it does not need `--pre`. uv also needs an explicit constraint for the transitive `fastmcp-slim` prerelease, so follow [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for a reproducible uv setup.
FastMCP depends on the `mcp` package, so the SDK stays installed. FastMCP 4 builds on SDK v2, where the protocol types live in a standalone `mcp_types` package that stays importable as `mcp.types`. Most of your `mcp.types` imports disappear entirely in the rewrite below, since FastMCP derives the protocol types from your function signatures.
diff --git a/docs/getting-started/upgrading/from-low-level-sdk-v2.mdx b/docs/getting-started/upgrading/from-low-level-sdk-v2.mdx
index f4222c0f0..89d384b72 100644
--- a/docs/getting-started/upgrading/from-low-level-sdk-v2.mdx
+++ b/docs/getting-started/upgrading/from-low-level-sdk-v2.mdx
@@ -66,15 +66,13 @@ For each item found, show the original code, say what it did, and give the FastM
## Install
-FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
+FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
```bash
-pip install "fastmcp==4.0.0b1"
-# or
-uv add "fastmcp==4.0.0b1"
+pip install "fastmcp==4.0.0b3"
```
-An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
+An exact pip pin installs even though it's a prerelease; it does not need `--pre`. uv also needs an explicit constraint for the transitive `fastmcp-slim` prerelease, so follow [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for a reproducible uv setup.
FastMCP 4 depends on the MCP SDK v2 you are already using, so `mcp_types` stays importable and every protocol type keeps its current name and fields. Most of those imports vanish from your code anyway — FastMCP derives them — but the ones you keep need no changes.
diff --git a/docs/getting-started/upgrading/from-mcp-sdk-v1.mdx b/docs/getting-started/upgrading/from-mcp-sdk-v1.mdx
index ec5ac5b7e..3ef03ee97 100644
--- a/docs/getting-started/upgrading/from-mcp-sdk-v1.mdx
+++ b/docs/getting-started/upgrading/from-mcp-sdk-v1.mdx
@@ -51,15 +51,13 @@ If you have already moved to SDK v2 and write against `MCPServer` today, see [Up
## Install
-FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
+FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
```bash
-pip install "fastmcp==4.0.0b1"
-# or
-uv add "fastmcp==4.0.0b1"
+pip install "fastmcp==4.0.0b3"
```
-An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
+An exact pip pin installs even though it's a prerelease; it does not need `--pre`. uv also needs an explicit constraint for the transitive `fastmcp-slim` prerelease, so follow [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for a reproducible uv setup.
FastMCP depends on the `mcp` package, so the SDK stays installed and importable. What changes is which parts of it you reach for. FastMCP 4 builds on SDK v2, where `mcp.server.fastmcp` is gone — anything you imported from it needs a new home, and the sections below cover that. `mcp.types` still resolves (it aliases the standalone `mcp_types` package), though its fields are snake_case now. Update your import, run your server, and if your tools work, you're done.
diff --git a/docs/getting-started/upgrading/from-mcp-sdk-v2.mdx b/docs/getting-started/upgrading/from-mcp-sdk-v2.mdx
index e25489005..e49060398 100644
--- a/docs/getting-started/upgrading/from-mcp-sdk-v2.mdx
+++ b/docs/getting-started/upgrading/from-mcp-sdk-v2.mdx
@@ -91,15 +91,13 @@ For each item found, show the original code, name what changed, and give the Fas
## Install
-FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
+FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` resolves to the latest *stable* release, which today is FastMCP 3:
```bash
-pip install "fastmcp==4.0.0b1"
-# or
-uv add "fastmcp==4.0.0b1"
+pip install "fastmcp==4.0.0b3"
```
-An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease).
+An exact pip pin installs even though it's a prerelease; it does not need `--pre`. uv also needs an explicit constraint for the transitive `fastmcp-slim` prerelease, so follow [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for a reproducible uv setup.
FastMCP 4 depends on the MCP SDK v2, so nothing you already import from `mcp_types` moves. That is the practical benefit of migrating at this version rather than an earlier one: you and FastMCP are on the same protocol layer, with the same snake_case field names and the same type package, so the migration touches only the server API.
From 37321449d91e3f3f3db321505a69126ac1b8601c Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Fri, 14 Aug 2026 13:43:56 -0400
Subject: [PATCH 19/27] docs: add FastMCP 4 beta 3 release entries (#4841)
---
docs/changelog.mdx | 38 ++++++++++++++++++++++++++++++++++++++
docs/docs.json | 2 +-
docs/updates.mdx | 18 ++++++++++++++++++
3 files changed, 57 insertions(+), 1 deletion(-)
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
index 82aeb032d..813893a0a 100644
--- a/docs/changelog.mdx
+++ b/docs/changelog.mdx
@@ -5,6 +5,44 @@ rss: true
tag: NEW
---
+
+
+**[v4.0.0b3: Fast Fourward](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b3)**
+
+FastMCP 4 beta 3 moves the v4 line toward general availability with Prefect Horizon authentication, `CallArgument` and `Depends` bindings for tools and background tasks, and a round of OAuth, proxy, OpenAPI, and Python 3.14 compatibility hardening.
+
+### Enhancements ✨
+* Add Prefect Horizon authentication client and local state by [@parkedwards](https://github.com/parkedwards) in [#4785](https://github.com/PrefectHQ/fastmcp/pull/4785)
+* Clarify auto-closed PR message by [@jlowin](https://github.com/jlowin) in [#4820](https://github.com/PrefectHQ/fastmcp/pull/4820)
+* Support CallArgument and Depends bindings from uncalled-for 0.4.0 by [@chrisguidry](https://github.com/chrisguidry) in [#4802](https://github.com/PrefectHQ/fastmcp/pull/4802)
+* Fix static analysis under newer ty releases by [@zzstoatzz](https://github.com/zzstoatzz) in [#4831](https://github.com/PrefectHQ/fastmcp/pull/4831)
+* Cover CallArgument resolution in background tasks by [@zzstoatzz](https://github.com/zzstoatzz) in [#4833](https://github.com/PrefectHQ/fastmcp/pull/4833)
+* Scalekit issuer updates backward compatibility by [@AkshayParihar33](https://github.com/AkshayParihar33) in [#4798](https://github.com/PrefectHQ/fastmcp/pull/4798)
+
+### Security 🔒
+* Add audience pinning to GoogleTokenVerifier by [@zzstoatzz](https://github.com/zzstoatzz) in [#4827](https://github.com/PrefectHQ/fastmcp/pull/4827)
+* Bump cryptography to 50.0.0 by [@zzstoatzz](https://github.com/zzstoatzz) in [#4836](https://github.com/PrefectHQ/fastmcp/pull/4836)
+
+### Fixes 🐞
+* Fix partial parameter hints on Python 3.14 by [@zzstoatzz](https://github.com/zzstoatzz) in [#4796](https://github.com/PrefectHQ/fastmcp/pull/4796)
+* fix(openapi): extract parameter-level example and examples by [@doneman536](https://github.com/doneman536) in [#4793](https://github.com/PrefectHQ/fastmcp/pull/4793)
+* Keep earlier consent CSRF tokens valid within a transaction by [@trevhud](https://github.com/trevhud) in [#4818](https://github.com/PrefectHQ/fastmcp/pull/4818)
+* Fix StatefulProxyClient reconnection after session failure by [@jlowin](https://github.com/jlowin) in [#4829](https://github.com/PrefectHQ/fastmcp/pull/4829)
+
+### Docs 📚
+* Docs language dropdown by [@znicholasbrown](https://github.com/znicholasbrown) in [#4801](https://github.com/PrefectHQ/fastmcp/pull/4801)
+* Docs: mirror v3.4.7 release notes by [@jlowin](https://github.com/jlowin) in [#4811](https://github.com/PrefectHQ/fastmcp/pull/4811)
+* docs: prepare FastMCP 4 beta 3 by [@jlowin](https://github.com/jlowin) in [#4840](https://github.com/PrefectHQ/fastmcp/pull/4840)
+* docs: add FastMCP 4 beta 3 release entries by [@jlowin](https://github.com/jlowin) in [#4841](https://github.com/PrefectHQ/fastmcp/pull/4841)
+
+## New Contributors
+* @parkedwards made their first contribution in [#4785](https://github.com/PrefectHQ/fastmcp/pull/4785)
+* @trevhud made their first contribution in [#4818](https://github.com/PrefectHQ/fastmcp/pull/4818)
+
+**Full Changelog**: [v4.0.0b2...v4.0.0b3](https://github.com/PrefectHQ/fastmcp/compare/v4.0.0b2...v4.0.0b3)
+
+
+
**[v3.4.7: Know Your Audience](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.7)**
diff --git a/docs/docs.json b/docs/docs.json
index c52daada0..3abd86a72 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -16,7 +16,7 @@
"dark": "#475569",
"light": "#1e3a5f"
},
- "content": "FastMCP 4 is in beta — build stateful applications on sessionless MCP. [See what's new](/getting-started/whats-new)."
+ "content": "FastMCP 4 beta is here — get the latest MCP protocol. [See what's new](/getting-started/whats-new)."
},
"colors": {
"dark": "#f72585",
diff --git a/docs/updates.mdx b/docs/updates.mdx
index ff5ae3023..dc23136ac 100644
--- a/docs/updates.mdx
+++ b/docs/updates.mdx
@@ -5,6 +5,24 @@ icon: "sparkles"
tag: NEW
---
+
+
+FastMCP 4 beta 3 moves the v4 line toward general availability with new authentication and dependency-injection capabilities, plus compatibility hardening across OAuth, proxies, OpenAPI, and Python 3.14.
+
+🔐 **Authentication foundations** — Prefect Horizon gains a native authentication client and local state, Google token verification can pin audiences, and Scalekit issuer updates preserve backward compatibility.
+
+🧰 **Tool dependencies** — `CallArgument` and `Depends` bindings from `uncalled-for` 0.4 work in regular tools and background tasks.
+
+🔄 **Runtime reliability** — stateful proxy clients reconnect after session failures, consent transactions keep valid earlier CSRF tokens, and partial parameter hints work on Python 3.14.
+
+🧾 **OpenAPI fidelity** — parameter-level `example` and `examples` values now flow into generated tool schemas.
+
+
+
Date: Fri, 14 Aug 2026 13:45:26 -0500
Subject: [PATCH 20/27] chore(deps): Update j178/prek-action action to v3
(#4808)
Co-authored-by: prefect-renovate[bot] <313130218+prefect-renovate[bot]@users.noreply.github.com>
Co-authored-by: nate nowack
---
.github/workflows/run-static.yml | 2 +-
.github/workflows/run-upgrade-checks.yml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/run-static.yml b/.github/workflows/run-static.yml
index 8297ef413..98e04ee9a 100644
--- a/.github/workflows/run-static.yml
+++ b/.github/workflows/run-static.yml
@@ -37,6 +37,6 @@ jobs:
resolution: locked
- name: Run prek
- uses: j178/prek-action@v2
+ uses: j178/prek-action@v3.0.0
env:
SKIP: no-commit-to-branch
diff --git a/.github/workflows/run-upgrade-checks.yml b/.github/workflows/run-upgrade-checks.yml
index 485cf1919..fb532f3ba 100644
--- a/.github/workflows/run-upgrade-checks.yml
+++ b/.github/workflows/run-upgrade-checks.yml
@@ -38,7 +38,7 @@ jobs:
resolution: upgrade
- name: Run prek
- uses: j178/prek-action@v2
+ uses: j178/prek-action@v3.0.0
env:
SKIP: no-commit-to-branch
From 542cde677ba36ce2bc9a8c73401b69d6efd44b02 Mon Sep 17 00:00:00 2001
From: "prefect-renovate[bot]"
<313130218+prefect-renovate[bot]@users.noreply.github.com>
Date: Fri, 14 Aug 2026 13:49:31 -0500
Subject: [PATCH 21/27] chore(deps): Update dependency node to v24 (#4807)
Co-authored-by: prefect-renovate[bot] <313130218+prefect-renovate[bot]@users.noreply.github.com>
Co-authored-by: nate nowack
---
.github/workflows/run-tests.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index 7db2b5865..b94e062c7 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -90,7 +90,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
- node-version: "22"
+ node-version: "24"
- name: Run conformance tests
uses: ./.github/actions/run-pytest
From c9cf23779e2cf7c3273165736b5defcdbd19738f Mon Sep 17 00:00:00 2001
From: "prefect-renovate[bot]"
<313130218+prefect-renovate[bot]@users.noreply.github.com>
Date: Fri, 14 Aug 2026 13:56:10 -0500
Subject: [PATCH 22/27] chore(deps): Update astral-sh/setup-uv action to v9
(#4806)
Co-authored-by: prefect-renovate[bot] <313130218+prefect-renovate[bot]@users.noreply.github.com>
Co-authored-by: nate nowack
---
.github/actions/setup-uv/action.yml | 2 +-
.github/workflows/auto-close-duplicates.yml | 2 +-
.github/workflows/auto-close-needs-mre.yml | 2 +-
.github/workflows/marvin-comment-on-issue.yml | 2 +-
.github/workflows/marvin-comment-on-pr.yml | 2 +-
.github/workflows/marvin-test-failure.yml | 2 +-
.github/workflows/publish-fastmcp-remote.yml | 2 +-
.github/workflows/publish-fastmcp-slim.yml | 2 +-
.github/workflows/publish-fastmcp-tasks.yml | 2 +-
.github/workflows/publish-fastmcp.yml | 2 +-
.github/workflows/run-schema-crash-test.yml | 2 +-
.github/workflows/update-config-schema.yml | 2 +-
.github/workflows/update-sdk-docs.yml | 2 +-
13 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/.github/actions/setup-uv/action.yml b/.github/actions/setup-uv/action.yml
index 0becaffad..0697b8cde 100644
--- a/.github/actions/setup-uv/action.yml
+++ b/.github/actions/setup-uv/action.yml
@@ -15,7 +15,7 @@ runs:
using: "composite"
steps:
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml
index a5606e5ff..a58115b46 100644
--- a/.github/workflows/auto-close-duplicates.yml
+++ b/.github/workflows/auto-close-duplicates.yml
@@ -26,7 +26,7 @@ jobs:
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v9.0.0
- name: Auto-close duplicate issues
run: uv run scripts/auto_close_duplicates.py
diff --git a/.github/workflows/auto-close-needs-mre.yml b/.github/workflows/auto-close-needs-mre.yml
index 08428ab0c..ef041abb1 100644
--- a/.github/workflows/auto-close-needs-mre.yml
+++ b/.github/workflows/auto-close-needs-mre.yml
@@ -26,7 +26,7 @@ jobs:
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v9.0.0
- name: Auto-close needs MRE issues
run: uv run scripts/auto_close_needs_mre.py
diff --git a/.github/workflows/marvin-comment-on-issue.yml b/.github/workflows/marvin-comment-on-issue.yml
index 72c38cdf7..d795719ca 100644
--- a/.github/workflows/marvin-comment-on-issue.yml
+++ b/.github/workflows/marvin-comment-on-issue.yml
@@ -28,7 +28,7 @@ jobs:
uses: actions/checkout@v7
- name: Install UV
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
diff --git a/.github/workflows/marvin-comment-on-pr.yml b/.github/workflows/marvin-comment-on-pr.yml
index 369a90c6b..276fab800 100644
--- a/.github/workflows/marvin-comment-on-pr.yml
+++ b/.github/workflows/marvin-comment-on-pr.yml
@@ -30,7 +30,7 @@ jobs:
fetch-depth: 0
- name: Install UV
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
diff --git a/.github/workflows/marvin-test-failure.yml b/.github/workflows/marvin-test-failure.yml
index c0c532b23..6b2a332af 100644
--- a/.github/workflows/marvin-test-failure.yml
+++ b/.github/workflows/marvin-test-failure.yml
@@ -41,7 +41,7 @@ jobs:
# Install UV package manager
- name: Install UV
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v9.0.0
# Install dependencies
- name: Install dependencies
diff --git a/.github/workflows/publish-fastmcp-remote.yml b/.github/workflows/publish-fastmcp-remote.yml
index 9e2c67990..a2ee35966 100644
--- a/.github/workflows/publish-fastmcp-remote.yml
+++ b/.github/workflows/publish-fastmcp-remote.yml
@@ -24,7 +24,7 @@ jobs:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v9.0.0
- name: Build fastmcp-remote
run: uv build --package fastmcp-remote
diff --git a/.github/workflows/publish-fastmcp-slim.yml b/.github/workflows/publish-fastmcp-slim.yml
index 9fdc69628..b4d6ce0a3 100644
--- a/.github/workflows/publish-fastmcp-slim.yml
+++ b/.github/workflows/publish-fastmcp-slim.yml
@@ -21,7 +21,7 @@ jobs:
fetch-depth: 0
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v9.0.0
- name: Build fastmcp-slim
run: uv build --package fastmcp-slim
diff --git a/.github/workflows/publish-fastmcp-tasks.yml b/.github/workflows/publish-fastmcp-tasks.yml
index 29fc38554..1aec0c89f 100644
--- a/.github/workflows/publish-fastmcp-tasks.yml
+++ b/.github/workflows/publish-fastmcp-tasks.yml
@@ -38,7 +38,7 @@ jobs:
fi
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v9.0.0
- name: Build fastmcp-tasks
if: steps.package_present.outputs.present == 'true'
diff --git a/.github/workflows/publish-fastmcp.yml b/.github/workflows/publish-fastmcp.yml
index 8b2ce33b2..76e789291 100644
--- a/.github/workflows/publish-fastmcp.yml
+++ b/.github/workflows/publish-fastmcp.yml
@@ -27,7 +27,7 @@ jobs:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v9.0.0
- name: Build fastmcp
run: uv build --package fastmcp
diff --git a/.github/workflows/run-schema-crash-test.yml b/.github/workflows/run-schema-crash-test.yml
index 6c9c766fb..618db999e 100644
--- a/.github/workflows/run-schema-crash-test.yml
+++ b/.github/workflows/run-schema-crash-test.yml
@@ -37,7 +37,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v9.0.0
- name: Set up Python
run: uv python install 3.12
diff --git a/.github/workflows/update-config-schema.yml b/.github/workflows/update-config-schema.yml
index 6600981da..d17f9f857 100644
--- a/.github/workflows/update-config-schema.yml
+++ b/.github/workflows/update-config-schema.yml
@@ -33,7 +33,7 @@ jobs:
token: ${{ steps.marvin-token.outputs.token }}
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
diff --git a/.github/workflows/update-sdk-docs.yml b/.github/workflows/update-sdk-docs.yml
index 9d05684d4..aba01ac52 100644
--- a/.github/workflows/update-sdk-docs.yml
+++ b/.github/workflows/update-sdk-docs.yml
@@ -33,7 +33,7 @@ jobs:
token: ${{ steps.marvin-token.outputs.token }}
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
From 0d86007617876558ddf7119291e0343e4b9398ac Mon Sep 17 00:00:00 2001
From: Eddie
Date: Mon, 17 Aug 2026 20:11:19 -0700
Subject: [PATCH 23/27] Add Prefect Horizon account commands (#4786)
* feat: add Horizon account commands
* feat: add a Horizon host option to login
* feat: refine Horizon account output
* style: refine Horizon output headings
* feat: describe device authorization requests
* fix: preserve Horizon command contracts
* fix: preserve environment credentials on logout
* fix: keep Horizon state reads consistent
* docs: hide the Horizon host override
---
docs/cli/overview.mdx | 35 ++
docs/deployment/prefect-horizon.mdx | 33 ++
fastmcp_slim/fastmcp/cli/cli.py | 6 +
fastmcp_slim/fastmcp/cli/deploy/command.py | 398 +++++++++++++++
.../fastmcp/cli/deploy/credentials.py | 27 +
fastmcp_slim/fastmcp/cli/deploy/output.py | 297 +++++++++++
tests/cli/deploy/test_command.py | 462 ++++++++++++++++++
tests/cli/deploy/test_credentials.py | 23 +
tests/cli/deploy/test_output.py | 167 +++++++
tests/cli/test_cli.py | 14 +
10 files changed, 1462 insertions(+)
create mode 100644 fastmcp_slim/fastmcp/cli/deploy/command.py
create mode 100644 fastmcp_slim/fastmcp/cli/deploy/output.py
create mode 100644 tests/cli/deploy/test_command.py
create mode 100644 tests/cli/deploy/test_output.py
diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx
index 9085daaa8..675514ecc 100644
--- a/docs/cli/overview.mdx
+++ b/docs/cli/overview.mdx
@@ -28,6 +28,9 @@ fastmcp --help
| [`generate-cli`](/cli/generate-cli) | Scaffold a standalone typed CLI from a server's tool schemas |
| [`project prepare`](/cli/running#pre-building-environments) | Pre-install dependencies into a reusable uv project |
| [`auth cimd`](/cli/auth) | Create and validate CIMD documents for OAuth |
+| `login` | Sign in to Prefect Horizon with a browser device flow |
+| `whoami` | Show the current Horizon account |
+| `logout` | Revoke the current Horizon key and remove the local credential |
| `version` | Print version info (`--copy` to copy to clipboard) |
## Server Targets
@@ -81,6 +84,38 @@ Run [`fastmcp discover`](/cli/client#discovering-configured-servers) to see what
## Authentication
+### Prefect Horizon Account
+
+Use the top-level account commands to manage the credential for Prefect Horizon.
+
+```bash
+fastmcp login
+fastmcp whoami
+fastmcp logout
+```
+
+`fastmcp login` first uses `HORIZON_API_KEY` or a valid stored key when one is available.
+When login needs a new key, it shows a verification URL and code.
+It opens a browser when the terminal supports it.
+If the browser does not open, use the shown URL and code on another device.
+To switch accounts, run `fastmcp logout` before you run `fastmcp login` again.
+
+Login stores only the personal Horizon API key.
+It does not select or store a deployment organization.
+`fastmcp whoami` gets the current user from Horizon.
+`fastmcp logout` attempts to revoke the stored key and always removes its local credential.
+
+Set `HORIZON_API_KEY` to use an environment credential instead.
+The CLI gives that value first precedence and never stores it.
+When this variable controls the session, logout does not revoke or remove any credential.
+Remove the variable from your environment to sign out.
+
+Use `--json` for stable command results.
+During JSON login, the verification challenge goes to stderr and the final result goes to stdout.
+JSON mode does not open a browser or ask a question.
+
+### MCP Server Authentication
+
When targeting an HTTP URL, the CLI enables OAuth authentication by default. If the server requires it, you'll be guided through the flow (typically opening a browser). If it doesn't, the setup is a silent no-op.
To skip authentication entirely — useful for local development servers — pass `--auth none`:
diff --git a/docs/deployment/prefect-horizon.mdx b/docs/deployment/prefect-horizon.mdx
index 68f157c52..9ff6c5356 100644
--- a/docs/deployment/prefect-horizon.mdx
+++ b/docs/deployment/prefect-horizon.mdx
@@ -13,6 +13,39 @@ Horizon includes a **free personal tier for FastMCP users**, making it the faste
Horizon is free for personal projects. Enterprise governance features are available for teams deploying to thousands of users.
+## FastMCP CLI Account
+
+Sign in to Horizon from the FastMCP CLI with the device authorization flow.
+
+```bash
+fastmcp login
+```
+
+The command uses an environment key or a valid stored key when one is available.
+When login needs a new key, it shows a verification URL and code before it opens the browser.
+If the browser cannot open, visit the shown URL and enter the code.
+To switch accounts, run `fastmcp logout` before you run `fastmcp login` again.
+New users can register and create their first Horizon organization in the browser.
+
+Check the active account after login.
+
+```bash
+fastmcp whoami
+```
+
+Remove the local credential and revoke the active personal API key when possible.
+
+```bash
+fastmcp logout
+```
+
+Login does not select or store a deployment organization.
+
+For an agent or a CI process, set `HORIZON_API_KEY` instead of storing a key.
+The CLI never writes the environment value to its credential file.
+When this variable controls the session, logout does not revoke or remove any credential.
+Remove the variable from the environment to sign out.
+
## The Platform
Horizon is organized into four integrated pillars:
diff --git a/fastmcp_slim/fastmcp/cli/cli.py b/fastmcp_slim/fastmcp/cli/cli.py
index 5513e3119..22c0c4596 100644
--- a/fastmcp_slim/fastmcp/cli/cli.py
+++ b/fastmcp_slim/fastmcp/cli/cli.py
@@ -21,6 +21,7 @@ import fastmcp
from fastmcp.cli import run as run_module
from fastmcp.cli.auth import auth_app
from fastmcp.cli.client import call_command, discover_command, list_command
+from fastmcp.cli.deploy.command import login, logout, whoami
from fastmcp.cli.generate import generate_cli_command
from fastmcp.cli.install import install_app
from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config
@@ -1134,6 +1135,11 @@ app.command(generate_cli_command, name="generate-cli")
# Add auth subcommand group (includes CIMD commands)
app.command(auth_app)
+# Add Prefect Horizon account commands
+app.command(login)
+app.command(logout)
+app.command(whoami)
+
if __name__ == "__main__":
app()
diff --git a/fastmcp_slim/fastmcp/cli/deploy/command.py b/fastmcp_slim/fastmcp/cli/deploy/command.py
new file mode 100644
index 000000000..a0a256c9e
--- /dev/null
+++ b/fastmcp_slim/fastmcp/cli/deploy/command.py
@@ -0,0 +1,398 @@
+"""Public Prefect Horizon authentication commands."""
+
+from __future__ import annotations
+
+import os
+import platform
+import sys
+import webbrowser
+from typing import Annotated, NoReturn
+
+from cyclopts import Parameter
+from pydantic import SecretStr
+from rich.status import Status
+
+import fastmcp
+from fastmcp.cli.deploy.authentication import (
+ DeviceAuthorizationDeniedError,
+ DeviceAuthorizationError,
+ DeviceAuthorizationExpiredError,
+ authorize_device,
+)
+from fastmcp.cli.deploy.configuration import (
+ ConfigurationStore,
+ HorizonConfiguration,
+)
+from fastmcp.cli.deploy.credentials import (
+ AuthenticationRequiredError,
+ CredentialStore,
+ ResolvedCredential,
+)
+from fastmcp.cli.deploy.horizon_client import (
+ DeviceAuthorization,
+ DeviceMetadata,
+ HorizonClient,
+ HorizonResponseError,
+ HorizonUnauthorizedError,
+ HorizonUnavailableError,
+ HorizonUser,
+)
+from fastmcp.cli.deploy.output import (
+ CommandName,
+ ErrorCategory,
+ emit_device_challenge,
+ emit_environment_logout,
+ emit_error,
+ emit_identity,
+ emit_logout,
+ start_device_approval_status,
+ stop_device_approval_status,
+)
+from fastmcp.cli.deploy.state import StateFileError, state_lock
+
+JsonOption = Annotated[
+ bool,
+ Parameter(
+ name="--json",
+ help="Write one final JSON result to stdout",
+ negative=(),
+ ),
+]
+HostOption = Annotated[
+ str | None,
+ Parameter(
+ name="--host",
+ help="Use and save a different Horizon host URL",
+ ),
+]
+
+
+def _can_open_browser() -> bool:
+ return sys.stdin.isatty() and sys.stdout.isatty()
+
+
+def _device_metadata() -> DeviceMetadata:
+ return DeviceMetadata(
+ device_name=platform.node() or None,
+ platform=platform.system().lower() or None,
+ architecture=platform.machine().lower() or None,
+ client_version=fastmcp.__version__,
+ )
+
+
+def _load_session_snapshot(
+ credentials: CredentialStore,
+) -> tuple[HorizonConfiguration, ResolvedCredential | None]:
+ with state_lock(credentials.path.parent):
+ configuration = ConfigurationStore(credentials.path.parent).load()
+ environment_key = os.environ.get("HORIZON_API_KEY")
+ if environment_key:
+ credential = ResolvedCredential(
+ api_key=SecretStr(environment_key),
+ source="environment",
+ )
+ else:
+ stored_key = credentials.load()
+ credential = (
+ ResolvedCredential(api_key=stored_key, source="stored")
+ if stored_key is not None
+ else None
+ )
+ return configuration, credential
+
+
+def _fail(
+ command: CommandName,
+ category: ErrorCategory,
+ message: str,
+ *,
+ json_output: bool,
+ details: dict[str, object] | None = None,
+) -> NoReturn:
+ emit_error(
+ command,
+ category,
+ message,
+ json_output=json_output,
+ details=details,
+ )
+ raise SystemExit(1)
+
+
+def _fail_for_expected_error(
+ command: CommandName,
+ error: Exception,
+ *,
+ json_output: bool,
+) -> NoReturn:
+ if isinstance(error, AuthenticationRequiredError):
+ _fail(
+ command,
+ "authentication_required",
+ "Run `fastmcp login` to sign in to Prefect Horizon.",
+ json_output=json_output,
+ )
+ if isinstance(error, HorizonUnauthorizedError):
+ _fail(
+ command,
+ "authentication_invalid",
+ "The Horizon credential is not valid. Run `fastmcp login` again.",
+ json_output=json_output,
+ )
+ if isinstance(error, DeviceAuthorizationDeniedError):
+ _fail(
+ command,
+ "authorization_denied",
+ "The device authorization request was denied.",
+ json_output=json_output,
+ )
+ if isinstance(error, DeviceAuthorizationExpiredError):
+ _fail(
+ command,
+ "authorization_expired",
+ "The device authorization request expired. Run the command again.",
+ json_output=json_output,
+ )
+ if isinstance(error, DeviceAuthorizationError):
+ _fail(
+ command,
+ "authorization_failed",
+ "The device authorization request failed. Run the command again.",
+ json_output=json_output,
+ )
+ if isinstance(error, HorizonUnavailableError):
+ _fail(
+ command,
+ "horizon_unavailable",
+ "The Horizon API is unavailable. Try again later.",
+ json_output=json_output,
+ )
+ if isinstance(error, HorizonResponseError):
+ _fail(
+ command,
+ "horizon_error",
+ "Horizon returned an unexpected response. Try again later.",
+ json_output=json_output,
+ )
+ if isinstance(error, StateFileError):
+ _fail(
+ command,
+ "state_error",
+ "The local Horizon state is invalid.",
+ json_output=json_output,
+ )
+ raise error
+
+
+async def _get_user(
+ api_origin: str,
+ credential: ResolvedCredential,
+) -> HorizonUser:
+ async with HorizonClient(api_origin, api_key=credential.api_key) as client:
+ return await client.get_current_user()
+
+
+async def login(
+ *,
+ host: HostOption = None,
+ json_output: JsonOption = False,
+) -> None:
+ """Sign in to Prefect Horizon."""
+ credentials = CredentialStore()
+
+ try:
+ configuration_store = ConfigurationStore()
+ requested_configuration: HorizonConfiguration | None = None
+ if host is not None:
+ try:
+ requested_configuration = configuration_store.set_api_origin(
+ host,
+ credentials=credentials,
+ )
+ except ValueError:
+ _fail(
+ "login",
+ "invalid_host",
+ "The Horizon host must be an HTTP origin.",
+ json_output=json_output,
+ )
+
+ configuration, credential = _load_session_snapshot(credentials)
+ if (
+ requested_configuration is not None
+ and configuration.api_origin != requested_configuration.api_origin
+ ):
+ raise StateFileError("The Horizon host changed during login")
+
+ async def device_authorization():
+ approval_status: Status | None = None
+
+ def show_challenge(challenge: DeviceAuthorization) -> None:
+ nonlocal approval_status
+ emit_device_challenge(challenge, json_output=json_output)
+ approval_status = start_device_approval_status(json_output=json_output)
+
+ try:
+ async with HorizonClient(configuration.api_origin) as client:
+ return await authorize_device(
+ client,
+ metadata=_device_metadata(),
+ on_challenge=show_challenge,
+ open_browser=not json_output and _can_open_browser(),
+ browser_opener=webbrowser.open,
+ )
+ finally:
+ stop_device_approval_status(approval_status)
+
+ async def interactive_credential() -> ResolvedCredential:
+ api_key = await device_authorization()
+ credentials.save_for_origin(
+ api_key,
+ expected_api_origin=configuration.api_origin,
+ )
+ return ResolvedCredential(api_key=api_key, source="interactive")
+
+ if credential is None:
+ credential = await interactive_credential()
+
+ try:
+ user = await _get_user(
+ configuration.api_origin,
+ credential,
+ )
+ except HorizonUnauthorizedError:
+ if credential.source == "environment":
+ raise
+
+ credentials.clear_if_matches(
+ credential.api_key,
+ expected_api_origin=configuration.api_origin,
+ )
+ if credential.source == "interactive":
+ raise
+
+ credential = await interactive_credential()
+ try:
+ user = await _get_user(
+ configuration.api_origin,
+ credential,
+ )
+ except HorizonUnauthorizedError:
+ credentials.clear_if_matches(
+ credential.api_key,
+ expected_api_origin=configuration.api_origin,
+ )
+ raise
+ except (
+ AuthenticationRequiredError,
+ DeviceAuthorizationError,
+ HorizonResponseError,
+ HorizonUnauthorizedError,
+ HorizonUnavailableError,
+ StateFileError,
+ ) as error:
+ _fail_for_expected_error("login", error, json_output=json_output)
+
+ emit_identity(
+ "login",
+ user,
+ json_output=json_output,
+ )
+
+
+async def whoami(
+ *,
+ json_output: JsonOption = False,
+) -> None:
+ """Show the current Prefect Horizon user."""
+ credentials = CredentialStore()
+ configuration: HorizonConfiguration | None = None
+ credential: ResolvedCredential | None = None
+
+ try:
+ configuration, credential = _load_session_snapshot(credentials)
+ if credential is None:
+ raise AuthenticationRequiredError("Horizon authentication is required")
+ user = await _get_user(
+ configuration.api_origin,
+ credential,
+ )
+ except HorizonUnauthorizedError as error:
+ if (
+ configuration is not None
+ and credential is not None
+ and credential.source == "stored"
+ ):
+ try:
+ credentials.clear_if_matches(
+ credential.api_key,
+ expected_api_origin=configuration.api_origin,
+ )
+ except StateFileError as cleanup_error:
+ _fail_for_expected_error(
+ "whoami",
+ cleanup_error,
+ json_output=json_output,
+ )
+ _fail_for_expected_error("whoami", error, json_output=json_output)
+ except (
+ AuthenticationRequiredError,
+ HorizonResponseError,
+ HorizonUnavailableError,
+ StateFileError,
+ ) as error:
+ _fail_for_expected_error("whoami", error, json_output=json_output)
+
+ emit_identity(
+ "whoami",
+ user,
+ json_output=json_output,
+ )
+
+
+async def logout(
+ *,
+ json_output: JsonOption = False,
+) -> None:
+ """Revoke the current Horizon key and remove the local credential."""
+ credentials = CredentialStore()
+
+ if os.environ.get("HORIZON_API_KEY"):
+ emit_environment_logout(json_output=json_output)
+ return
+
+ try:
+ configuration, credential = _load_session_snapshot(credentials)
+ if credential is None:
+ emit_logout(remote_revoked=False, json_output=json_output)
+ return
+
+ async with HorizonClient(
+ configuration.api_origin,
+ api_key=credential.api_key,
+ ) as client:
+ try:
+ await client.revoke_current_api_key()
+ finally:
+ credentials.clear_if_matches(
+ credential.api_key,
+ expected_api_origin=configuration.api_origin,
+ )
+ except HorizonUnauthorizedError:
+ emit_logout(remote_revoked=False, json_output=json_output)
+ return
+ except (HorizonResponseError, HorizonUnavailableError):
+ _fail(
+ "logout",
+ "remote_revocation_failed",
+ "The local credential was removed, but the remote key can remain active.",
+ json_output=json_output,
+ details={
+ "localCredentialRemoved": True,
+ "remoteCredentialMayRemain": True,
+ },
+ )
+ except StateFileError as error:
+ _fail_for_expected_error("logout", error, json_output=json_output)
+
+ emit_logout(remote_revoked=True, json_output=json_output)
diff --git a/fastmcp_slim/fastmcp/cli/deploy/credentials.py b/fastmcp_slim/fastmcp/cli/deploy/credentials.py
index bd129abee..8ec5f936c 100644
--- a/fastmcp_slim/fastmcp/cli/deploy/credentials.py
+++ b/fastmcp_slim/fastmcp/cli/deploy/credentials.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
+import secrets
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
@@ -96,6 +97,32 @@ class CredentialStore:
raise StateFileError("The Horizon host changed during login")
self.save(api_key)
+ def clear_if_matches(
+ self,
+ api_key: SecretStr | str,
+ *,
+ expected_api_origin: str,
+ ) -> None:
+ """Clear a key only while its Horizon origin and value are active."""
+ from fastmcp.cli.deploy.configuration import ConfigurationStore
+
+ expected_api_origin = normalize_api_origin(expected_api_origin)
+ expected_api_key = (
+ api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
+ )
+ with state_lock(self.path.parent):
+ active_api_origin = ConfigurationStore(self.path.parent).load().api_origin
+ active_api_key = self.load()
+ if (
+ active_api_origin == expected_api_origin
+ and active_api_key is not None
+ and secrets.compare_digest(
+ active_api_key.get_secret_value(),
+ expected_api_key,
+ )
+ ):
+ self.clear()
+
def clear(self) -> None:
remove_state(self.path)
diff --git a/fastmcp_slim/fastmcp/cli/deploy/output.py b/fastmcp_slim/fastmcp/cli/deploy/output.py
new file mode 100644
index 000000000..2fbfaa2ba
--- /dev/null
+++ b/fastmcp_slim/fastmcp/cli/deploy/output.py
@@ -0,0 +1,297 @@
+"""Stable terminal and JSON output for Horizon CLI commands."""
+
+from __future__ import annotations
+
+import json
+import sys
+from typing import Literal
+
+from rich import box
+from rich.align import Align
+from rich.console import Console, Group
+from rich.padding import Padding
+from rich.panel import Panel
+from rich.status import Status
+from rich.table import Table
+from rich.text import Text
+
+from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser
+
+CommandName = Literal["login", "logout", "whoami"]
+ErrorCategory = Literal[
+ "authentication_invalid",
+ "authentication_required",
+ "authorization_denied",
+ "authorization_expired",
+ "authorization_failed",
+ "horizon_error",
+ "horizon_unavailable",
+ "invalid_host",
+ "remote_revocation_failed",
+ "state_error",
+]
+
+console = Console()
+error_console = Console(stderr=True)
+
+
+def _write_json(payload: object, *, stderr: bool = False) -> None:
+ stream = sys.stderr if stderr else sys.stdout
+ print(json.dumps(payload, separators=(",", ":")), file=stream, flush=True)
+
+
+def _banner(title: str, *, style: str) -> Panel:
+ return Panel(
+ Align.center(Text(title, style=f"bold {style}")),
+ box=box.ROUNDED,
+ border_style=style,
+ padding=(0, 1),
+ width=52,
+ )
+
+
+def _account_panel(
+ user: HorizonUser,
+ *,
+ title: str,
+ message: str,
+) -> Panel:
+ name = Text(user.name or user.email, style="bold")
+ details: list[Text] = [name]
+ if user.name:
+ details.append(Text(user.email, style="cyan"))
+ details.extend([Text(), Text(message, style="green")])
+ return Panel(
+ Group(*details),
+ title=Text(title, style="bold green"),
+ title_align="left",
+ box=box.ROUNDED,
+ border_style="green",
+ padding=(1, 2),
+ width=52,
+ )
+
+
+def _format_duration(seconds: int) -> str:
+ if seconds % 60 == 0:
+ minutes = seconds // 60
+ unit = "minute" if minutes == 1 else "minutes"
+ return f"{minutes} {unit}"
+ unit = "second" if seconds == 1 else "seconds"
+ return f"{seconds} {unit}"
+
+
+def emit_device_challenge(
+ authorization: DeviceAuthorization,
+ *,
+ json_output: bool,
+) -> None:
+ """Show a device challenge before polling starts."""
+ if json_output:
+ _write_json(
+ {
+ "event": "device_authorization",
+ "verificationUrl": authorization.verification_uri,
+ "verificationUrlComplete": authorization.verification_uri_complete,
+ "userCode": authorization.user_code,
+ },
+ stderr=True,
+ )
+ return
+
+ console.print()
+ console.print(_banner("Deploy FastMCP on Horizon", style="magenta"))
+ console.print()
+ console.print(Text("✓ Device authorization started", style="bold green"))
+ console.print()
+ console.print(" Open this URL in your browser:")
+ console.print()
+ console.print(
+ Padding(
+ Text(authorization.verification_uri_complete, style="cyan underline"),
+ (0, 2),
+ )
+ )
+ console.print()
+ console.print(" Confirm this code:")
+ console.print()
+ code = Table.grid()
+ code.add_column(justify="center", width=52)
+ code.add_row(Text(authorization.user_code, style="bold"))
+ console.print(code)
+ console.print()
+ expires_in = _format_duration(authorization.expires_in)
+ console.print(Text(f"The request expires in {expires_in}.", style="dim"))
+ console.print(Text("Press Ctrl-C to cancel.", style="dim"))
+ console.print()
+
+
+def start_device_approval_status(*, json_output: bool) -> Status | None:
+ """Start the terminal spinner while the browser approval is pending."""
+ if json_output:
+ return None
+ status = console.status(
+ "[cyan]Waiting for approval in your browser[/cyan]",
+ spinner="dots",
+ spinner_style="cyan",
+ )
+ status.start()
+ return status
+
+
+def stop_device_approval_status(status: Status | None) -> None:
+ """Stop a device approval spinner when one is active."""
+ if status is not None:
+ status.stop()
+
+
+def emit_identity(
+ command: Literal["login", "whoami"],
+ user: HorizonUser,
+ *,
+ json_output: bool,
+) -> None:
+ """Show the authenticated user."""
+ if json_output:
+ _write_json(
+ {
+ "ok": True,
+ "command": command,
+ "user": user.model_dump(mode="json"),
+ }
+ )
+ return
+
+ console.print()
+ if command == "login":
+ panel = _account_panel(
+ user,
+ title="Logged into Horizon",
+ message="You are signed in to FastMCP.",
+ )
+ else:
+ panel = _account_panel(
+ user,
+ title="Horizon Account",
+ message="● Signed in",
+ )
+ console.print(panel)
+ console.print()
+
+
+def emit_environment_logout(*, json_output: bool) -> None:
+ """Explain why logout cannot change an environment credential."""
+ if json_output:
+ _write_json(
+ {
+ "ok": True,
+ "command": "logout",
+ "credentialSource": "environment",
+ "localCredentialRemoved": False,
+ "remoteRevoked": False,
+ }
+ )
+ return
+
+ message = Group(
+ Text("This session uses HORIZON_API_KEY.", style="bold"),
+ Text("Remove it from your environment to sign out."),
+ Text("No credential was revoked or removed.", style="dim"),
+ )
+ console.print()
+ console.print(
+ Panel(
+ message,
+ title=Text("Horizon Account", style="bold cyan"),
+ title_align="left",
+ box=box.ROUNDED,
+ border_style="cyan",
+ padding=(1, 2),
+ width=60,
+ )
+ )
+ console.print()
+
+
+def emit_logout(
+ *,
+ remote_revoked: bool,
+ json_output: bool,
+) -> None:
+ """Show a successful local logout result."""
+ if json_output:
+ _write_json(
+ {
+ "ok": True,
+ "command": "logout",
+ "localCredentialRemoved": True,
+ "remoteRevoked": remote_revoked,
+ }
+ )
+ return
+
+ if remote_revoked:
+ title = "Logged out of Horizon"
+ message = "The Horizon credential was revoked and removed from this device."
+ style = "green"
+ else:
+ title = "Horizon Account"
+ message = "No active Horizon credential remains on this device."
+ style = "cyan"
+
+ console.print()
+ console.print(
+ Panel(
+ Text(message),
+ title=Text(title, style=f"bold {style}"),
+ title_align="left",
+ box=box.ROUNDED,
+ border_style=style,
+ padding=(1, 2),
+ width=60,
+ )
+ )
+ console.print()
+
+
+def emit_error(
+ command: CommandName,
+ category: ErrorCategory,
+ message: str,
+ *,
+ json_output: bool,
+ details: dict[str, object] | None = None,
+) -> None:
+ """Show a stable expected command failure."""
+ if json_output:
+ payload: dict[str, object] = {
+ "ok": False,
+ "command": command,
+ "error": {
+ "category": category,
+ "message": message,
+ },
+ }
+ if details:
+ payload.update(details)
+ _write_json(payload)
+ return
+
+ titles = {
+ "login": "✗ Sign in failed",
+ "logout": "✗ Sign out failed",
+ "whoami": "✗ Account lookup failed",
+ }
+ error_console.print()
+ error_console.print(
+ Panel(
+ Text(message),
+ title=Text(titles[command], style="bold red"),
+ title_align="left",
+ box=box.ROUNDED,
+ border_style="red",
+ padding=(1, 2),
+ width=60,
+ )
+ )
+ error_console.print()
diff --git a/tests/cli/deploy/test_command.py b/tests/cli/deploy/test_command.py
new file mode 100644
index 000000000..ad2a3bb48
--- /dev/null
+++ b/tests/cli/deploy/test_command.py
@@ -0,0 +1,462 @@
+import json
+from collections.abc import Callable, Iterator
+from contextlib import contextmanager
+from pathlib import Path
+from unittest.mock import Mock
+from urllib.parse import parse_qs
+
+import httpx2
+import pytest
+from pydantic import SecretStr
+
+import fastmcp
+import fastmcp.cli.deploy.authentication as authentication_module
+import fastmcp.cli.deploy.command as command_module
+from fastmcp.cli.deploy.command import login, logout, whoami
+from fastmcp.cli.deploy.configuration import ConfigurationStore
+from fastmcp.cli.deploy.credentials import CredentialStore
+from fastmcp.cli.deploy.horizon_client import HorizonClient
+from fastmcp.cli.deploy.state import StateFileError
+
+
+class HorizonAuthAPI:
+ def __init__(
+ self,
+ *,
+ token_error: str | None = None,
+ revoke_status: int = 204,
+ invalid_api_key: str | None = None,
+ on_request: Callable[[httpx2.Request], None] | None = None,
+ ) -> None:
+ self.token_error = token_error
+ self.revoke_status = revoke_status
+ self.invalid_api_key = invalid_api_key
+ self.on_request = on_request
+ self.requests: list[httpx2.Request] = []
+
+ def __call__(self, request: httpx2.Request) -> httpx2.Response:
+ self.requests.append(request)
+ if self.on_request is not None:
+ self.on_request(request)
+ path = request.url.path
+ if path == "/api/v0/oauth/device/authorization":
+ return httpx2.Response(
+ 200,
+ json={
+ "device_code": "device-secret",
+ "user_code": "ABCD-EFGH",
+ "verification_uri": "https://horizon.prefect.io/oauth/device",
+ "verification_uri_complete": (
+ "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
+ ),
+ "expires_in": 600,
+ "interval": 1,
+ },
+ )
+ if path == "/api/v0/oauth/device/token":
+ if self.token_error is not None:
+ return httpx2.Response(400, json={"error": self.token_error})
+ return httpx2.Response(
+ 200,
+ json={"access_token": "fmcp_device_key", "token_type": "Bearer"},
+ )
+ if path == "/api/v0/me":
+ if request.headers.get("Authorization") == (
+ f"Bearer {self.invalid_api_key}"
+ ):
+ return httpx2.Response(401)
+ return httpx2.Response(
+ 200,
+ json={
+ "user": {
+ "id": "user-1",
+ "email": "ada@example.com",
+ "name": "Ada",
+ }
+ },
+ )
+ if path == "/api/v0/me/api-key":
+ return httpx2.Response(self.revoke_status)
+ raise AssertionError(f"Unexpected request: {request.method} {path}")
+
+
+@pytest.fixture
+def use_horizon_api(
+ monkeypatch: pytest.MonkeyPatch,
+) -> Callable[[HorizonAuthAPI], None]:
+ def use(api: HorizonAuthAPI) -> None:
+ transport = httpx2.MockTransport(api)
+
+ def client(
+ api_origin: str,
+ *,
+ api_key: SecretStr | str | None = None,
+ ) -> HorizonClient:
+ return HorizonClient(
+ api_origin,
+ api_key=api_key,
+ transport=transport,
+ )
+
+ monkeypatch.setattr(command_module, "HorizonClient", client)
+
+ return use
+
+
+def test_session_snapshot_reads_host_and_credential_under_one_lock(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ events: list[str] = []
+ configuration_load = ConfigurationStore.load
+ credential_load = CredentialStore.load
+
+ @contextmanager
+ def lock(directory: Path) -> Iterator[None]:
+ events.append("lock")
+ yield
+ events.append("unlock")
+
+ def load_configuration(store: ConfigurationStore):
+ events.append("configuration")
+ return configuration_load(store)
+
+ def load_credential(store: CredentialStore):
+ events.append("credential")
+ return credential_load(store)
+
+ monkeypatch.setattr(command_module, "state_lock", lock)
+ monkeypatch.setattr(ConfigurationStore, "load", load_configuration)
+ monkeypatch.setattr(CredentialStore, "load", load_credential)
+
+ configuration, credential = command_module._load_session_snapshot(CredentialStore())
+
+ assert configuration.api_origin == "https://horizon.prefect.io"
+ assert credential is None
+ assert events == ["lock", "configuration", "credential", "unlock"]
+
+
+@pytest.fixture(autouse=True)
+def no_device_poll_delay(monkeypatch: pytest.MonkeyPatch) -> None:
+ async def sleep(_: float) -> None:
+ return None
+
+ monkeypatch.setattr(authentication_module.asyncio, "sleep", sleep)
+
+
+async def test_json_login_writes_one_result_and_challenge_to_stderr(
+ use_horizon_api: Callable[[HorizonAuthAPI], None],
+ capsys: pytest.CaptureFixture[str],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ api = HorizonAuthAPI()
+ use_horizon_api(api)
+ browser_open = Mock()
+ monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
+ monkeypatch.setattr(command_module.platform, "node", lambda: "Avery's laptop")
+ monkeypatch.setattr(command_module.platform, "system", lambda: "Darwin")
+ monkeypatch.setattr(command_module.platform, "machine", lambda: "arm64")
+ monkeypatch.setattr(command_module.fastmcp, "__version__", "4.0.0")
+
+ await login(json_output=True)
+
+ captured = capsys.readouterr()
+ stdout_lines = captured.out.strip().splitlines()
+ assert len(stdout_lines) == 1
+ assert json.loads(stdout_lines[0]) == {
+ "ok": True,
+ "command": "login",
+ "user": {
+ "id": "user-1",
+ "email": "ada@example.com",
+ "name": "Ada",
+ },
+ }
+ assert json.loads(captured.err) == {
+ "event": "device_authorization",
+ "verificationUrl": "https://horizon.prefect.io/oauth/device",
+ "verificationUrlComplete": (
+ "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
+ ),
+ "userCode": "ABCD-EFGH",
+ }
+ browser_open.assert_not_called()
+ authorization_request = next(
+ request
+ for request in api.requests
+ if request.url.path == "/api/v0/oauth/device/authorization"
+ )
+ assert parse_qs(authorization_request.content.decode()) == {
+ "client_id": ["fastmcp-cli"],
+ "device_name": ["Avery's laptop"],
+ "platform": ["darwin"],
+ "architecture": ["arm64"],
+ "client_version": ["4.0.0"],
+ }
+
+ state = json.loads(CredentialStore().path.read_text())
+ assert state == {"schemaVersion": 1, "apiKey": "fmcp_device_key"}
+ assert not (fastmcp.settings.home / "cli" / "config.json").exists()
+
+
+async def test_login_host_is_saved_before_device_authorization(
+ use_horizon_api: Callable[[HorizonAuthAPI], None],
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ api = HorizonAuthAPI()
+ use_horizon_api(api)
+
+ await login(host="https://dev.horizon.prefect.io/", json_output=True)
+
+ assert json.loads(capsys.readouterr().out)["ok"] is True
+ configuration_path = fastmcp.settings.home / "cli" / "config.json"
+ assert json.loads(configuration_path.read_text()) == {
+ "schemaVersion": 1,
+ "apiOrigin": "https://dev.horizon.prefect.io",
+ }
+ assert {request.url.host for request in api.requests} == {"dev.horizon.prefect.io"}
+
+
+async def test_login_rejects_an_invalid_host(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ with pytest.raises(SystemExit, match="1"):
+ await login(host="https://horizon.prefect.io/path", json_output=True)
+
+ result = json.loads(capsys.readouterr().out)
+ assert result["error"]["category"] == "invalid_host"
+ assert CredentialStore().path.exists() is False
+
+
+async def test_tty_login_survives_browser_open_failure(
+ use_horizon_api: Callable[[HorizonAuthAPI], None],
+ capsys: pytest.CaptureFixture[str],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ use_horizon_api(HorizonAuthAPI())
+ browser_open = Mock(side_effect=OSError("No browser"))
+ monkeypatch.setattr(command_module, "_can_open_browser", lambda: True)
+ monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
+
+ await login()
+
+ output = capsys.readouterr().out
+ assert "https://horizon.prefect.io/oauth/device" in output
+ assert "ABCD-EFGH" in output
+ assert "Logged into Horizon" in output
+ assert "Ada" in output
+ assert "ada@example.com" in output
+ assert "Organization" not in output
+ browser_open.assert_called_once()
+
+
+async def test_whoami_uses_the_stored_key_after_a_restart(
+ use_horizon_api: Callable[[HorizonAuthAPI], None],
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ api = HorizonAuthAPI()
+ use_horizon_api(api)
+ await login(json_output=True)
+ capsys.readouterr()
+
+ await whoami(json_output=True)
+
+ result = json.loads(capsys.readouterr().out)
+ assert result["command"] == "whoami"
+ assert result["user"]["email"] == "ada@example.com"
+ assert [request.url.path for request in api.requests].count("/api/v0/me") == 2
+
+
+async def test_login_replaces_an_invalid_stored_key(
+ use_horizon_api: Callable[[HorizonAuthAPI], None],
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key"))
+ CredentialStore().save("fmcp_stale_key")
+
+ await login(json_output=True)
+
+ captured = capsys.readouterr()
+ assert json.loads(captured.out)["ok"] is True
+ assert json.loads(captured.err)["event"] == "device_authorization"
+ stored_key = CredentialStore().load()
+ assert stored_key is not None
+ assert stored_key.get_secret_value() == "fmcp_device_key"
+
+
+async def test_login_never_persists_an_environment_key(
+ use_horizon_api: Callable[[HorizonAuthAPI], None],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ api = HorizonAuthAPI()
+ use_horizon_api(api)
+ monkeypatch.setenv("HORIZON_API_KEY", "fmcp_environment_key")
+
+ await login(json_output=True)
+
+ assert CredentialStore().path.exists() is False
+ assert not any(
+ request.url.path.startswith("/api/v0/oauth/device") for request in api.requests
+ )
+
+
+async def test_json_whoami_reports_a_failed_rejected_key_cleanup(
+ use_horizon_api: Callable[[HorizonAuthAPI], None],
+ capsys: pytest.CaptureFixture[str],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ use_horizon_api(HorizonAuthAPI(invalid_api_key="fmcp_stale_key"))
+ CredentialStore().save("fmcp_stale_key")
+
+ def fail_clear(store: CredentialStore) -> None:
+ raise StateFileError("cleanup failed")
+
+ monkeypatch.setattr(CredentialStore, "clear", fail_clear)
+
+ with pytest.raises(SystemExit, match="1"):
+ await whoami(json_output=True)
+
+ result = json.loads(capsys.readouterr().out)
+ assert result["error"]["category"] == "state_error"
+
+
+async def test_whoami_does_not_clear_a_newer_host_credential(
+ use_horizon_api: Callable[[HorizonAuthAPI], None],
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ credentials = CredentialStore()
+ credentials.save("fmcp_stale_key")
+ switched = False
+
+ def switch_host(request: httpx2.Request) -> None:
+ nonlocal switched
+ if request.url.path != "/api/v0/me" or switched:
+ return
+ switched = True
+ ConfigurationStore().set_api_origin(
+ "https://dev.horizon.prefect.io",
+ credentials=credentials,
+ )
+ credentials.save_for_origin(
+ "fmcp_new_key",
+ expected_api_origin="https://dev.horizon.prefect.io",
+ )
+
+ api = HorizonAuthAPI(
+ invalid_api_key="fmcp_stale_key",
+ on_request=switch_host,
+ )
+ use_horizon_api(api)
+
+ with pytest.raises(SystemExit, match="1"):
+ await whoami(json_output=True)
+
+ result = json.loads(capsys.readouterr().out)
+ assert result["error"]["category"] == "authentication_invalid"
+ assert ConfigurationStore().load().api_origin == ("https://dev.horizon.prefect.io")
+ stored_key = credentials.load()
+ assert stored_key is not None
+ assert stored_key.get_secret_value() == "fmcp_new_key"
+ assert api.requests[0].url.host == "horizon.prefect.io"
+
+
+async def test_json_whoami_does_not_start_device_authorization(
+ capsys: pytest.CaptureFixture[str],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ browser_open = Mock()
+ monkeypatch.setattr(command_module.webbrowser, "open", browser_open)
+
+ with pytest.raises(SystemExit, match="1"):
+ await whoami(json_output=True)
+
+ result = json.loads(capsys.readouterr().out)
+ assert result["error"]["category"] == "authentication_required"
+ browser_open.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ ("token_error", "category"),
+ [
+ ("access_denied", "authorization_denied"),
+ ("expired_token", "authorization_expired"),
+ ],
+)
+async def test_json_login_reports_stable_device_failures(
+ token_error: str,
+ category: str,
+ use_horizon_api: Callable[[HorizonAuthAPI], None],
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ use_horizon_api(HorizonAuthAPI(token_error=token_error))
+
+ with pytest.raises(SystemExit, match="1"):
+ await login(json_output=True)
+
+ result = json.loads(capsys.readouterr().out)
+ assert result["error"]["category"] == category
+ assert CredentialStore().path.exists() is False
+
+
+async def test_logout_does_not_modify_environment_or_stored_credentials(
+ use_horizon_api: Callable[[HorizonAuthAPI], None],
+ capsys: pytest.CaptureFixture[str],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ api = HorizonAuthAPI()
+ use_horizon_api(api)
+ monkeypatch.setenv("HORIZON_API_KEY", "fmcp_environment_key")
+ CredentialStore().save("fmcp_stored_key")
+
+ await logout(json_output=True)
+
+ assert json.loads(capsys.readouterr().out) == {
+ "ok": True,
+ "command": "logout",
+ "credentialSource": "environment",
+ "localCredentialRemoved": False,
+ "remoteRevoked": False,
+ }
+ stored_key = CredentialStore().load()
+ assert stored_key is not None
+ assert stored_key.get_secret_value() == "fmcp_stored_key"
+ assert api.requests == []
+
+
+async def test_logout_revokes_the_remote_key_and_clears_local_state(
+ use_horizon_api: Callable[[HorizonAuthAPI], None],
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ api = HorizonAuthAPI()
+ use_horizon_api(api)
+ CredentialStore().save("fmcp_stored_key")
+
+ await logout(json_output=True)
+
+ assert json.loads(capsys.readouterr().out) == {
+ "ok": True,
+ "command": "logout",
+ "localCredentialRemoved": True,
+ "remoteRevoked": True,
+ }
+ assert CredentialStore().path.exists() is False
+ assert any(
+ request.method == "DELETE" and request.url.path == "/api/v0/me/api-key"
+ for request in api.requests
+ )
+
+
+async def test_logout_clears_local_state_when_remote_revocation_fails(
+ use_horizon_api: Callable[[HorizonAuthAPI], None],
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ use_horizon_api(HorizonAuthAPI(revoke_status=503))
+ CredentialStore().save("fmcp_stored_key")
+
+ with pytest.raises(SystemExit, match="1"):
+ await logout(json_output=True)
+
+ result = json.loads(capsys.readouterr().out)
+ assert result["error"]["category"] == "remote_revocation_failed"
+ assert result["localCredentialRemoved"] is True
+ assert result["remoteCredentialMayRemain"] is True
+ assert CredentialStore().path.exists() is False
diff --git a/tests/cli/deploy/test_credentials.py b/tests/cli/deploy/test_credentials.py
index 23e98ca62..f62b3dc02 100644
--- a/tests/cli/deploy/test_credentials.py
+++ b/tests/cli/deploy/test_credentials.py
@@ -183,6 +183,29 @@ async def test_interactive_credential_rejects_an_origin_change(
assert store.load() is None
+def test_conditional_clear_preserves_newer_state(tmp_path: Path) -> None:
+ store = CredentialStore(tmp_path)
+ store.save("fmcp_current")
+
+ store.clear_if_matches(
+ "fmcp_different",
+ expected_api_origin="https://horizon.prefect.io",
+ )
+ store.clear_if_matches(
+ "fmcp_current",
+ expected_api_origin="https://dev.horizon.prefect.io",
+ )
+
+ assert load_secret(store).get_secret_value() == "fmcp_current"
+
+ store.clear_if_matches(
+ "fmcp_current",
+ expected_api_origin="https://horizon.prefect.io",
+ )
+
+ assert store.load() is None
+
+
async def test_missing_noninteractive_credential_is_explicit(tmp_path: Path) -> None:
with pytest.raises(AuthenticationRequiredError):
await resolve_credential(CredentialStore(tmp_path), environ={})
diff --git a/tests/cli/deploy/test_output.py b/tests/cli/deploy/test_output.py
new file mode 100644
index 000000000..d445cdaf0
--- /dev/null
+++ b/tests/cli/deploy/test_output.py
@@ -0,0 +1,167 @@
+import json
+
+import pytest
+
+from fastmcp.cli.deploy.horizon_client import DeviceAuthorization, HorizonUser
+from fastmcp.cli.deploy.output import (
+ emit_device_challenge,
+ emit_environment_logout,
+ emit_error,
+ emit_identity,
+ emit_logout,
+)
+
+
+def authorization() -> DeviceAuthorization:
+ return DeviceAuthorization(
+ device_code="device-secret",
+ user_code="ABCD-EFGH",
+ verification_uri="https://horizon.prefect.io/oauth/device",
+ verification_uri_complete=(
+ "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
+ ),
+ expires_in=600,
+ interval=5,
+ )
+
+
+def user() -> HorizonUser:
+ return HorizonUser(id="user-1", email="ada@example.com", name="Ada")
+
+
+def test_json_device_challenge_uses_only_stderr(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ emit_device_challenge(authorization(), json_output=True)
+
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert json.loads(captured.err) == {
+ "event": "device_authorization",
+ "verificationUrl": "https://horizon.prefect.io/oauth/device",
+ "verificationUrlComplete": (
+ "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH"
+ ),
+ "userCode": "ABCD-EFGH",
+ }
+
+
+def test_tty_device_challenge_uses_the_sign_in_layout(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ emit_device_challenge(authorization(), json_output=False)
+
+ output = capsys.readouterr().out
+ assert "│" in output
+ assert "Deploy FastMCP on Horizon" in output
+ assert "✓ Device authorization started" in output
+ assert "https://horizon.prefect.io/oauth/device?user_code=ABCD-EFGH" in output
+ assert "ABCD-EFGH" in output
+ assert "The request expires in 10 minutes." in output
+
+
+def test_json_identity_has_stable_fields(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ emit_identity("login", user(), json_output=True)
+
+ result = json.loads(capsys.readouterr().out)
+ assert result == {
+ "ok": True,
+ "command": "login",
+ "user": {
+ "id": "user-1",
+ "email": "ada@example.com",
+ "name": "Ada",
+ },
+ }
+
+
+def test_tty_identity_uses_an_account_panel(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ emit_identity("whoami", user(), json_output=False)
+
+ output = capsys.readouterr().out
+ assert "│" in output
+ assert "Horizon Account" in output
+ assert "Ada" in output
+ assert "ada@example.com" in output
+ assert "● Signed in" in output
+ assert "Organization" not in output
+
+
+def test_json_error_has_stable_fields(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ emit_error(
+ "logout",
+ "remote_revocation_failed",
+ "The remote key can remain active.",
+ json_output=True,
+ details={
+ "localCredentialRemoved": True,
+ "remoteCredentialMayRemain": True,
+ },
+ )
+
+ result = json.loads(capsys.readouterr().out)
+ assert result == {
+ "ok": False,
+ "command": "logout",
+ "error": {
+ "category": "remote_revocation_failed",
+ "message": "The remote key can remain active.",
+ },
+ "localCredentialRemoved": True,
+ "remoteCredentialMayRemain": True,
+ }
+
+
+def test_tty_environment_logout_explains_that_no_action_was_taken(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ emit_environment_logout(json_output=False)
+
+ output = capsys.readouterr().out
+ assert "Horizon Account" in output
+ assert "This session uses HORIZON_API_KEY." in output
+ assert "Remove it from your environment to sign out." in output
+ assert "No credential was revoked or removed." in output
+
+
+def test_json_environment_logout_has_stable_fields(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ emit_environment_logout(json_output=True)
+
+ assert json.loads(capsys.readouterr().out) == {
+ "ok": True,
+ "command": "logout",
+ "credentialSource": "environment",
+ "localCredentialRemoved": False,
+ "remoteRevoked": False,
+ }
+
+
+def test_tty_logout_uses_the_horizon_header(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ emit_logout(remote_revoked=True, json_output=False)
+
+ output = capsys.readouterr().out
+ assert "Logged out of Horizon" in output
+ assert "│" in output
+
+
+def test_json_logout_has_stable_fields(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ emit_logout(remote_revoked=True, json_output=True)
+
+ assert json.loads(capsys.readouterr().out) == {
+ "ok": True,
+ "command": "logout",
+ "localCredentialRemoved": True,
+ "remoteRevoked": True,
+ }
diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py
index 7100683bc..046b6eb9b 100644
--- a/tests/cli/test_cli.py
+++ b/tests/cli/test_cli.py
@@ -35,6 +35,20 @@ class TestMainCLI:
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
+ @pytest.mark.parametrize("name", ["login", "logout", "whoami"])
+ def test_horizon_account_commands_are_top_level(self, name: str):
+ command, bound, _ = app.parse_args([name, "--json"])
+
+ assert command.__name__ == name # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
+ assert bound.arguments == {"json_output": True}
+
+ def test_login_accepts_a_horizon_host(self):
+ _, bound, _ = app.parse_args(
+ ["login", "--host", "https://dev.horizon.prefect.io"]
+ )
+
+ assert bound.arguments == {"host": "https://dev.horizon.prefect.io"}
+
class TestVersionCommand:
"""Test the version command."""
From b2025212d8bc2618547d15076a118819e960479a Mon Sep 17 00:00:00 2001
From: Jake Kaplan <40362401+jakekaplan@users.noreply.github.com>
Date: Tue, 18 Aug 2026 12:36:24 -0400
Subject: [PATCH 24/27] Fix proxy forwarding of MCP transport headers (#4853)
---
fastmcp_slim/fastmcp/client/dependencies.py | 15 +++
.../fastmcp/client/transports/base.py | 10 +-
.../fastmcp/client/transports/http.py | 12 +-
fastmcp_slim/fastmcp/client/transports/sse.py | 14 +--
.../fastmcp/server/providers/proxy.py | 4 +-
.../providers/proxy/test_proxy_headers.py | 107 ++++++++++++++++++
6 files changed, 142 insertions(+), 20 deletions(-)
create mode 100644 tests/server/providers/proxy/test_proxy_headers.py
diff --git a/fastmcp_slim/fastmcp/client/dependencies.py b/fastmcp_slim/fastmcp/client/dependencies.py
index 54faaefe3..7a8f9f046 100644
--- a/fastmcp_slim/fastmcp/client/dependencies.py
+++ b/fastmcp_slim/fastmcp/client/dependencies.py
@@ -1,6 +1,21 @@
"""Client-side dependency helpers."""
+def _get_forwardable_http_headers() -> dict[str, str]:
+ """Return ambient headers safe to copy onto a new MCP connection.
+
+ MCP transport and routing headers describe one HTTP hop and must be
+ regenerated for the new connection. `Last-Event-ID` likewise belongs to
+ the inbound connection's event stream. Other headers, including
+ authorization and custom proxy headers, are preserved.
+ """
+ return {
+ name: value
+ for name, value in get_http_headers(include={"authorization"}).items()
+ if not name.startswith("mcp-") and name != "last-event-id"
+ }
+
+
def get_http_headers(
include_all: bool = False,
include: set[str] | None = None,
diff --git a/fastmcp_slim/fastmcp/client/transports/base.py b/fastmcp_slim/fastmcp/client/transports/base.py
index 422f3ed9c..9a3d9e09f 100644
--- a/fastmcp_slim/fastmcp/client/transports/base.py
+++ b/fastmcp_slim/fastmcp/client/transports/base.py
@@ -49,10 +49,12 @@ class TransportOptions:
session_class: The ClientSession class to instantiate. Proxies supply a
session that skips output-schema validation, since they relay
results rather than consume them.
- forward_incoming_headers: Whether to forward the inbound request's
- authorization header upstream. Only appropriate for proxies, where
- the caller's credentials are meant to be propagated. Honored by the
- HTTP and SSE transports; ignored by the others.
+ forward_incoming_headers: Whether to forward eligible inbound HTTP
+ headers upstream, including authorization. Hop-specific HTTP headers
+ and MCP transport, routing, and event-stream state are excluded
+ because each backend connection owns that state. Only appropriate
+ for proxies; honored by the HTTP and SSE transports and ignored by
+ the others.
backend_mode: The connect `mode` to give backend clients that a wrapping
transport builds on this client's behalf, so a chain of connections
speaks one protocol era end to end. `None` leaves each backend
diff --git a/fastmcp_slim/fastmcp/client/transports/http.py b/fastmcp_slim/fastmcp/client/transports/http.py
index 3ba827931..11703623d 100644
--- a/fastmcp_slim/fastmcp/client/transports/http.py
+++ b/fastmcp_slim/fastmcp/client/transports/http.py
@@ -20,7 +20,7 @@ from fastmcp.client.auth.client_credentials import (
PrivateKeyJWTOAuthProvider,
)
from fastmcp.client.auth.oauth import OAuth
-from fastmcp.client.dependencies import get_http_headers
+from fastmcp.client.dependencies import _get_forwardable_http_headers
from fastmcp.client.transports.base import (
ClientTransport,
SessionKwargs,
@@ -161,12 +161,12 @@ class StreamableHttpTransport(ClientTransport):
) -> AsyncIterator[ClientSession]:
options = transport_options or TransportOptions()
- # When used in a proxy, forward the inbound request's authorization
- # header to the upstream server. This is off by default so that a
- # plain Client used inside a server tool handler doesn't accidentally
- # leak the caller's credentials to an unrelated remote server.
+ # Proxies preserve eligible inbound headers while starting a distinct
+ # MCP connection with its own transport state.
+ # This is off by default so a plain Client used inside a server tool
+ # handler cannot leak caller headers to an unrelated remote server.
if options.forward_incoming_headers:
- headers = get_http_headers(include={"authorization"}) | self.headers
+ headers = _get_forwardable_http_headers() | self.headers
else:
headers = dict(self.headers)
diff --git a/fastmcp_slim/fastmcp/client/transports/sse.py b/fastmcp_slim/fastmcp/client/transports/sse.py
index ed5602444..86f997b72 100644
--- a/fastmcp_slim/fastmcp/client/transports/sse.py
+++ b/fastmcp_slim/fastmcp/client/transports/sse.py
@@ -21,7 +21,7 @@ from fastmcp.client.auth.client_credentials import (
PrivateKeyJWTOAuthProvider,
)
from fastmcp.client.auth.oauth import OAuth
-from fastmcp.client.dependencies import get_http_headers
+from fastmcp.client.dependencies import _get_forwardable_http_headers
from fastmcp.client.transports.base import (
ClientTransport,
SessionKwargs,
@@ -138,14 +138,12 @@ class SSETransport(ClientTransport):
options = transport_options or TransportOptions()
client_kwargs: dict[str, Any] = {}
- # When used in a proxy, forward the inbound request's authorization
- # header to the upstream server. This is off by default so that a
- # plain Client used inside a server tool handler doesn't accidentally
- # leak the caller's credentials to an unrelated remote server.
+ # Proxies preserve eligible inbound headers while starting a distinct
+ # MCP connection with its own transport state.
+ # This is off by default so a plain Client used inside a server tool
+ # handler cannot leak caller headers to an unrelated remote server.
if options.forward_incoming_headers:
- client_kwargs["headers"] = (
- get_http_headers(include={"authorization"}) | self.headers
- )
+ client_kwargs["headers"] = _get_forwardable_http_headers() | self.headers
else:
client_kwargs["headers"] = dict(self.headers)
diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py
index c6b9ef367..c77a3b9d4 100644
--- a/fastmcp_slim/fastmcp/server/providers/proxy.py
+++ b/fastmcp_slim/fastmcp/server/providers/proxy.py
@@ -96,8 +96,8 @@ class _ForwardingClientSession(ClientSession):
# Settings every proxy-backend connection uses: relay results without policing
-# the backend's output schema, and forward the caller's authorization header
-# upstream (appropriate for a proxy, where credentials are meant to propagate).
+# the backend's output schema, and forward eligible caller headers upstream
+# without inheriting frontend-owned MCP transport state.
PROXY_TRANSPORT_OPTIONS = TransportOptions(
session_class=_ForwardingClientSession,
forward_incoming_headers=True,
diff --git a/tests/server/providers/proxy/test_proxy_headers.py b/tests/server/providers/proxy/test_proxy_headers.py
new file mode 100644
index 000000000..b82147a0c
--- /dev/null
+++ b/tests/server/providers/proxy/test_proxy_headers.py
@@ -0,0 +1,107 @@
+"""Header forwarding across ProxyProvider HTTP hops."""
+
+import json
+from typing import Any
+
+import httpx2
+from mcp import MCPError
+from mcp_types import METHOD_NOT_FOUND
+from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
+
+from fastmcp import FastMCP
+from fastmcp.server.middleware import Middleware
+from fastmcp.server.providers.proxy import ProxyClient, ProxyProvider
+from fastmcp.utilities.tests import asgi_server
+
+
+async def test_proxy_does_not_forward_frontend_mcp_headers_to_legacy_backend():
+ """A modern frontend's transport state does not contaminate a legacy backend."""
+ captured_requests: list[httpx2.Request] = []
+
+ class RejectDiscovery(Middleware):
+ async def on_discover(self, context, call_next):
+ raise MCPError(code=METHOD_NOT_FOUND, message="Method not found")
+
+ backend = FastMCP("Legacy Backend", middleware=[RejectDiscovery()])
+
+ @backend.tool
+ def legacy_ping() -> str:
+ return "pong"
+
+ async with asgi_server(backend) as running_backend:
+
+ async def capture_request(request: httpx2.Request) -> None:
+ captured_requests.append(request)
+
+ def backend_http_client(
+ headers: dict[str, str] | None = None,
+ timeout: httpx2.Timeout | None = None,
+ auth: httpx2.Auth | None = None,
+ **kwargs: Any,
+ ) -> httpx2.AsyncClient:
+ return running_backend.http_client(
+ headers=headers,
+ timeout=timeout,
+ auth=auth,
+ event_hooks={"request": [capture_request]},
+ **kwargs,
+ )
+
+ backend_transport = running_backend.transport(
+ httpx_client_factory=backend_http_client
+ )
+ proxy = FastMCP(
+ "Proxy",
+ providers=[
+ ProxyProvider(lambda: ProxyClient(backend_transport, mode="auto"))
+ ],
+ )
+
+ async with asgi_server(proxy) as running_proxy:
+ async with running_proxy.client(
+ mode="auto",
+ headers={
+ "Authorization": "Bearer frontend-token",
+ "X-Proxy-Custom": "preserved",
+ "Mcp-Name": "frontend-name",
+ "Mcp-Param-Tenant": "frontend-tenant",
+ "Mcp-Session-Id": "frontend-session",
+ "Last-Event-ID": "frontend-event",
+ },
+ ) as client:
+ assert client.protocol_version == LATEST_MODERN_VERSION
+ tools = await client.list_tools()
+
+ assert [tool.name for tool in tools] == ["legacy_ping"]
+
+ def request_for(method: str) -> httpx2.Request:
+ return next(
+ request
+ for request in captured_requests
+ if request.method == "POST"
+ and json.loads(request.content).get("method") == method
+ )
+
+ discover = request_for("server/discover")
+ initialize = request_for("initialize")
+ list_tools = request_for("tools/list")
+
+ assert discover.headers["mcp-protocol-version"] == LATEST_MODERN_VERSION
+ assert discover.headers["mcp-method"] == "server/discover"
+
+ assert "mcp-protocol-version" not in initialize.headers
+ assert "mcp-method" not in initialize.headers
+ initialize_body = json.loads(initialize.content)
+ assert initialize_body["params"]["protocolVersion"] == LATEST_HANDSHAKE_VERSION
+
+ assert list_tools.headers["mcp-protocol-version"] == LATEST_HANDSHAKE_VERSION
+ assert "mcp-method" not in list_tools.headers
+ assert list_tools.headers["mcp-session-id"] != "frontend-session"
+
+ for request in (discover, initialize, list_tools):
+ assert request.headers["authorization"] == "Bearer frontend-token"
+ assert request.headers["x-proxy-custom"] == "preserved"
+ assert "mcp-name" not in request.headers
+ assert "mcp-param-tenant" not in request.headers
+ assert request.headers.get("mcp-session-id") != "frontend-session"
+ assert "last-event-id" not in request.headers
From bfcdfa59ecce41cc957287a866faf2a57ad78f94 Mon Sep 17 00:00:00 2001
From: nate nowack
Date: Tue, 18 Aug 2026 11:42:17 -0500
Subject: [PATCH 25/27] Scope Marvin App token to each job's declared
permissions (#4834)
Co-authored-by: Claude Opus 5 (1M context)
---
.github/workflows/marvin-dedupe-issues.yml | 12 +++++++++++-
.github/workflows/marvin-label-triage.yml | 9 ++++++++-
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/marvin-dedupe-issues.yml b/.github/workflows/marvin-dedupe-issues.yml
index a9de7d0d3..f5a8c7b17 100644
--- a/.github/workflows/marvin-dedupe-issues.yml
+++ b/.github/workflows/marvin-dedupe-issues.yml
@@ -36,6 +36,12 @@ jobs:
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
+ # Match the job's `permissions:` block above. Unscoped, the token
+ # inherits the App installation's full set — which includes
+ # contents: write and actions: write, neither of which this job
+ # declares and both of which end up in the model's shell as GH_TOKEN.
+ permission-contents: read
+ permission-issues: write
- name: Set dedupe prompt
id: dedupe-prompt
@@ -114,8 +120,12 @@ jobs:
prompt: ${{ steps.dedupe-prompt.outputs.PROMPT }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }}
allowed_non_write_users: "*"
+ # No `Bash(gh api:*)`: it reaches every endpoint the token can, which
+ # is the reason marvin-label-triage routes its one write through
+ # .github/scripts/triage-label.sh instead. Dedupe searches, reads and
+ # comments — the four verbs below cover that.
claude_args: |
- --allowedTools "Bash(gh issue view:*)","Bash(gh search:*)","Bash(gh issue list:*)","Bash(gh api:*)","Bash(gh issue comment:*)",Task
+ --allowedTools "Bash(gh issue view:*)","Bash(gh search:*)","Bash(gh issue list:*)","Bash(gh issue comment:*)",Task
settings: |
{
"model": "claude-sonnet-5",
diff --git a/.github/workflows/marvin-label-triage.yml b/.github/workflows/marvin-label-triage.yml
index 2cd4fe7f5..b01ad3844 100644
--- a/.github/workflows/marvin-label-triage.yml
+++ b/.github/workflows/marvin-label-triage.yml
@@ -56,7 +56,14 @@ jobs:
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- owner: PrefectHQ
+ # No `owner:` — with it set and `repositories:` empty the token is
+ # scoped to every repo in the PrefectHQ installation. Triage only
+ # ever touches this one. The permissions below match the job's
+ # `permissions:` block; unscoped the token would also carry
+ # contents: write and actions: write from the App installation.
+ permission-contents: read
+ permission-issues: write
+ permission-pull-requests: write
- name: Set triage prompt
id: triage-prompt
From 92465c7f1fd87e9a47c6319c1ad6f71c8d65a260 Mon Sep 17 00:00:00 2001
From: nate nowack
Date: Tue, 18 Aug 2026 11:42:34 -0500
Subject: [PATCH 26/27] Exclude Cookie from forwarded HTTP headers (#4843)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Exclude Cookie from forwarded HTTP headers
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context)
* Keep cookie readable through CurrentHeaders and document it
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context)
---------
Co-authored-by: Claude Opus 5 (1M context)
---
docs/servers/dependency-injection.mdx | 10 +++-
fastmcp_slim/fastmcp/server/dependencies.py | 18 ++++---
tests/server/http/test_http_dependencies.py | 60 +++++++++++++++++++++
3 files changed, 80 insertions(+), 8 deletions(-)
diff --git a/docs/servers/dependency-injection.mdx b/docs/servers/dependency-injection.mdx
index 8d10b0ca0..0a3770532 100644
--- a/docs/servers/dependency-injection.mdx
+++ b/docs/servers/dependency-injection.mdx
@@ -199,7 +199,15 @@ def get_user_agent() -> str:
return headers.get("user-agent", "Unknown")
```
-By default, problematic headers like `host` and `content-length` are excluded. Use `get_http_headers(include_all=True)` to include all headers.
+By default, problematic headers like `host` and `content-length` are excluded, along with the credential headers `authorization` and `cookie`. Credentials are withheld because most callers forward whatever they receive, and a session cookie scoped to your MCP host should not reach a separate backend origin.
+
+To read a credential header, ask for it by name:
+
+```python
+headers = get_http_headers(include={"cookie"})
+```
+
+`CurrentHeaders()` already includes both credential headers, since it exposes the current request to your handler rather than forwarding it. Use `get_http_headers(include_all=True)` to include every header.
### Access Token
diff --git a/fastmcp_slim/fastmcp/server/dependencies.py b/fastmcp_slim/fastmcp/server/dependencies.py
index a6f06778b..e8006c991 100644
--- a/fastmcp_slim/fastmcp/server/dependencies.py
+++ b/fastmcp_slim/fastmcp/server/dependencies.py
@@ -547,9 +547,9 @@ def get_http_headers(
Never raises an exception, even if there is no active HTTP request (in which case
an empty dict is returned).
- By default, strips problematic headers like `content-length` and `authorization`
- that cause issues if forwarded to downstream services. If `include_all` is True,
- all headers are returned.
+ By default, strips problematic headers like `content-length`, and credential
+ headers like `authorization` and `cookie`, that cause issues if forwarded to
+ downstream services. If `include_all` is True, all headers are returned.
The `include` parameter allows specific headers to be included even if they would
normally be excluded. This is useful for proxy transports that need to forward
@@ -570,6 +570,7 @@ def get_http_headers(
"expect",
"accept",
"authorization",
+ "cookie",
# Proxy-related headers
"proxy-authenticate",
"proxy-authorization",
@@ -1068,7 +1069,10 @@ class _CurrentHeaders(Dependency[dict[str, str]]):
"""Async context manager for HTTP Headers dependency."""
async def __aenter__(self) -> dict[str, str]:
- return get_http_headers(include={"authorization"})
+ # Credential headers are denied by default because most callers forward
+ # what they get. This dependency only exposes the current request to the
+ # handler, so it opts them back in.
+ return get_http_headers(include={"authorization", "cookie"})
async def __aexit__(
self,
@@ -1083,9 +1087,9 @@ def CurrentHeaders() -> dict[str, str]:
"""Get the current HTTP request headers.
This dependency provides access to the HTTP headers for the current request,
- including the authorization header. Returns an empty dictionary when no HTTP
- request is available, making it safe to use in code that might run over any
- transport.
+ including the `authorization` and `cookie` headers, which `get_http_headers()`
+ withholds by default. Returns an empty dictionary when no HTTP request is
+ available, making it safe to use in code that might run over any transport.
Returns:
A dependency that resolves to a dictionary of header name -> value
diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py
index 704baebc3..36be3de6b 100644
--- a/tests/server/http/test_http_dependencies.py
+++ b/tests/server/http/test_http_dependencies.py
@@ -177,6 +177,66 @@ async def test_get_http_headers_excludes_content_type(sse_server: ASGIServer):
assert headers["x-custom-header"] == "should-be-included"
+async def test_get_http_headers_excludes_cookie(sse_server: ASGIServer):
+ """get_http_headers() must not leak the caller's Cookie to a backend.
+
+ The OpenAPI provider forwards this mapping to the upstream named in the
+ spec, so a session cookie scoped to the MCP host would otherwise reach a
+ separate origin on every tool call. Callers that genuinely need it can ask
+ for it back with `include={"cookie"}`, the same escape hatch authorization
+ uses.
+ """
+ from fastmcp.server.dependencies import get_http_headers
+
+ server = FastMCP()
+
+ @server.tool
+ def default_headers() -> dict[str, str]:
+ return get_http_headers()
+
+ @server.tool
+ def opted_in_headers() -> dict[str, str]:
+ return get_http_headers(include={"cookie"})
+
+ async with asgi_server(server, transport="sse") as running_server:
+ async with running_server.client(
+ headers={"Cookie": "session=alice-secret", "X-Keep": "yes"}
+ ) as client:
+ default = (await client.call_tool("default_headers")).data
+ assert "cookie" not in default
+ assert default["x-keep"] == "yes"
+
+ opted_in = (await client.call_tool("opted_in_headers")).data
+ assert opted_in["cookie"] == "session=alice-secret"
+
+
+async def test_current_headers_still_exposes_cookie(sse_server: ASGIServer):
+ """CurrentHeaders() reads the request, so credentials stay visible.
+
+ The default denylist protects call sites that forward headers upstream.
+ A handler inspecting its own request needs the cookie, the same way it
+ already needs authorization.
+ """
+ from fastmcp.server.dependencies import CurrentHeaders
+
+ server = FastMCP()
+
+ @server.tool
+ def read_request(headers: dict = CurrentHeaders()) -> dict[str, str]:
+ return headers
+
+ async with asgi_server(server, transport="sse") as running_server:
+ async with running_server.client(
+ headers={
+ "Cookie": "session=alice-secret",
+ "Authorization": "Bearer alice-token",
+ }
+ ) as client:
+ headers = (await client.call_tool("read_request")).data
+ assert headers["cookie"] == "session=alice-secret"
+ assert headers["authorization"] == "Bearer alice-token"
+
+
def _worker_snapshot_headers() -> dict[str, str]:
"""Read the HTTP headers snapshotted at task submission from inside a worker."""
task_info = get_task_context()
From 609f79b8a118cd6c4bb58a5341bafd76e42e5a2b Mon Sep 17 00:00:00 2001
From: nate nowack
Date: Tue, 18 Aug 2026 11:42:45 -0500
Subject: [PATCH 27/27] Bump cryptography to 50.0.0 in the testing_demo example
(#4844)
Co-authored-by: Claude Opus 5 (1M context)
---
examples/testing_demo/uv.lock | 101 +++++++++++++++++-----------------
1 file changed, 50 insertions(+), 51 deletions(-)
diff --git a/examples/testing_demo/uv.lock b/examples/testing_demo/uv.lock
index a9cb8f163..5f54dc249 100644
--- a/examples/testing_demo/uv.lock
+++ b/examples/testing_demo/uv.lock
@@ -15,13 +15,12 @@ exclude-newer-span = "P1W"
[options.exclude-newer-package]
mcp-types = false
prefab-ui = false
-truststore = false
-fastmcp-slim = false
+pydocket = false
+uncalled-for = false
fastmcp = false
mcp = false
-httpcore2 = false
fastmcp-remote = false
-httpx2 = false
+fastmcp-slim = false
[[package]]
name = "aiofile"
@@ -259,59 +258,59 @@ wheels = [
[[package]]
name = "cryptography"
-version = "49.0.0"
+version = "50.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
- { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
- { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
- { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
- { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
- { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
- { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
- { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
- { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
- { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
- { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
- { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
- { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
- { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
- { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
- { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
- { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
- { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
- { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
- { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
- { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
- { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
- { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
- { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
- { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
- { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
- { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
- { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
- { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
- { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
- { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
- { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
- { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
- { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
- { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
- { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
- { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
- { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
- { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
- { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
- { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
- { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
- { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
- { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
- { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
+ { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
+ { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
+ { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
+ { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
+ { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
+ { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
+ { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
+ { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
+ { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
+ { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
+ { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
+ { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
+ { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
+ { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" },
+ { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
+ { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
+ { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
+ { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
+ { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
+ { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" },
+ { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" },
+ { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" },
+ { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" },
]
[[package]]