Skip to content

fix(mcp): advertise slash-less protected-resource at root mount - #1088

Open
Aaron ("AJ") Steers (aaronsteers) wants to merge 2 commits into
mainfrom
devin/1784914272-fix-prm-root-mount-slash
Open

fix(mcp): advertise slash-less protected-resource at root mount#1088
Aaron ("AJ") Steers (aaronsteers) wants to merge 2 commits into
mainfrom
devin/1784914272-fix-prm-root-mount-slash

Conversation

@aaronsteers

@aaronsteers Aaron ("AJ") Steers (aaronsteers) commented Jul 24, 2026

Copy link
Copy Markdown
Member

Summary

Strict RFC 9728 MCP clients can't connect to the hosted MCP servers: they reject the advertised protected-resource because it carries a trailing slash the client doesn't expect.

Protected resource https://mcp.internal.airbyte.ai/cloud-mcp/
does not match expected https://mcp.internal.airbyte.ai/cloud-mcp (or origin)

Root cause: behind the path-stripping load balancer we mount the MCP endpoint at root (mcp_path="/", see main()), and FastMCP derives the RFC 8707 resource id from that mount path — _get_resource_url("/") computes base_url.rstrip("/") + "/" + "", i.e. it appends a trailing slash (.../cloud-mcp/). The client canonicalizes the connection URL to the slash-less form and rejects the mismatch. (Lenient clients like Goose tolerated it, which is why this only surfaced on strict clients.)

Fix: at a root mount only, normalize the resource computation so "/" is treated as the root case (None), which FastMCP already resolves to the bare base_url (no slash). Non-root mounts (e.g. the local /mcp default) are untouched.

# new helper, invoked from main() only when mcp_path == "/"
def _advertise_root_mount_resource(auth):
    original = auth._get_resource_url
    def resolve_resource_url(path=None):
        normalized = path if path and path != "/" else None   # "/" -> None
        return original(normalized)
    auth._get_resource_url = resolve_resource_url
    # recurse: the PRM document and the WWW-Authenticate challenge are built
    # from different providers (interactive server vs. top-level MultiAuth)
    if isinstance(auth, MultiAuth):
        ...recurse into auth.server and auth.verifiers...

