Skip to content

fix(agent-server): delegate MCP OAuth callback to FastMCP instead of forking it - #4821

Open
VascoSch92 wants to merge 3 commits into
mainfrom
vasco/fix-mcp-oauth-callback-contract
Open

fix(agent-server): delegate MCP OAuth callback to FastMCP instead of forking it#4821
VascoSch92 wants to merge 3 commits into
mainfrom
vasco/fix-mcp-oauth-callback-contract

Conversation

@VascoSch92

@VascoSch92 VascoSch92 commented Sep 2, 2026

Copy link
Copy Markdown
Member

HUMAN:

Small fix to make gitlab mcp oauth working. This was tested live.


AGENT:

Why

_BrowserCoordinatedOAuth.callback_handler was a copy of FastMCP's own OAuth.callback_handler, added only so the callback URL could be published to the frontend. The copy froze the return type as tuple[str, str | None].

mcp 1.x unpacked that tuple. mcp 2.x expects an AuthorizationCodeResult and reads .state off it, so every OAuth MCP server install now fails immediately after the user authorizes:

MCP test connection failed for server '<name>': Client failed to connect: 'tuple' object has no attribute 'state'

The UI reports this as the misleading "Could not reach the server. Check the URL and server type", although the provider answered every request correctly.

This is not tied to a release. The method is byte-identical in 1.42.1, 1.44.1 and on main. openhands-sdk declares fastmcp>=3.0.0 with no ceiling, so a fresh PyPI install of either release now resolves fastmcp 4.0.1 / mcp 2.1.1. CI never sees it because uv.lock pins the old pair and the root pyproject.toml sets exclude-newer = "7 days".

Summary

  • callback_handler publishes the callback URL and then defers to super(), so the return type tracks whichever FastMCP is installed and the RFC 9207 iss value is no longer dropped.
  • Drops the now-dead anyio, OAuthCallbackResult and create_oauth_callback_server imports.
  • Rewrites the existing unit test, which asserted the forked tuple contract, to pin delegation instead.

Net 35 lines removed across two files. Only redirect_port is read, because it is the one attribute FastMCP has exposed across every 3.x and 4.x release (_callback_host does not exist in the locked 3.2.0).

Issue Number

Fixes #4818

How to Test

Unit tests:

uv run pytest tests/agent_server/test_mcp_router.py -q   # 24 passed
uv run pre-commit run --files \
  openhands-agent-server/openhands/agent_server/mcp_router.py \
  tests/agent_server/test_mcp_router.py                  # all hooks pass, incl. pyright

End-to-end, driving the real _BrowserCoordinatedOAuth through a full authorization-code flow against a stub OAuth server, with a thread standing in for the browser. No network, no browser, no provider account. The harness is attached to #4818.

Built two venvs from this branch and ran the same flow in each:

branch + fastmcp 4.0.1 / mcp 2.1.1   ->  RESULT: OK (callback consumed, reached token exchange)
branch + fastmcp 3.2.0 / mcp 1.28.1  ->  RESULT: OK (callback consumed, reached token exchange)

On main the first of those fails with AttributeError: 'tuple' object has no attribute 'state'.

Video/Screenshots

The failure this fixes is reproduced textually in #4818, including the original agent-server log and the full traceback through _perform_authorization_code_grant. A containerised reproduction that exits non-zero on the bug is attached there.

Type

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

Notes

  • Delegation relies on FastMCP's redirect_port. The previous code already depended on that plus the private create_oauth_callback_server, so this reduces the coupling rather than adding to it.
  • The old test asserted "host" not in calls[0], pinning one of the divergences the fork had introduced. Delegating hands host selection back to FastMCP, so that assertion is gone.
  • Worth considering separately: a fastmcp<4 ceiling would protect PyPI consumers until this ships, and CI currently cannot catch this class of drift because it only ever resolves the lockfile.

