Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions airbyte/mcp/http_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING
from urllib.parse import urlparse

from fastmcp.server.auth import MultiAuth
from fastmcp_extensions import (
assert_http_trusted_execution_disabled,
register_landing_page,
Expand All @@ -60,6 +62,10 @@
)


if TYPE_CHECKING:
from fastmcp.server.auth import AuthProvider


logger = logging.getLogger(__name__)

# Human-facing landing page shown when a browser GETs the MCP endpoint.
Expand All @@ -77,6 +83,37 @@ def _get_server_url() -> str:
return _env_or_default(MCP_SERVER_URL_ENV, DEFAULT_MCP_SERVER_URL)


def _advertise_root_mount_resource(auth: AuthProvider) -> None:
"""Advertise the slash-less public URL as the RFC 9728 resource at a root mount.

Behind a path-stripping load balancer the MCP endpoint is mounted at root
(`mcp_path="/"`), and FastMCP derives the protected-resource identifier from
that mount path — appending a trailing slash (e.g. `.../cloud-mcp/`). Strict
RFC 9728 clients canonicalize the connection URL to the slash-less form
(`.../cloud-mcp`) and reject the mismatch, so they cannot attach. FastMCP
already returns the bare base URL for a *root* mount path (`None`/`""`), so
this maps the `"/"` mount path onto that root case, leaving non-root mounts
(e.g. the local `"/mcp"` default) untouched.

Applied to every provider in the tree because the protected-resource
metadata document and the `WWW-Authenticate` challenge are built from
different providers (the interactive server versus the top-level `MultiAuth`).
"""
original = auth._get_resource_url # noqa: SLF001 # FastMCP has no public seam for this.

def resolve_resource_url(path: str | None = None): # noqa: ANN202
normalized = path if path and path != "/" else None
return original(normalized)

auth._get_resource_url = resolve_resource_url # type: ignore[method-assign] # noqa: SLF001

if isinstance(auth, MultiAuth):
if auth.server is not None:
_advertise_root_mount_resource(auth.server)
for verifier in auth.verifiers:
_advertise_root_mount_resource(verifier)


def main() -> None:
"""Start the Airbyte MCP server with HTTP transport."""
logging.basicConfig(level=logging.INFO)
Expand All @@ -91,6 +128,11 @@ def main() -> None:
# the bare server URL when mounted at root, otherwise the server URL + mcp_path.
endpoint_url = server_url if mcp_path == "/" else server_url.rstrip("/") + mcp_path

# At a root mount FastMCP would advertise a trailing-slash resource that
# strict RFC 9728 clients reject; pin it to the slash-less public URL.
if mcp_path == "/" and app.auth is not None:
_advertise_root_mount_resource(app.auth)

# Serve a browser-friendly landing page on GET at the MCP path. In stateless
# mode FastMCP only binds POST/DELETE there, so this GET route does not
# interfere with MCP traffic.
Expand Down
59 changes: 59 additions & 0 deletions tests/unit_tests/test_mcp_http_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
"""Unit tests for the HTTP transport entry point in `airbyte.mcp.http_main`.

These cover `_advertise_root_mount_resource`, which normalizes the RFC 9728
protected-resource identifier when the MCP endpoint is mounted at root behind a
path-stripping load balancer. Without it FastMCP advertises a trailing-slash
resource (e.g. `.../cloud-mcp/`) that strict clients reject because they
canonicalize the connection URL to the slash-less form (`.../cloud-mcp`).
"""

from __future__ import annotations

import pytest
from fastmcp.server.auth import MultiAuth
from fastmcp.server.auth.auth import TokenVerifier

from airbyte.mcp.http_main import _advertise_root_mount_resource


_BASE_URL = "https://mcp.internal.airbyte.ai/cloud-mcp"


class _FakeProvider(TokenVerifier):
"""Minimal concrete provider that inherits FastMCP's resource-URL logic."""

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

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



@pytest.mark.parametrize(
("mcp_path", "expected"),
[
pytest.param("/", _BASE_URL, id="root_mount_drops_trailing_slash"),
pytest.param("", _BASE_URL, id="empty_path_is_root"),
pytest.param(None, _BASE_URL, id="none_path_is_root"),
pytest.param("/mcp", f"{_BASE_URL}/mcp", id="non_root_path_unchanged"),
],
)
def test_advertise_root_mount_resource(mcp_path: str | None, expected: str) -> None:
"""The helper maps a root mount to the slash-less resource, leaving others intact."""
provider = _FakeProvider(base_url=_BASE_URL)

_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



def test_advertise_root_mount_resource_recurses_into_multiauth() -> None:
"""The fix reaches the interactive server and every headless verifier in the tree."""
server = _FakeProvider(base_url=_BASE_URL)
verifier = _FakeProvider(base_url=_BASE_URL)
multi = MultiAuth(server=server, verifiers=[verifier])

_advertise_root_mount_resource(multi)

for provider in (multi, server, verifier):
assert str(provider._get_resource_url("/")) == _BASE_URL
assert str(provider._get_resource_url("/mcp")) == f"{_BASE_URL}/mcp"
Loading