Skip to content

fix(sdk): require fastmcp>=3.2.0 so expired MCP OAuth tokens refresh - #4857

Merged
VascoSch92 merged 1 commit into
mainfrom
vasco/mcp-oauth-fastmcp-expiry-floor
Sep 4, 2026
Merged

fix(sdk): require fastmcp>=3.2.0 so expired MCP OAuth tokens refresh#4857
VascoSch92 merged 1 commit into
mainfrom
vasco/mcp-oauth-fastmcp-expiry-floor

Conversation

@VascoSch92

@VascoSch92 VascoSch92 commented Sep 4, 2026

Copy link
Copy Markdown
Member

HUMAN:

Fixing a dependency issue with FastMCP


AGENT:

Why

openhands-sdk declares fastmcp>=3.0.0. On fastmcp 3.0.0 through 3.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:

if self.context.current_tokens and self.context.current_tokens.expires_in:
    self.context.update_token_expiry(self.context.current_tokens)   # now + expires_in

update_token_expiry sets the expiry to now plus the relative expires_in. The adapter does persist an absolute expires_at under /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() returns True, the refresh grant in mcp's async_auth_flow is 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:

fastmcp get_token_expiry
3.0.0, 3.0.2, 3.1.0, 3.1.1 absent
3.2.0+ present

The OpenHands side is already correct and needs no change: MCPSettingsOAuthTokenStore maps 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_server matches on.

Nothing in CI catches this, which is why the floor drifted: uv.lock already 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 stale uvx cache being the obvious case. It is reachable today:

$ uv pip compile <(echo "openhands-sdk @ ./openhands-sdk"; echo "fastmcp==3.1.1")
fastmcp==3.1.1
mcp==1.29.1
Resolved 129 packages     # no conflict

Summary

  • Raise the openhands-sdk floor to fastmcp>=3.2.0, the first release that reads the persisted absolute token expiry back.
  • Update the matching requires-dist specifier in uv.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.0 floor it documents, on the
token-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 fastmcp OAuth client and the real mcp auth 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.

uv venv venv-3.1.1 -p 3.13 && VIRTUAL_ENV=$PWD/venv-3.1.1 uv pip install fastmcp==3.1.1
uv venv venv-3.2.0 -p 3.13 && VIRTUAL_ENV=$PWD/venv-3.2.0 uv pip install fastmcp==3.2.0
./venv-3.1.1/bin/python repro.py; ./venv-3.2.0/bin/python repro.py
repro.py
import asyncio, time
from collections.abc import Mapping, Sequence
from typing import Any
import fastmcp, httpx
from fastmcp.client.auth.oauth import OAuth

MCP_URL = "https://mcp.example.com/mcp"
STALE = "STALE-ACCESS-TOKEN"
NOW = time.time()


class DictStore:
    """Minimal AsyncKeyValue with the same (collection, key) shape OpenHands uses."""

    def __init__(self, seed): self._d = dict(seed)

    async def get(self, key, *, collection=None): return self._d.get((collection or "", key))

    async def ttl(self, key, *, collection=None): return await self.get(key, collection=collection), None

    async def put(self, key, value, *, collection=None, ttl=None): self._d[(collection or "", key)] = dict(value)

    async def delete(self, key, *, collection=None): return self._d.pop((collection or "", key), None) is not None

    async def get_many(self, keys, *, collection=None): return [await self.get(k, collection=collection) for k in keys]

    async def ttl_many(self, keys, *, collection=None): return [await self.ttl(k, collection=collection) for k in keys]

    async def put_many(self, keys, values, *, collection=None, ttl=None):
        for k, v in zip(keys, values, strict=True): await self.put(k, v, collection=collection)

    async def delete_many(self, keys, *, collection=None):
        return sum(bool(await self.delete(k, collection=collection)) for k in keys)


def seed():
    """State of a server authorized 2h ago whose 1h access token died 1h ago."""
    return DictStore({
        ("mcp-oauth-token", f"{MCP_URL}/tokens"): {
            "access_token": STALE, "refresh_token": "REFRESH-TOKEN",
            "token_type": "Bearer", "expires_in": 3600,
        },
        ("mcp-oauth-token-expiry", f"{MCP_URL}/token_expiry"): {"expires_at": NOW - 3600},
        ("mcp-oauth-client-info", f"{MCP_URL}/client_info"): {
            "client_id": "test-client-id", "client_secret": "test-client-secret",
            "redirect_uris": ["http://localhost:8765/callback"],
            "token_endpoint_auth_method": "client_secret_post",
        },
    })


