From 4e136e60d69d49bd95974a7f832a5b02a3d20420 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:45:24 -0400 Subject: [PATCH] Document the issuer_url identity change for upgraders (#4658) * Document the issuer_url identity change for upgraders Adds an upgrade note covering the one-time re-authorization, fixes the MultiAuth examples that pointed issuer_url at the upstream IdP, and corrects the OCI docstring. * Address review: valid docstring example, narrower reauth scope * Scope the reauth checklist item to token-minting providers --- docs/getting-started/upgrading/from-fastmcp-3.mdx | 10 ++++++++-- docs/servers/auth/authentication.mdx | 14 +++++++++++--- docs/servers/auth/multi-auth.mdx | 14 +++++++++++--- docs/servers/auth/oauth-proxy.mdx | 2 +- fastmcp_slim/fastmcp/server/auth/auth.py | 14 +++++++++++++- .../fastmcp/server/auth/identity_assertion.py | 2 +- .../fastmcp/server/auth/providers/huggingface.py | 3 +++ fastmcp_slim/fastmcp/server/auth/providers/oci.py | 5 ++++- 8 files changed, 52 insertions(+), 12 deletions(-) diff --git a/docs/getting-started/upgrading/from-fastmcp-3.mdx b/docs/getting-started/upgrading/from-fastmcp-3.mdx index 86bcd58ad..eb9f0a0f0 100644 --- a/docs/getting-started/upgrading/from-fastmcp-3.mdx +++ b/docs/getting-started/upgrading/from-fastmcp-3.mdx @@ -215,12 +215,18 @@ Two `@tool` parameters and two settings are gone: ## Behavior changes to verify -Two server-side behaviors changed in ways that compile fine but can surface at runtime. +Three server-side behaviors changed in ways that compile fine but can surface at runtime. **Templated resources are path-screened by default.** Every templated resource now has its extracted parameter values checked for path-traversal (`..` segments), absolute paths, and null bytes *before your handler runs*, at the server's read chokepoint. A rejected read returns a non-leaky "resource not found" error. Only a standalone `..` segment counts as traversal, so values that merely contain dots (`file.tar.gz`, `HEAD~3..HEAD`) and dotfiles (`.env`) still pass. If a template legitimately accepts `..`-bearing or absolute values, exempt the parameter with `ResourceSecurity(exempt_params={...})`, disable the check per-component with `security=None`, or set a server-wide default with `FastMCP(resource_security=...)`. See [Resources → Path Security](/servers/resources#path-security). **Resource-not-found now returns `-32602`.** The wire error code for a missing resource from the core `resources/read` handler changed from `-32002` to `-32602` (`INVALID_PARAMS`, per SEP-2164). The human-readable message ("Resource not found: ...") is unchanged, so this only affects clients that matched on the numeric code — update those to expect `-32602`. (The opt-in `ErrorHandlingMiddleware` keeps its own per-method-prefix code mapping; if you run it with `transform_errors=True` it can still map not-found to a different code, so it is unaffected by this change.) +**An OAuth server whose `issuer_url` differs from its `base_url` re-authorizes its clients once.** `issuer_url` exists so a server's OAuth identity can differ from the URL its endpoints are mounted at — the usual case being a server under `/api` whose discovery lives at the host root. It now supplies the `issuer` in the authorization server metadata, the `iss` claim on every token the server mints, and the RFC 9207 `iss` on authorization responses; `base_url` still supplies `authorization_endpoint`, `token_endpoint`, and the rest, because that is where the routes are actually mounted. Both values previously came from `base_url`, which published an `issuer` contradicting the URL the client had just performed discovery at — a document RFC 8414 §3.3 requires a strict client to reject. + +The cost of the correction is the `iss` on tokens already in the wild, so it falls on the providers that mint their own tokens — `OAuthProxy` and everything built on it. Access *and* refresh tokens carry the claim, and the verifier compares it exactly, so clients cannot refresh their way across the upgrade; it is a one-time full re-authorization. Interactive clients re-prompt and recover on their own, while a headless deployment holding a long-lived refresh token needs someone to re-authorize it. Plan the upgrade for a window where that is acceptable. If an identity provider mints SEP-990 ID-JAG assertions for this server, repoint their `aud` at the new issuer too — unless you pin the expected value with `IdentityAssertion(audience=...)`, which overrides the issuer and keeps working untouched. + +Servers that leave `issuer_url` unset, or set it to the same value as `base_url`, are unaffected. It defaults to `base_url`, and the metadata and minted `iss` are byte-identical to what 3.x produced. + ## Deprecation timeline The camelCase bridge is a migration aid, not a permanent fixture. It works today and warns on every bridged read so you can find and update the affected call sites. Plan to migrate your reads to snake_case: the shims will be removed in a future release, after which only the snake_case names resolve — the same state you get today by setting `mcp_camelcase_compat = False`. Turning the setting off is a good way to surface every remaining camelCase read in your code as a hard `AttributeError` before the shims go away. @@ -273,7 +279,7 @@ Most servers upgrade untouched. Work down this list to find the ones that don't: 6. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged. 7. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`. 8. **Decide the client era.** `Client` now defaults to `mode="auto"`. If a server relies on `on_initialize` or per-session state, keep its clients on `mode="legacy"` or restrict the server's served protocol versions. -9. **Verify behavior changes.** Confirm templated resources that legitimately accept `..` or absolute paths are exempted, and update any client that matched the old `-32002` resource-not-found code. +9. **Verify behavior changes.** Confirm templated resources that legitimately accept `..` or absolute paths are exempted, update any client that matched the old `-32002` resource-not-found code, and if your server mints its own OAuth tokens (`OAuthProxy` and the providers built on it) under an `issuer_url` that differs from its `base_url`, schedule the [one-time re-authorization](#behavior-changes-to-verify) its clients now need. 10. **Run with the camelCase bridge off.** Set `mcp_camelcase_compat = False` (or `FASTMCP_MCP_CAMELCASE_COMPAT=false`) in CI to surface every remaining camelCase read as a hard `AttributeError` before the shims are removed. The executable version of this checklist lives in [`tests/test_upgrade_from_v3.py`](https://github.com/PrefectHQ/fastmcp/blob/main/tests/test_upgrade_from_v3.py): it builds representative 3.x-style servers and asserts they run unchanged, and pins every removed surface to the exact error it now raises. diff --git a/docs/servers/auth/authentication.mdx b/docs/servers/auth/authentication.mdx index d37c57f36..a948a647c 100644 --- a/docs/servers/auth/authentication.mdx +++ b/docs/servers/auth/authentication.mdx @@ -189,11 +189,19 @@ from fastmcp import FastMCP from fastmcp.server.auth import MultiAuth, OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier +upstream_verifier = JWTVerifier( + jwks_uri="https://login.example.com/.well-known/jwks.json", + issuer="https://login.example.com", + audience="my-app", +) + auth = MultiAuth( server=OAuthProxy( - issuer_url="https://login.example.com/...", - client_id="my-app", - client_secret="secret", + upstream_authorization_endpoint="https://login.example.com/oauth/authorize", + upstream_token_endpoint="https://login.example.com/oauth/token", + upstream_client_id="my-app", + upstream_client_secret="secret", + token_verifier=upstream_verifier, base_url="https://my-server.com", ), verifiers=[ diff --git a/docs/servers/auth/multi-auth.mdx b/docs/servers/auth/multi-auth.mdx index ba54d25ab..3675c92a6 100644 --- a/docs/servers/auth/multi-auth.mdx +++ b/docs/servers/auth/multi-auth.mdx @@ -22,11 +22,19 @@ from fastmcp import FastMCP from fastmcp.server.auth import MultiAuth, OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier +upstream_verifier = JWTVerifier( + jwks_uri="https://login.example.com/.well-known/jwks.json", + issuer="https://login.example.com", + audience="my-app", +) + auth = MultiAuth( server=OAuthProxy( - issuer_url="https://login.example.com/...", - client_id="my-app", - client_secret="secret", + upstream_authorization_endpoint="https://login.example.com/oauth/authorize", + upstream_token_endpoint="https://login.example.com/oauth/token", + upstream_client_id="my-app", + upstream_client_secret="secret", + token_verifier=upstream_verifier, base_url="https://my-server.com", ), verifiers=[ diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 2979bffab..101139ca4 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -114,7 +114,7 @@ mcp = FastMCP(name="My Server", auth=auth) Public URL where OAuth endpoints will be accessible, **including any mount path** (e.g., `https://your-server.com/api`). - This URL is used to construct OAuth callback URLs and operational endpoints. When mounting under a path prefix, include that prefix in `base_url`. Use `issuer_url` separately to specify where auth server metadata is located (typically at root level). + This URL is used to construct OAuth callback URLs and operational endpoints. When mounting under a path prefix, include that prefix in `base_url`. Use `issuer_url` separately to give the server an OAuth identity that differs from where its endpoints are mounted (typically the root level). diff --git a/fastmcp_slim/fastmcp/server/auth/auth.py b/fastmcp_slim/fastmcp/server/auth/auth.py index fb24d9d04..6e3b13f42 100644 --- a/fastmcp_slim/fastmcp/server/auth/auth.py +++ b/fastmcp_slim/fastmcp/server/auth/auth.py @@ -646,10 +646,22 @@ class MultiAuth(AuthProvider): Example: ```python + from fastmcp import FastMCP from fastmcp.server.auth import MultiAuth, JWTVerifier, OAuthProxy + upstream = OAuthProxy( + upstream_authorization_endpoint="https://login.example.com/oauth/authorize", + upstream_token_endpoint="https://login.example.com/oauth/token", + upstream_client_id="my-app", + upstream_client_secret="secret", + token_verifier=JWTVerifier( + jwks_uri="https://login.example.com/.well-known/jwks.json" + ), + base_url="https://my-server.com", + ) + auth = MultiAuth( - server=OAuthProxy(issuer_url="https://login.example.com/..."), + server=upstream, verifiers=[JWTVerifier(jwks_uri="https://example.com/.well-known/jwks.json")], ) mcp = FastMCP("my-server", auth=auth) diff --git a/fastmcp_slim/fastmcp/server/auth/identity_assertion.py b/fastmcp_slim/fastmcp/server/auth/identity_assertion.py index 07c3b0e58..2c503cc06 100644 --- a/fastmcp_slim/fastmcp/server/auth/identity_assertion.py +++ b/fastmcp_slim/fastmcp/server/auth/identity_assertion.py @@ -218,7 +218,7 @@ class IdentityAssertionValidator: """ self.config = config # Accept the audience both with and without a trailing slash: metadata - # advertises the issuer exactly as pydantic renders base_url (a bare + # advertises the issuer exactly as pydantic renders issuer_url (a bare # domain gains a trailing slash), so an IdP that sets `aud` to the # advertised value verbatim must match, and so must one that strips it. if config.audience: diff --git a/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py b/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py index de89372eb..22b6c5d6b 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py @@ -209,6 +209,9 @@ class HuggingFaceProvider(OAuthProxy): client_secret: Hugging Face OAuth app client secret. Optional for public PKCE apps; when omitted, ``jwt_signing_key`` is required. base_url: Public URL where OAuth endpoints will be accessible. + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use + root-level URL to avoid 404s during discovery when mounting under + a path. required_scopes: Required Hugging Face scopes. Defaults to ``["openid", "profile"]``. valid_scopes: Scopes clients may request. Defaults to required scopes. diff --git a/fastmcp_slim/fastmcp/server/auth/providers/oci.py b/fastmcp_slim/fastmcp/server/auth/providers/oci.py index f5fb8bea7..ffd98ed09 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/oci.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/oci.py @@ -156,7 +156,10 @@ class OCIProvider(OIDCProxy): resource_base_url: Optional public base URL for the protected resource metadata and token audience. Defaults to ``base_url``. audience: OCI API audience (optional) - issuer_url: Issuer URL for OCI IAM Domain metadata. This will override issuer URL from the discovery URL. + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). This is + this server's own OAuth identity, not the OCI IAM Domain's — it has no + effect on the upstream issuer taken from the discovery URL. Use a + root-level URL to avoid 404s during discovery when mounting under a path. required_scopes: Required OCI scopes (defaults to ["openid"]) redirect_path: Redirect path configured in OCI IAM Domain Integrated Application. The default is "/auth/callback". allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.