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.