async def main():
    oauth = OAuth(mcp_url=MCP_URL, token_storage=seed(), client_name="repro")
    await oauth._initialize()
    ctx = oauth.context

    flow = oauth.async_auth_flow(httpx.Request("POST", MCP_URL, json={}))
    first = await flow.__anext__()
    await flow.aclose()

    refreshed = first.url.path.endswith("/token")
    sent_stale = first.headers.get("Authorization") == f"Bearer {STALE}"

    print(f"fastmcp                {fastmcp.__version__}")
    print(f"stored expiry          {NOW - 3600:.0f} (1h in the past)")
    print(f"expiry after _init     {ctx.token_expiry_time:.0f} ({ctx.token_expiry_time - NOW:+.0f}s vs now)")
    print(f"is_token_valid()       {ctx.is_token_valid()}")
    print(f"first request emitted  {first.method} {first.url}")
    print(f"refresh attempted      {refreshed}")
    print(f"sent the dead token    {sent_stale}")
    return 1 if sent_stale and not refreshed else 0


raise SystemExit(asyncio.run(main()))

Result, with the expiry shifted forward by exactly expires_in on the broken version:

======== fastmcp 3.1.1 ========          ======== fastmcp 3.2.0 ========
stored expiry          (1h in the past)  stored expiry          (1h in the past)
expiry after _init     +3600s vs now     expiry after _init     -3600s vs now
is_token_valid()       True              is_token_valid()       False
first request emitted  POST .../mcp      first request emitted  POST .../token
refresh attempted      False             refresh attempted      True
sent the dead token    True              sent the dead token    False

BUG: dead token sent, no refresh          OK: refresh grant sent

The fix makes the broken pairing unreachable:

$ uv pip compile <(echo "openhands-sdk @ ./openhands-sdk"; echo "fastmcp==3.1.1")
  × No solution found when resolving dependencies:
    openhands-sdk==1.44.1 depends on fastmcp>=3.2.0

Regression check, unchanged from main since the resolved version does not move:

uv sync --frozen --group dev
uv run --frozen pytest tests/sdk/mcp/ tests/agent_server/test_mcp_oauth_store.py tests/agent_server/test_mcp_router.py -q
# 162 passed in 23.99s
uv lock --check   # Resolved 415 packages

Video/Screenshots

The failure is textual and reproduced in full above, on both sides of the boundary.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

  • No ceiling is added. A <4 ceiling would also hide the mcp 2.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.
  • No test is added. The constraint is enforced by the resolver, and any assertion in the test suite would only restate the pin against whatever version is already installed.
  • uv.lock is edited surgically rather than regenerated. A full uv lock on the current uv release rewrites the exclude-newer stamp and reshuffles markers on unrelated packages, which would bury a two-line change. uv lock --check passes on the result.
  • A user already stuck has an access token that may be long dead. Refresh will fire for them once this ships, but if the refresh token has also expired they still need a working re-authorization, which is fix(agent-server): delegate MCP OAuth callback to FastMCP instead of forking it #4821.

🐳 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

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python-slim amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:5a271e5-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-5a271e5-python \
  ghcr.io/openhands/agent-server:5a271e5-python

All tags pushed for this build