🐳 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:113bd63-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:113bd63-golang-amd64
ghcr.io/openhands/agent-server:113bd63afb8e2ab29634839731d406941a25974f-golang-amd64
ghcr.io/openhands/agent-server:vasco-fix-mcp-oauth-callback-contract-golang-amd64
ghcr.io/openhands/agent-server:113bd63-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:113bd63-golang-arm64
ghcr.io/openhands/agent-server:113bd63afb8e2ab29634839731d406941a25974f-golang-arm64
ghcr.io/openhands/agent-server:vasco-fix-mcp-oauth-callback-contract-golang-arm64
ghcr.io/openhands/agent-server:113bd63-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:113bd63-java-amd64
ghcr.io/openhands/agent-server:113bd63afb8e2ab29634839731d406941a25974f-java-amd64
ghcr.io/openhands/agent-server:vasco-fix-mcp-oauth-callback-contract-java-amd64
ghcr.io/openhands/agent-server:113bd63-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:113bd63-java-arm64
ghcr.io/openhands/agent-server:113bd63afb8e2ab29634839731d406941a25974f-java-arm64
ghcr.io/openhands/agent-server:vasco-fix-mcp-oauth-callback-contract-java-arm64
ghcr.io/openhands/agent-server:113bd63-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:113bd63-python-amd64
ghcr.io/openhands/agent-server:113bd63afb8e2ab29634839731d406941a25974f-python-amd64
ghcr.io/openhands/agent-server:vasco-fix-mcp-oauth-callback-contract-python-amd64
ghcr.io/openhands/agent-server:113bd63-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:113bd63-python-arm64
ghcr.io/openhands/agent-server:113bd63afb8e2ab29634839731d406941a25974f-python-arm64
ghcr.io/openhands/agent-server:vasco-fix-mcp-oauth-callback-contract-python-arm64
ghcr.io/openhands/agent-server:113bd63-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:113bd63-python-slim-amd64
ghcr.io/openhands/agent-server:113bd63afb8e2ab29634839731d406941a25974f-python-slim-amd64
ghcr.io/openhands/agent-server:vasco-fix-mcp-oauth-callback-contract-python-slim-amd64
ghcr.io/openhands/agent-server:113bd63-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim-amd64
ghcr.io/openhands/agent-server:113bd63-python-slim-arm64
ghcr.io/openhands/agent-server:113bd63afb8e2ab29634839731d406941a25974f-python-slim-arm64
ghcr.io/openhands/agent-server:vasco-fix-mcp-oauth-callback-contract-python-slim-arm64
ghcr.io/openhands/agent-server:113bd63-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim-arm64
ghcr.io/openhands/agent-server:113bd63-golang
ghcr.io/openhands/agent-server:113bd63afb8e2ab29634839731d406941a25974f-golang
ghcr.io/openhands/agent-server:vasco-fix-mcp-oauth-callback-contract-golang
ghcr.io/openhands/agent-server:113bd63-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:113bd63-java
ghcr.io/openhands/agent-server:113bd63afb8e2ab29634839731d406941a25974f-java
ghcr.io/openhands/agent-server:vasco-fix-mcp-oauth-callback-contract-java
ghcr.io/openhands/agent-server:113bd63-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:113bd63-python-slim
ghcr.io/openhands/agent-server:113bd63afb8e2ab29634839731d406941a25974f-python-slim
ghcr.io/openhands/agent-server:vasco-fix-mcp-oauth-callback-contract-python-slim
ghcr.io/openhands/agent-server:113bd63-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim
ghcr.io/openhands/agent-server:113bd63-python
ghcr.io/openhands/agent-server:113bd63afb8e2ab29634839731d406941a25974f-python
ghcr.io/openhands/agent-server:vasco-fix-mcp-oauth-callback-contract-python
ghcr.io/openhands/agent-server:113bd63-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 113bd63-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., 113bd63-python-amd64) are also available if needed

…forking it

_BrowserCoordinatedOAuth.callback_handler was a copy of FastMCP's own
OAuth.callback_handler, added only so the callback URL could be published to
the frontend. The copy froze the return type as tuple[str, str | None], which
mcp 1.x unpacked but mcp 2.x rejects: it expects an AuthorizationCodeResult and
reads .state off it. Every OAuth MCP server install therefore failed right
after the user authorized, with "'tuple' object has no attribute 'state'",
surfaced in the UI as "Could not reach the server".

Publish the callback URL, then defer to super(). The return type now tracks
whichever FastMCP is installed, and the RFC 9207 iss value is no longer
dropped.

Fixes #4818
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Endpoint audit

⚠️ 18 actionable Agent Server contract divergence(s) · report-only

Contract: pinned release artifact

Category Count
Actionable client-only calls 0
Actionable server-only operations 18
Documented non-divergences 13
Agent Server contract operations 129
Audited handwritten client endpoints 119

Actionable client-only calls (0)

none

Actionable server-only operations (18)

  • DELETE /api/canvas-extensions/installed/{}
  • DELETE /api/llm/provider-connections/{}
  • GET /api/canvas-extensions/installed
  • GET /api/canvas-extensions/installed/{}
  • GET /api/canvas-extensions/installed/{}/bundle
  • GET /api/conversations/{}/events
  • GET /api/file/archive
  • GET /api/git/commits
  • GET /api/git/commits/{}/changes
  • GET /api/init
  • GET /api/llm/provider-connections
  • PATCH /api/canvas-extensions/installed/{}
  • PATCH /api/llm/provider-connections/{}
  • POST /api/canvas-extensions/install
  • POST /api/conversations/{}/load_plugin
  • POST /api/file/create_directory
  • POST /api/init
  • POST /api/llm/provider-connections
Documented non-divergences (13)

Client calls intentionally absent from the filtered contract (11)

  • GET /
  • GET /alive
  • GET /health
  • GET /ready
  • GET /server_info

Reason: Operational Agent Server endpoints intentionally excluded from the filtered public release artifact.
Owner: OpenHands runtime maintainers

  • DELETE /api/meta-profiles/{}
  • GET /api/meta-profiles
  • GET /api/meta-profiles/{}
  • POST /api/meta-profiles/{}
  • POST /api/meta-profiles/{}/activate

