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>
* 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>
* Scope tasks to authorization context, not session
Tasks were keyed by the transport-layer Mcp-Session-Id, which is
server-assigned and changes on reconnect — so clients lost access to
their running tasks after any connection interruption.
The MCP spec says tasks should be bound to authorization context, not
session. This replaces session_id with task_scope (derived from
AccessToken.client_id, URL-encoded) in all task data Redis keys and
Docket task keys. When no auth is configured, a "_" sentinel is used
and security comes from UUID task ID entropy per the spec.
Session ID is still used for transport-level concerns (notification
queues, subscriber registration) and is now stored in the
TaskContextSnapshot payload so background workers can still deliver
notifications.
Also extracts all the task context infrastructure (TaskContextInfo,
TaskContextSnapshot, snapshot loading, session/server registries) from
server/dependencies.py into a new server/tasks/context.py to keep the
DI module from sprawling further.
Closes#3758🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Rename _redis_key to _snapshot_redis_key
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Document in-process session registry as an optimization
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Tidy imports and docstrings
Hoist imports where safe, keep subscriptions/notifications deferred in
handlers.py since they pull in docket at module level. Sharpen docstrings
on keys.py and context.py so each module owns its lane. Clean up the
re-export block in dependencies.py.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix misleading comment on re-export block
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Tighten task scope: include sub claim, partition keyspaces
Addresses review feedback on #3800:
- Compose task scope from client_id and the JWT sub claim (when present)
so fixed-OAuth deployments isolate per user, not just per client.
- Replace the "_" anonymous sentinel with a tagged keyspace partition.
Docket keys are now auth:{enc_scope}:... or anon:..., and Redis keys
use fastmcp:task:auth:{enc_scope}:... or fastmcp:task:anon:...,
routed through a single task_redis_prefix() helper.
- get_task_scope() returns the raw scope (or None); encoding happens
once at the keys.py boundary, collapsing the previous double-quote
invariant.
- Drop the dormant fallback in notifications.py that routed
input_required relays into the anon keyspace when task_scope was
missing -- log and skip instead.
- Add comprehensive parser/encoder tests in test_task_keys.py covering
round-trips, malformed keys, and adversarial scopes ("anon", "_", and
scopes containing : / | %).
- Add cross-scope rejection tests: distinct client_ids, distinct sub
claims under a shared client_id, and authenticated vs anonymous.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Delete entirely commented-out test_run_server.py (99 lines dead code)
- Fix test_pydantic_model_with_stringified_json_no_strict: replace
try/except-both-branches-pass with clear pytest.raises assertion
- Fix test_path_traversal_blocked: remove dead assertions after
pytest.raises (lines after raise never execute)
Error handling middleware test fixes are in a separate PR (#3858)
which also fixes the underlying RetryMiddleware bug.
🤖 Generated with Claude Code
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Retry when LLM returns text instead of calling final_response tool
Instead of raising RuntimeError immediately when the LLM returns a text
response instead of calling the `final_response` tool for structured
output, retry up to 3 times with an explicit nudge message asking the
model to use the tool. This mirrors the existing retry behavior for
validation errors but with a separate, smaller cap.
Fixes#3847
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add tests for text response retry logic
Tests cover:
- Text response followed by successful final_response (retry works)
- Text response exceeding max retries (raises RuntimeError)
- Nudge message appended to history on retry
- No retry when result_type is None (text is valid)
Addresses review feedback from PR review tool (v1 flagged missing tests as high severity).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Slim down text response retry tests
Remove test_nudge_message_in_history (implementation detail).
Reduce boilerplate in remaining 3 tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix ruff format
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the ___ separator for FastMCPApp backend tool routing with a
deterministic hash(app_name, tool_name) prefix, and replaces the shared
singleton prefab renderer resource with per-tool resources synthesized
on demand.
Backend tools are now callable via <hash>_<local_name> instead of
<app_name>___<local_name>. The dispatcher walks the provider tree
recursively via get_tool_by_hash (same pattern as get_app_tool).
Each prefab tool gets its own renderer resource at
ui://prefab/tool/<hash>/renderer.html with per-tool CSP — fixing the
bug where PrefabAppConfig(csp=...) never actually applied.
Closes#3735, closes#3805
- AggregateProvider._collect_list_results detects duplicate component
names across providers, respecting the server's on_duplicate setting
- Provider errors logged at WARNING instead of DEBUG
- Parent server re-masks ToolErrors from mounted children at the
FastMCPError catch boundary instead of mutating the child server
Fixes#3825
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Enables stricter type checking by promoting rules that default to
ignore: division-by-zero, possibly-missing-attribute,
possibly-missing-import, possibly-unresolved-reference,
unsupported-dynamic-base, unsupported-operator, unused-ignore-comment.
6 of the 7 rules had zero violations. possibly-unresolved-reference
had 9 (5 in src/, 3 in tests/, 1 walrus-operator false positive
suppressed with ty: ignore).
🤖 Generated with Claude Code
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Detect async/sync generators after tool execution and materialize
into lists before the result conversion pipeline processes them
- Generator materialization runs inside timeout scope so slow generators
respect the configured timeout
- Handle bytes return types: UTF-8 bytes as text, non-UTF-8 as base64
- Suppress output_schema for bytes return types (can't be structured JSON)
- Catch UnicodeDecodeError alongside PydanticSerializationError in
convert_result for robustness
Fixes#3829
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pydocket 0.19.0 fixes the fakeredis 2.35.0 FakeConnection rename
internally, so we no longer need to carry the fakeredis ceiling
ourselves. Removes the direct fakeredis[lua]<2.35.0 dependency from the
tasks extra entirely — it's just a transitive of pydocket now.
🤖 Generated with Claude Code
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>