Keycloak returns refresh_expires_in=0 for offline_access tokens to signal
'this refresh token never expires'. Base OAuthProxy has no way to know this
is intentional rather than a malformed response, so it falls through to the
standard 1-year wall-clock fallback — which causes the FastMCP refresh token
TTL to shrink on every subsequent refresh cycle until it reaches ~0 after
one year, forcing re-authentication even though the Keycloak offline token
is still valid.
This commit:
- Adds KeycloakOAuthProxy(OAuthProxy) to providers/keycloak.py with a
convenience __init__ that derives OIDC endpoints from realm_url
- Adds _zero_refresh_expiry_means_never_expires: bool = False class attr
on OAuthProxy; KeycloakOAuthProxy sets it to True
- Adds refresh_token_never_expires: bool = False to UpstreamTokenSet so
the intent is visible in stored state
- When the flag is set and val==0: marks the token as never-expiring and
clears refresh_token_expires_at so subsequent refresh cycles always get
a fresh full fallback-TTL FastMCP RT instead of a decaying one
- Base OAuthProxy is completely unchanged for val==0: falls through to
the existing 1-year wall-clock fallback as before
Tests:
- test_refresh_expires_in_zero_issues_refresh_token: KeycloakOAuthProxy
correctly issues a refresh token and marks upstream as never-expiring
- test_refresh_expires_in_zero_subsequent_refresh_does_not_shrink: TTL
stays at ~1 year after repeated refresh cycles
- test_base_proxy_does_not_treat_zero_as_never_expires: confirms base
OAuthProxy behaviour is unaffected
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When Keycloak returns refresh_expires_in=0 (offline token, never expires) on
every token response, the previous code only guarded the initial exchange path.
On subsequent exchange_refresh_token and transparent refresh cycles, the code
would fall through to 'keep existing expiry' — inheriting the wall-clock
timestamp set at initial exchange. After ~1 year that decayed to ~0 seconds,
issuing FastMCP RTs with 1-second TTL and forcing re-auth even though the
Keycloak offline token was still valid.
Fix: add elif val == 0 to both refresh paths that clears refresh_token_expires_at
to None. The fallback branch then issues a fresh full fallback-TTL FastMCP RT
on every cycle, matching the 'always-valid' semantics of offline tokens.
Also improves the debug log message to distinguish 'never expires' from
'expiry not provided' so operators can see exactly what Keycloak sent.
Adds a regression test: test_refresh_expires_in_zero_subsequent_refresh_does_not_shrink
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Fix OCI Provider issue in 3.x version. Add OCI auth provider example and test
* Fix OCI Provider issue in 3.x version. Add OCI auth provider example and test. Fixed a couple of minor issues in README.
* Rerun CI
* Restore task snapshot via a worker-level dependency
`get_access_token()` returned `None` inside background tasks whenever
`FASTMCP_DOCKET_URL` pointed at a `redis+cluster://` URL. The write side
was fine — it went through `docket.redis()`, which is cluster-aware —
but fastmcp kept a parallel sync Redis client just to read the snapshot
back, and `Redis.from_url()` rejects the cluster scheme.
Docket 0.19.1 ships worker-level dependencies that resolve per task in
the same asyncio.Task as user code, so ContextVars propagate cleanly.
That lets us load the snapshot once via `restore_task_snapshot` and
drop the sync Redis path entirely. Sync helpers like
`get_access_token()` and `get_http_request()` now just read a
ContextVar; Docket is the sole Redis consumer.
Closes#3897
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Revert TaskKey stub to a plain return
NotImplementedError would fire at module import if anything evaluated
the default; a no-op stub keeps the module usable without the
fastmcp[tasks] extra, which is what we want.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix: OpenAPI request director content-type dispatch and cookie params
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Stringify multipart form values and cookie params for httpx
httpx rejects non-string scalars in files= and cookies=.
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add tests for non-string multipart values and cookie stringification
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Consolidate to single httpx.Request construction point
Eliminate early returns by using variables for files/data kwargs.
All httpx body kwargs accept None, so we set exactly one and
pass all to a single Request() call.
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Use _query_scalar_to_str for multipart booleans, add tuple passthrough test
Reuse existing boolean serialization (true/false not True/False) for
multipart form fields. Add test for file-like tuple passthrough.
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Normalize media type for dispatch, use OpenAPI serialization for cookies
- Strip content-type parameters (e.g. "; charset=utf-8") and lowercase
before matching, so variants like "Multipart/Form-Data" match correctly
- Use _query_scalar_to_str for cookie values (true/false not True/False)
- Add boolean cookie test
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Preserve media-type parameters in Content-Type header
Use raw_content_type (with charset etc.) for the outgoing header,
normalized form only for dispatch matching.
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Pass bytes/file-like values directly in multipart, add charset preservation test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: allow hyphens in resource template parameter names
Normalize hyphens to underscores at the regex group level in build_regex()
and at the param extraction level in from_function(). No API changes —
build_regex still returns Pattern | None, match_uri_template still returns
the same dict shape.
Closes#3921🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Guard against query params clobbering path params
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add tests for wildcard hyphens, expand, and query clobber guard
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* ruff format fix
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add collision detection for hyphen/underscore param name normalization
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* OTEL: Fix attribute compliance and improve telemetry helpers
Attribute compliance:
- Remove rpc.system/service/method (MCP is not traditional RPC)
- Add gen_ai.tool.name on tools/call spans
- Add gen_ai.prompt.name on prompts/get spans
- Fix session_id check (truthy -> is not None)
Telemetry helper improvements:
- Add is_recording() guards to skip work on non-recording spans
- Add error.type attribute with __qualname__ on error spans
- Use isinstance check for ToolError to set "tool_error" error type
- Include exception message in span status description
- Add tool_name/prompt_name params to server_span and client_span
Client call_tool enrichment:
- Reflect tool-level errors (result.isError) on client span status
so callers see ERROR even though the MCP protocol call succeeded
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove resource URI from span names to avoid high-cardinality
Per MCP semantic conventions, resource URIs SHOULD NOT be included in
span names by default since they can be unbounded (especially with
templates like users://{id}/profile). The URI remains available via
the mcp.resource.uri attribute.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add missing gen_ai/mcp attributes to proxy and delegate spans
- Proxy tool spans: add gen_ai.tool.name
- Proxy prompt spans: add gen_ai.prompt.name
- All delegate spans: add mcp.method.name
- Docs: remove rpc.* references, update span names and attributes table
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Hoist ToolError imports to module level, add rpc.* migration note
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: handle non-TextContent error responses in ProxyTool
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Avoid serializing binary content into ToolError messages
Use type name instead of str(content) to prevent dumping
large base64 payloads into error messages.
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* ruff format fix
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: server safety guards for self-mount, duplicate middleware, mount arg order
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Remove duplicate middleware and mount arg order checks
These are runtime type checking, not bugs — a type checker catches them.
Keep only the self-mount guard which is a semantic check.
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>