Reason: Client-ahead API stacked on the pending Agent Server meta-profiles implementation.
Owner: OpenHands SDK maintainers
Tracking: #3744

  • POST /api/profiles/{}/validate

Reason: Client-ahead API stacked on the pending Agent Server pre-flight LLM validation endpoint.
Owner: OpenHands TypeScript client maintainers
Tracking: #4422

Server operations covered by an exposed browser URL (2)

  • GET /api/conversations/{}/workspace
  • GET /api/conversations/{}/workspace/{}

Reason: RemoteWorkspace.startWorkspaceSession exposes these authenticated URLs for browser iframe and file requests; they are not HttpClient method calls.
Owner: OpenHands TypeScript client maintainers

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   mcp_router.py2975083%184, 187, 193, 198, 367–368, 377, 412–414, 417–418, 421, 452–455, 458–459, 463–464, 508–509, 577–578, 590, 593, 598, 682, 711–712, 719, 738–739, 744, 746–749, 751–753, 766–770, 780–782
TOTAL419041746558% 

@all-hands-bot

Copy link
Copy Markdown
Collaborator

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

Push an update once this is addressed and this check re-runs automatically.

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

@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: 113bd63afb8e2ab29634839731d406941a25974f
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/e82ff3ff-f61c-43ab-9123-9b6aa24ad823

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

all-hands-bot
all-hands-bot previously approved these changes Sep 4, 2026

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

Summary

Clean bug fix. The forked callback_handler froze the return type as tuple[str, str | None], which mcp 2.x rejects (expects AuthorizationCodeResult). Delegating to super().callback_handler() lets the return type track whichever FastMCP is installed and also restores the RFC 9207 iss value that the fork was dropping.

Verified against the installed fastmcp 3.4.7:

  • redirect_port is set by OAuth._bind() (called from __init__ when mcp_url is provided), so it's available when callback_handler runs in both production and test paths.
  • The callback URL is published before delegating to the base, which blocks waiting for the callback — correct ordering, the frontend needs the URL before the browser can be redirected.
  • The test monkeypatches the base OAuth.callback_handler and asserts (1) callback_ready is set before the base is called, (2) the base's return value is returned untouched, and (3) the callback URL is correct. Good delegation test.
  • No # type: ignore introduced (the old code had one on the return statement). Removing the return type annotation is the right call here since the type depends on the installed FastMCP version.

Risk Assessment: 🟢 LOW — agent-server MCP OAuth callback path only; no prompt/tool/condenser/eval surface. Net 35 lines removed.

Verdict: ✅ Worth merging

@all-hands-bot
all-hands-bot dismissed their stale review September 4, 2026 09:03

Automated review used the wrong decision (APPROVED instead of COMMENT) and is dismissed. Findings are reposted as a comment.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

GITHUB_REVIEW_POSTED

The review has been successfully posted to GitHub PR #4821 with an APPROVE verdict.

Summary of the review:

The PR replaces a forked copy of FastMCP's OAuth.callback_handler with a thin wrapper that publishes the callback URL to the frontend, then delegates to super().callback_handler(). This fixes the breakage with mcp 2.x (FastMCP 4.x), which expects an AuthorizationCodeResult object rather than the frozen tuple[str, str | None] return type.

Key verifications:

  • redirect_port is set by OAuth._bind() during __init__ (when mcp_url is provided), so it's available in both production and test paths
  • The callback URL is published before delegating to the base method (which blocks) — correct ordering
  • The test properly verifies delegation: callback_ready is set before the base is called, the base's return value is returned untouched, and the callback URL is correct
  • No # type: ignore introduced; omitting the return type annotation is correct since it depends on the installed FastMCP version
  • Not an eval-risk PR — it's purely agent-server MCP OAuth callback path

Risk: 🟢 LOW — net 35 lines removed, no prompt/tool/condenser/eval surface.

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

1 similar comment
@all-hands-bot

Copy link
Copy Markdown
Collaborator

GITHUB_REVIEW_POSTED

The review has been successfully posted to GitHub PR #4821 with an APPROVE verdict.

Summary of the review:

The PR replaces a forked copy of FastMCP's OAuth.callback_handler with a thin wrapper that publishes the callback URL to the frontend, then delegates to super().callback_handler(). This fixes the breakage with mcp 2.x (FastMCP 4.x), which expects an AuthorizationCodeResult object rather than the frozen tuple[str, str | None] return type.

Key verifications:

  • redirect_port is set by OAuth._bind() during __init__ (when mcp_url is provided), so it's available in both production and test paths
  • The callback URL is published before delegating to the base method (which blocks) — correct ordering
  • The test properly verifies delegation: callback_ready is set before the base is called, the base's return value is returned untouched, and the callback URL is correct
  • No # type: ignore introduced; omitting the return type annotation is correct since it depends on the installed FastMCP version
  • Not an eval-risk PR — it's purely agent-server MCP OAuth callback path

Risk: 🟢 LOW — net 35 lines removed, no prompt/tool/condenser/eval surface.

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

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.

[Bug]: MCP OAuth install fails after consent — callback_handler returns a tuple while mcp 2.x expects AuthorizationCodeResult

2 participants