ghcr.io/openhands/agent-server:5a271e5-golang-amd64
ghcr.io/openhands/agent-server:5a271e52140dc3f509ffafd6cc2d5d228247ab66-golang-amd64
ghcr.io/openhands/agent-server:vasco-mcp-oauth-fastmcp-expiry-floor-golang-amd64
ghcr.io/openhands/agent-server:5a271e5-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:5a271e5-golang-arm64
ghcr.io/openhands/agent-server:5a271e52140dc3f509ffafd6cc2d5d228247ab66-golang-arm64
ghcr.io/openhands/agent-server:vasco-mcp-oauth-fastmcp-expiry-floor-golang-arm64
ghcr.io/openhands/agent-server:5a271e5-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:5a271e5-java-amd64
ghcr.io/openhands/agent-server:5a271e52140dc3f509ffafd6cc2d5d228247ab66-java-amd64
ghcr.io/openhands/agent-server:vasco-mcp-oauth-fastmcp-expiry-floor-java-amd64
ghcr.io/openhands/agent-server:5a271e5-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:5a271e5-java-arm64
ghcr.io/openhands/agent-server:5a271e52140dc3f509ffafd6cc2d5d228247ab66-java-arm64
ghcr.io/openhands/agent-server:vasco-mcp-oauth-fastmcp-expiry-floor-java-arm64
ghcr.io/openhands/agent-server:5a271e5-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:5a271e5-python-amd64
ghcr.io/openhands/agent-server:5a271e52140dc3f509ffafd6cc2d5d228247ab66-python-amd64
ghcr.io/openhands/agent-server:vasco-mcp-oauth-fastmcp-expiry-floor-python-amd64
ghcr.io/openhands/agent-server:5a271e5-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:5a271e5-python-arm64
ghcr.io/openhands/agent-server:5a271e52140dc3f509ffafd6cc2d5d228247ab66-python-arm64
ghcr.io/openhands/agent-server:vasco-mcp-oauth-fastmcp-expiry-floor-python-arm64
ghcr.io/openhands/agent-server:5a271e5-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:5a271e5-python-slim-amd64
ghcr.io/openhands/agent-server:5a271e52140dc3f509ffafd6cc2d5d228247ab66-python-slim-amd64
ghcr.io/openhands/agent-server:vasco-mcp-oauth-fastmcp-expiry-floor-python-slim-amd64
ghcr.io/openhands/agent-server:5a271e5-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim-amd64
ghcr.io/openhands/agent-server:5a271e5-python-slim-arm64
ghcr.io/openhands/agent-server:5a271e52140dc3f509ffafd6cc2d5d228247ab66-python-slim-arm64
ghcr.io/openhands/agent-server:vasco-mcp-oauth-fastmcp-expiry-floor-python-slim-arm64
ghcr.io/openhands/agent-server:5a271e5-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim-arm64
ghcr.io/openhands/agent-server:5a271e5-golang
ghcr.io/openhands/agent-server:5a271e52140dc3f509ffafd6cc2d5d228247ab66-golang
ghcr.io/openhands/agent-server:vasco-mcp-oauth-fastmcp-expiry-floor-golang
ghcr.io/openhands/agent-server:5a271e5-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:5a271e5-java
ghcr.io/openhands/agent-server:5a271e52140dc3f509ffafd6cc2d5d228247ab66-java
ghcr.io/openhands/agent-server:vasco-mcp-oauth-fastmcp-expiry-floor-java
ghcr.io/openhands/agent-server:5a271e5-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:5a271e5-python-slim
ghcr.io/openhands/agent-server:5a271e52140dc3f509ffafd6cc2d5d228247ab66-python-slim
ghcr.io/openhands/agent-server:vasco-mcp-oauth-fastmcp-expiry-floor-python-slim
ghcr.io/openhands/agent-server:5a271e5-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim
ghcr.io/openhands/agent-server:5a271e5-python
ghcr.io/openhands/agent-server:5a271e52140dc3f509ffafd6cc2d5d228247ab66-python
ghcr.io/openhands/agent-server:vasco-mcp-oauth-fastmcp-expiry-floor-python
ghcr.io/openhands/agent-server:5a271e5-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 5a271e5-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 5a271e5-python-amd64) are also available if needed

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.
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
TOTAL42849781882% 
report-only-changed-files is enabled. No files were changed during this commit :)

@VascoSch92
VascoSch92 marked this pull request as ready for review September 4, 2026 13:47
@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 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 @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: 5a271e52140dc3f509ffafd6cc2d5d228247ab66
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/0f600fbe-8e4f-4053-a11f-1338f0a05f77

This comment was posted by an AI agent (OpenHands).

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: 5a271e52140dc3f509ffafd6cc2d5d228247ab66
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/8236903d-306b-409b-a70d-236335cf26c1

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.toml is the only pyproject.toml in the repo declaring fastmcp, so the floor exists exactly once; no other manifest needs a matching bump.
  • uv.lock still resolves fastmcp to 3.2.0 (the [[package]] entry is unchanged, upload-time = 2026-03-30, well past the 7-day freshness guardrail), and the requires-dist specifier in the openhands-sdk entry 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-server reads the persisted absolute expiry directly (MCPSettingsOAuthTokenStore in openhands-agent-server/openhands/agent_server/mcp_oauth_store.py only maps collections/keys; the only get_token_expiry consumer is inside fastmcp itself), so 3.2.0's read-back of /token_expiry is the only mechanism that makes the refresh grant fire. The repo-side key mapping already covers token_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.0fastmcp>=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:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger 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.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately on re-review.
  3. 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 /iterate to 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 all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.0 in openhands-sdk/pyproject.toml plus the matching requires-dist line in uv.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-newer window. 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:46 maps the /token_expiry collection, and openhands-sdk/openhands/sdk/settings/model.py:776-780 migrates token_expires_at into 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:

  1. One Validate PR description check 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.
  2. 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.

@VascoSch92
VascoSch92 merged commit e99c9e9 into main Sep 4, 2026
60 of 62 checks passed
@VascoSch92
VascoSch92 deleted the vasco/mcp-oauth-fastmcp-expiry-floor branch September 4, 2026 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants