fix(sdk): require fastmcp>=3.2.0 so expired MCP OAuth tokens refresh - #4857
Conversation
fastmcp 3.0.0-3.1.1 recompute the OAuth token expiry as now + expires_in on every _initialize(), ignoring the absolute expiry they persisted. A long-dead access token reads as freshly issued, the refresh grant never fires, and the stale token is sent to the provider. fastmcp 3.2.0 reads the stored expiry back.
Python API breakage checks — ✅ PASSEDResult: ✅ PASSED |
REST API breakage checks (OpenAPI) — ✅ PASSEDResult: ✅ PASSED |
|
🚦 CI is currently failing on this PR's latest commit. Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request This is an automated check - no AI was used to generate this comment. |
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Review
🟢 Good taste — a two-line dependency floor change with a well-verified rationale.
Verified against the workspace:
openhands-sdk/pyproject.tomlis the onlypyproject.tomlin the repo declaringfastmcp, so the floor exists exactly once; no other manifest needs a matching bump.uv.lockstill resolvesfastmcpto 3.2.0 (the[[package]]entry is unchanged,upload-time = 2026-03-30, well past the 7-day freshness guardrail), and therequires-distspecifier in theopenhands-sdkentry matches the new floor — the lockfile is internally consistent with a pure floor-raise and no dependency movement.- The claim that this is a resolver-only fix is accurate for this repo: nothing in
openhands-sdk/openhands-agent-serverreads the persisted absolute expiry directly (MCPSettingsOAuthTokenStoreinopenhands-agent-server/openhands/agent_server/mcp_oauth_store.pyonly maps collections/keys; the onlyget_token_expiryconsumer is inside fastmcp itself), so 3.2.0's read-back of/token_expiryis the only mechanism that makes the refresh grant fire. The repo-side key mapping already coverstoken_expiry. - No test added is the right call: any assertion would only restate the pin against whatever version is installed.
- No agent-behavior/eval risk — this changes no prompts, tools, or loop logic; it only removes broken resolver outcomes.
Non-blocking note (no change requested): a user whose refresh token has also expired will still need re-authorization — correctly deferred to #4821.
[RISK ASSESSMENT]
- [Overall PR] Risk Assessment: 🟢 LOW — metadata-only floor raise (
fastmcp>=3.0.0→fastmcp>=3.2.0), resolved version unchanged, lockfile consistent, and the excluded versions are strictly older releases (no supply-chain freshness concern).
VERDICT: ✅ Worth merging
KEY INSIGHT: The persisted-absolute-expiry read-back in fastmcp 3.2.0 is the only consumer of /token_expiry, so raising the floor is both necessary and sufficient for the refresh grant to fire.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately on re-review.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Taste Rating: 🟢 Good taste — a two-line dependency floor that makes a real bug unreachable, with no resolved-version movement.
Verified:
- The diff is exactly what it claims:
fastmcp>=3.0.0→>=3.2.0inopenhands-sdk/pyproject.tomlplus the matchingrequires-distline inuv.lock. The locked version stays 3.2.0, so nothing moves for anyone already syncing from the lockfile. - Supply-chain / freshness guardrail: fastmcp 3.2.0 was uploaded to PyPI on 2026-03-30 — ~5 months old, well past the repo's 7-day
exclude-newerwindow. No freshness concern, and the resolved version doesn't change anyway. - The technical rationale is grounded in workspace code:
openhands-agent-server/openhands/agent_server/mcp_oauth_store.py:46maps the/token_expirycollection, andopenhands-sdk/openhands/sdk/settings/model.py:776-780migratestoken_expires_atinto token storage — so the SDK side does persist the absolute expiry; only fastmcp <3.2.0 fails to read it back. Raising the floor is the correct minimal fix. - CI for this head SHA is green:
sdk-tests,agent-server-tests,coverage-report,build, etc. all pass.
Two observations, neither blocking:
- One
Validate PR descriptioncheck run on this commit reports failure — looks like a PR-description/HUMAN-field validation issue, not a code issue. Flagging in case it gates merge for you. - Skipping a test here is the right call — the constraint is enforced by the resolver, and an assertion would only restate the pin against the installed version.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟢 LOW — metadata-only change with no resolved-version movement; the only behavioral effect is making the fastmcp 3.0.x–3.1.x pairing unresolvable, which is exactly the intent. No public API changes, no eval-risk category.
VERDICT: ✅ Worth merging
KEY INSIGHT: The bug lives entirely in the upstream dependency, so a version floor — not SDK code — is the correct fix shape, and keeping the lockfile edit surgical avoids burying it in resolver churn.
HUMAN:
Fixing a dependency issue with FastMCP
AGENT:
Why
openhands-sdkdeclaresfastmcp>=3.0.0. On fastmcp3.0.0through3.1.1, an expired MCP OAuth access token is never refreshed, which is the first half of OpenHands/OpenHands#17077 (Atlassian Rovo and GitLab).Those versions end
OAuth._initialize()with an unconditional recompute:update_token_expirysets the expiry to now plus the relativeexpires_in. The adapter does persist an absoluteexpires_atunder/token_expiry, but nothing reads it back:get_token_expiry()does not exist before 3.2.0. So on every startup a token that died hours ago is re-dated as freshly issued,is_token_valid()returnsTrue, the refresh grant inmcp'sasync_auth_flowis skipped, and the dead access token goes to the provider. The user sees requests failing with no way out except re-authenticating.fastmcp 3.2.0 (upstream #2862) added the reader and prefers the stored value.
I verified the boundary by unpacking the published wheels rather than trusting the changelog:
get_token_expiryThe OpenHands side is already correct and needs no change:
MCPSettingsOAuthTokenStoremaps all three keys and collections FastMCP writes (/tokens,/client_info,/token_expiry), and FastMCP keys storage on the full MCP URL, which is what_find_matching_oauth_servermatches on.Nothing in CI catches this, which is why the floor drifted:
uv.lockalready pins 3.2.0, and a fresh index resolve lands on 4.x. Only an environment that resolved between 2026-02-18 and 2026-03-30 and stayed there is exposed, a staleuvxcache being the obvious case. It is reachable today:Summary
openhands-sdkfloor tofastmcp>=3.2.0, the first release that reads the persisted absolute token expiry back.requires-distspecifier inuv.lock. The resolved version is unchanged at 3.2.0, so this is a two-line metadata change with no dependency movement.Issue Number
Part of #4818 — the unbounded
fastmcp>=3.0.0floor it documents, on thetoken-refresh side. Upstream report: OpenHands/OpenHands#17077.
The callback-contract half of the same outage is fixed separately in
#4821.
How to Test
End-to-end reproduction, offline. It drives the real
fastmcpOAuth client and the realmcpauth flow with a token store seeded exactly as the agent-server's settings-backed store holds it, then inspects the first request the auth flow emits: a refresh POST (correct) or the MCP call carrying the dead token.repro.pyResult, with the expiry shifted forward by exactly
expires_inon the broken version:The fix makes the broken pairing unreachable:
Regression check, unchanged from
mainsince the resolved version does not move:Video/Screenshots
The failure is textual and reproduced in full above, on both sides of the boundary.
Type
Notes
<4ceiling would also hide themcp2.x callback contract break, which fix(agent-server): delegate MCP OAuth callback to FastMCP instead of forking it #4821 fixes properly; with that merged the floor alone is the right shape. A fresh resolve today lands on fastmcp 4.0.2 / mcp 2.1.1, so the two changes are complementary.uv.lockis edited surgically rather than regenerated. A fulluv lockon the current uv release rewrites theexclude-newerstamp and reshuffles markers on unrelated packages, which would bury a two-line change.uv lock --checkpasses on the result.🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)
• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server
Variants & Base Images
eclipse-temurin:17-jdknikolaik/python-nodejs:python3.13-nodejs22-slimnikolaik/python-nodejs:python3.13-nodejs22-slimgolang:1.21-bookwormPull (multi-arch manifest)
# Each variant is a multi-arch manifest supporting both amd64 and arm64 docker pull ghcr.io/openhands/agent-server:5a271e5-pythonRun
All tags pushed for this build
About Multi-Architecture Support
5a271e5-python) is a multi-arch manifest supporting both amd64 and arm645a271e5-python-amd64) are also available if needed