Net effect (verified locally against the real OIDC provider tree, MCP_SERVER_URL=https://mcp.internal.airbyte.ai/cloud-mcp):

              route path                                   document `resource` / WWW-Authenticate
before  /.well-known/oauth-protected-resource/cloud-mcp/   https://mcp.internal.airbyte.ai/cloud-mcp/   (rejected)
after   /.well-known/oauth-protected-resource/cloud-mcp    https://mcp.internal.airbyte.ai/cloud-mcp    (accepted)

The recursion is required because MultiAuth (interactive OIDC + headless JWT) builds the PRM document from server while the WWW-Authenticate challenge resolves through the top-level provider — both must agree, or the client sees an inconsistent challenge.

FastMCP exposes no public seam for this, so the helper wraps the private _get_resource_url (annotated # noqa: SLF001, matching the repo's existing non-public-API-access convention). Applies to the hosted cloud-mcp deployment; the equivalent fix for ops-mcp lives in its own repo.

Tests: tests/unit_tests/test_mcp_http_main.py — parametrized over root (/, "", None) → slash-less and non-root (/mcp) → unchanged, plus MultiAuth recursion into the interactive server and headless verifiers.

Link to Devin session: https://app.devin.ai/sessions/a5b9501ef92c412aad0408b7c74ef9c8
Requested by: Aaron ("AJ") Steers (@aaronsteers)

Summary by CodeRabbit

  • Bug Fixes
    • Fixed protected-resource URL formatting when the MCP endpoint is mounted at the server root, removing the trailing slash.
    • Improved compatibility with strict RFC 9728 clients by normalizing the root resource identifier.
    • Ensured consistent URL handling across authentication providers, including composed/nested authentication setups.
  • Tests
    • Added unit coverage for root vs non-root mount paths and validation of normalization propagation through multi-provider authentication.

Copilot AI review requested due to automatic review settings July 24, 2026 17:35
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration
devin-ai-integration Bot marked this pull request as ready for review July 24, 2026 17:35
@github-actions

Copy link
Copy Markdown

👋 Greetings, Airbyte Team Member!

Here are some helpful tips and reminders for your convenience.

💡 Show Tips and Tricks

Testing This PyAirbyte Version

You can test this version of PyAirbyte using the following:

# Run PyAirbyte CLI from this branch:
uvx --from 'git+https://github.com/airbytehq/PyAirbyte.git@devin/1784914272-fix-prm-root-mount-slash' pyairbyte --help

# Install PyAirbyte from this branch for development:
pip install 'git+https://github.com/airbytehq/PyAirbyte.git@devin/1784914272-fix-prm-root-mount-slash'

PR Slash Commands

Airbyte Maintainers can execute the following slash commands on your PR:

  • /fix-pr - Fixes most formatting and linting issues
  • /uv-lock - Updates uv.lock file
  • /test-pr - Runs tests with the updated PyAirbyte
  • /prerelease - Builds and publishes a prerelease version to PyPI
📚 Show Repo Guidance

Helpful Resources

Community Support

Questions? Join the #pyairbyte channel in our Slack workspace.

📝 Edit this welcome message.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes an incompatibility where strict RFC 9728 MCP clients reject the advertised OAuth protected-resource URL when the MCP endpoint is mounted at / behind a path-stripping load balancer (FastMCP was producing a trailing-slash resource).

Changes:

  • Add _advertise_root_mount_resource() to normalize "/"None when resolving the RFC 8707 resource URL, and recurse through MultiAuth provider trees.
  • Apply the normalization at startup only when the derived mcp_path == "/" and authentication is enabled.
  • Add unit tests covering root vs non-root mounts and MultiAuth recursion.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
airbyte/mcp/http_main.py Adds root-mount-only normalization of the advertised protected-resource URL (including MultiAuth recursion) and applies it during HTTP startup.
tests/unit_tests/test_mcp_http_main.py Adds unit tests validating slash-less root resource behavior and MultiAuth provider-tree coverage.
Comments suppressed due to low confidence (1)

tests/unit_tests/test_mcp_http_main.py:59

  • These asserts access the private _get_resource_url method and will be flagged by Ruff SLF001. Suppress explicitly on each line to keep lint passing.
        assert str(provider._get_resource_url("/")) == _BASE_URL
        assert str(provider._get_resource_url("/mcp")) == f"{_BASE_URL}/mcp"

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


_advertise_root_mount_resource(provider)

assert str(provider._get_resource_url(mcp_path)) == expected

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚫 Not fixing — false positive. ruff check passes clean on this file with SLF enabled. Ruff's flake8-self intentionally does not flag private-member access when the receiver is an instance of a class defined in the same module (_FakeProvider here), so no # noqa: SLF001 is needed. Adding one would itself trigger RUF100 (unused-noqa).


Devin session

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9cc40cae-b782-4033-9dd5-f92ae47764f5

📥 Commits

Reviewing files that changed from the base of the PR and between 8215514 and 87d818d.

📒 Files selected for processing (1)
  • airbyte/mcp/http_main.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • airbyte/mcp/http_main.py

📝 Walkthrough

Walkthrough

The HTTP MCP entrypoint now normalizes protected-resource URLs for authenticated root mounts, including nested MultiAuth providers. Unit tests cover root-equivalent paths, non-root paths, and recursive propagation.

Changes

Root-mounted authentication URLs

Layer / File(s) Summary
Resource URL normalization
airbyte/mcp/http_main.py
Adds _advertise_root_mount_resource, recursively overrides auth-provider URL generation, and invokes it for authenticated root mounts.
URL normalization tests
tests/unit_tests/test_mcp_http_main.py
Tests slash-less root resource URLs, unchanged non-root paths, and recursive MultiAuth behavior.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • airbytehq/PyAirbyte#1051: Derives mcp_path from MCP_SERVER_URL, including possible root mounts handled by this change.

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the root-mount protected-resource URL normalization change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch devin/1784914272-fix-prm-root-mount-slash

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/unit_tests/test_mcp_http_main.py (1)

31-59: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Could we add a focused main() wiring test?

These tests exercise _advertise_root_mount_resource directly, so they would still pass if main() stopped calling it at the root mount. A small test covering authenticated root and non-root startup paths would protect the production branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit_tests/test_mcp_http_main.py` around lines 31 - 59, Add a focused
test for main() startup wiring that verifies _advertise_root_mount_resource is
invoked for both authenticated root-mount and non-root configurations. Keep the
existing helper behavior tests, and assert the production startup path applies
the expected resource URL handling in each case.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@airbyte/mcp/http_main.py`:
- Line 87: Update the docstring describing the slash-less public URL and root
mount to reference RFC 9728 instead of RFC 8707, leaving the implementation
unchanged.

In `@tests/unit_tests/test_mcp_http_main.py`:
- Around line 26-28: Update the test fake’s verify_token method to use the
FastMCP-compatible AccessToken | None return annotation instead of -> None,
while preserving its behavior of always returning None. Import or reference
AccessToken using the project’s existing typing conventions.

---

Nitpick comments:
In `@tests/unit_tests/test_mcp_http_main.py`:
- Around line 31-59: Add a focused test for main() startup wiring that verifies
_advertise_root_mount_resource is invoked for both authenticated root-mount and
non-root configurations. Keep the existing helper behavior tests, and assert the
production startup path applies the expected resource URL handling in each case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e1ffa3c2-df72-4a67-bc8f-cb10993c10d3

📥 Commits

Reviewing files that changed from the base of the PR and between bacbe7b and 8215514.

📒 Files selected for processing (2)
  • airbyte/mcp/http_main.py
  • tests/unit_tests/test_mcp_http_main.py

Comment thread airbyte/mcp/http_main.py Outdated
Comment on lines +26 to +28
async def verify_token(self, token: str) -> None:
"""Never authenticates; the resource-URL logic is what is under test."""
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
fd -a 'test_mcp_http_main.py' . || true
rg -n "class .*Dependency|verify_token|FastMCP|AccessToken" -S . --glob '!**/.git/**' --glob '!**/node_modules/**' | head -200

echo
echo "== test file excerpt =="
file="$(fd 'test_mcp_http_main.py' . | head -1 || true)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  sed -n '1,80p' "$file" | cat -n
fi

echo
echo "== dependency definition excerpt =="
dep="$(fd -i '.*dependency.*' . | head -200 | rg 'mcp|http|fast|mcp' | head -20 || true)"
echo "$dep"
if [ -n "$dep" ]; then
  for f in $dep; do
    echo "--- $f ---"
    wc -l "$f"
    rg -n "verify_token|AccessToken|return None" "$f" -C 6 || true
  done
fi

Repository: airbytehq/PyAirbyte

Length of output: 8076


🌐 Web query:

FastMCP dependency injection verify_token return type AccessToken | None

💡 Result:

In FastMCP, the verify_token method, which is implemented by authentication providers to validate bearer tokens, indeed has a return type of AccessToken | None [1][2][3]. When a token is valid, it returns an AccessToken object containing information such as the client ID, scopes, expiration, and claims [1][4][5]. If the token is invalid, expired, or otherwise fails verification, it returns None [1][6][7]. Regarding dependency injection: - To access the authenticated user's token in a tool or dependency, you can use get_access_token(), which returns AccessToken | None [4][8][9]. This is useful when authentication is optional [4][10]. - For cases where you want to enforce authentication and have the dependency raise an error if no token is found, you can use CurrentAccessToken() (an injectable dependency) [4][8][10].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== local environment availability =="
python3 - <<'PY'
try:
    import fastmcp
    print("fastmcp_available=true", getattr(fastmcp, "__version__", None))
except Exception as e:
    print("fastmcp_available=false", repr(e))
PY

echo
echo "== installed token verifier signature/types if available =="
python3 - <<'PY'
import inspect
try:
    from fastmcp.server.auth.auth import TokenVerifier
    print("TokenVerifier_available=true")
    sig = inspect.signature(TokenVerifier.verify_token)
    print("verify_token_signature=", repr(sig))
    annotations = getattr(TokenVerifier.verify_token, "__annotations__", {})
    print("verify_token_annotations=", repr(annotations))
    print("has_return_annotation=", "return" in annotations)
    print("return_annotation_string=", str(annotations.get("return", "<no return annotation>")))
except Exception as e:
    print("TokenVerifier_available=false", repr(e))
PY

Repository: airbytehq/PyAirbyte

Length of output: 407


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== requirements / pyproject references =="
for f in pyproject.toml requirements.txt poetry.lock README.md; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    rg -n "fastmcp|fast-mcp|FastMCP" "$f" || true
  fi
done

echo
echo "== vendored/fastmcp presence if any =="
git ls-files | rg 'fastmcp|FastMCP' || true

Repository: airbytehq/PyAirbyte

Length of output: 706


🌐 Web query:

FastMCP verify_token return type "async def verify_token" "AccessToken | None"

💡 Result:

In the FastMCP framework, the verify_token method is defined as an asynchronous function (async def) that returns an AccessToken object if the token is valid, or None if the token is invalid, expired, or otherwise fails validation [1][2][3]. This signature is a core requirement for classes implementing the TokenVerifier protocol [1][4][5]. When implementing a custom token verifier, your verify_token method should follow this pattern [6]: async def verify_token(self, token: str) -> AccessToken | None: # Perform validation logic if is_valid(token): return AccessToken(...) return None The AccessToken object returned contains authentication details such as the token string, client ID, granted scopes, expiration time, and additional claims [7].

Citations:


Preserve FastMCP’s verify_token return annotation?

TokenVerifier.verify_token is AsyncCallable[..., AccessToken | None], so -> None is runtime-safe here because this fake always returns None, but -> AccessToken | None would close the static override gap. What do you think?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit_tests/test_mcp_http_main.py` around lines 26 - 28, Update the test
fake’s verify_token method to use the FastMCP-compatible AccessToken | None
return annotation instead of -> None, while preserving its behavior of always
returning None. Import or reference AccessToken using the project’s existing
typing conventions.

Source: MCP tools

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚫 Not fixing. -> None is a valid (covariant) narrowing of the base AsyncCallable[..., AccessToken | None] return type, so it's runtime-safe and statically sound — Pyrefly is clean. This fake never returns a token; annotating -> AccessToken | None would only widen it to a value it never produces. Keeping -> None avoids an extra AccessToken import for a test whose subject is the resource-URL logic, not token verification. Happy to change it if a maintainer prefers the exact base signature.


Devin session

@github-code-quality

github-code-quality Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: Python

Python / code-coverage/pytest-fast

The overall coverage in commit 87d818d in the devin/1784914272-fix... branch is 68%. The coverage in commit d9f652f in the main branch is 65%.

Show a code coverage summary of the most impacted files.
File main d9f652f devin/1784914272-fix... 87d818d +/-
airbyte/mcp/_tool_utils.py 72% 84% +12%
airbyte/mcp/registry.py 53% 70% +17%
airbyte/mcp/server.py 69% 88% +19%
airbyte/mcp/_arg_resolvers.py 13% 44% +31%
airbyte/mcp/int...c_history_ui.py 0% 36% +36%
airbyte/mcp/http_main.py 0% 62% +62%
airbyte/mcp/int...hared_models.py 0% 81% +81%
airbyte/cloud/models.py 0% 91% +91%
airbyte/mcp/int..._registry_ui.py 0% 92% +92%
airbyte/mcp/int...nc_status_ui.py 0% 97% +97%

Python / code-coverage/pytest-no-creds

The overall coverage in commit 87d818d in the devin/1784914272-fix... branch is 68%. The coverage in commit d9f652f in the main branch is 65%.

Show a code coverage summary of the most impacted files.
File main d9f652f devin/1784914272-fix... 87d818d +/-
airbyte/mcp/_tool_utils.py 72% 84% +12%
airbyte/mcp/registry.py 53% 70% +17%
airbyte/mcp/server.py 69% 88% +19%
airbyte/mcp/_arg_resolvers.py 13% 44% +31%
airbyte/mcp/int...c_history_ui.py 0% 36% +36%
airbyte/mcp/http_main.py 0% 62% +62%
airbyte/mcp/int...hared_models.py 0% 81% +81%
airbyte/cloud/models.py 0% 91% +91%
airbyte/mcp/int..._registry_ui.py 0% 92% +92%
airbyte/mcp/int...nc_status_ui.py 0% 97% +97%

Python / code-coverage/pytest

The overall coverage in commit 87d818d in the devin/1784914272-fix... branch is 73%. The coverage in commit d9f652f in the main branch is 71%.

Show a code coverage summary of the most impacted files.
File main d9f652f devin/1784914272-fix... 87d818d +/-
airbyte/mcp/_tool_utils.py 72% 84% +12%
airbyte/mcp/registry.py 53% 70% +17%
airbyte/mcp/server.py 69% 88% +19%
airbyte/mcp/_arg_resolvers.py 13% 44% +31%
airbyte/mcp/int...c_history_ui.py 0% 36% +36%
airbyte/mcp/http_main.py 0% 62% +62%
airbyte/mcp/int...hared_models.py 0% 81% +81%
airbyte/mcp/int..._registry_ui.py 0% 92% +92%
airbyte/cloud/models.py 0% 93% +93%
airbyte/mcp/int...nc_status_ui.py 0% 97% +97%

Updated July 24, 2026 18:13 UTC

Co-Authored-By: AJ Steers <aj@airbyte.io>
Copilot AI review requested due to automatic review settings July 24, 2026 17:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

from airbyte.mcp.http_main import _advertise_root_mount_resource


_BASE_URL = "https://mcp.internal.airbyte.ai/cloud-mcp"
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.

2 participants