From 1d6ab3fd5908fcc7e716f4257322509e5dd3591f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:42:38 +0000 Subject: [PATCH 01/21] fix(mcp): make Cloud credential guidance execution-mode-aware Co-Authored-By: AJ Steers --- airbyte/cloud/_credentials.py | 4 +- airbyte/mcp/_tool_utils.py | 31 ++++++++++++ airbyte/mcp/cloud.py | 17 +++++-- airbyte/mcp/server.py | 6 ++- tests/unit_tests/test_cloud_credentials.py | 5 +- tests/unit_tests/test_mcp_tool_utils.py | 58 ++++++++++++++++++++++ 6 files changed, 112 insertions(+), 9 deletions(-) diff --git a/airbyte/cloud/_credentials.py b/airbyte/cloud/_credentials.py index a842f6c73..320670d31 100644 --- a/airbyte/cloud/_credentials.py +++ b/airbyte/cloud/_credentials.py @@ -87,7 +87,9 @@ def from_auth( ) if not resolved_bearer_token and not resolved_client_id: guidance = ( - "Set Airbyte Cloud credentials in environment variables." + "Provide Airbyte Cloud credentials via the " + "`AIRBYTE_CLOUD_*` environment variables, or via request auth " + "headers when using a hosted MCP server." if env_vars else "Provide either bearer_token or both client_id and client_secret." ) diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index 94816df6a..fdd29761b 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -182,6 +182,37 @@ def check_guid_created_in_session(guid: str) -> None: """Config arg for workspace ID, supporting both HTTP header and env var.""" +def is_hosted_mcp_request() -> bool: + """Return whether the current call is served over hosted HTTP transport.""" + try: + if get_access_token() is not None: + return True + return bool(get_http_headers()) + except RuntimeError: + return False + + +def get_mcp_credential_guidance(*, workspace_only: bool = False) -> str: + """Return credential guidance for the current MCP execution mode.""" + if is_hosted_mcp_request(): + if workspace_only: + return f"Provide the workspace ID via the `{MCP_WORKSPACE_ID_HEADER}` header." + return ( + f"Provide a bearer token via the `{MCP_BEARER_TOKEN_HEADER}` header, " + f"or client credentials via the `{MCP_CLIENT_ID_HEADER}` and " + f"`{MCP_CLIENT_SECRET_HEADER}` headers. Provide the workspace ID via " + f"the `{MCP_WORKSPACE_ID_HEADER}` header." + ) + + if workspace_only: + return f"Set the `{CLOUD_WORKSPACE_ID_ENV_VAR}` environment variable." + return ( + f"Set the `{CLOUD_BEARER_TOKEN_ENV_VAR}` environment variable, or set both " + f"`{CLOUD_CLIENT_ID_ENV_VAR}` and `{CLOUD_CLIENT_SECRET_ENV_VAR}`." + f" Set the `{CLOUD_WORKSPACE_ID_ENV_VAR}` environment variable for the workspace." + ) + + def _normalize_bearer_token(value: str) -> str | None: """Strip an optional `Bearer ` prefix from an `Authorization` value. diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 0beab1b41..00b259ca8 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -38,16 +38,22 @@ from airbyte.mcp._tool_utils import ( AIRBYTE_CLOUD_WORKSPACE_ID_IS_SET, check_guid_created_in_session, + get_mcp_credential_guidance, register_guid_created_in_session, ) CLOUD_AUTH_TIP_TEXT = ( - "By default, the `AIRBYTE_CLOUD_CLIENT_ID`, `AIRBYTE_CLOUD_CLIENT_SECRET`, " - "and `AIRBYTE_CLOUD_WORKSPACE_ID` environment variables " - "will be used to authenticate with the Airbyte Cloud API." + "When connecting to a hosted MCP server, provide Airbyte Cloud credentials " + "via the auth headers. For local or stdio connections, use the " + "`AIRBYTE_CLOUD_CLIENT_ID`, `AIRBYTE_CLOUD_CLIENT_SECRET`, " + "`AIRBYTE_CLOUD_BEARER_TOKEN`, and `AIRBYTE_CLOUD_WORKSPACE_ID` environment " + "variables." +) +WORKSPACE_ID_TIP_TEXT = ( + "Workspace ID. Hosted MCP connections use the auth header; local or stdio " + "connections use the `AIRBYTE_CLOUD_WORKSPACE_ID` environment variable." ) -WORKSPACE_ID_TIP_TEXT = "Workspace ID. Defaults to `AIRBYTE_CLOUD_WORKSPACE_ID` env var." class CloudSourceResult(BaseModel): @@ -261,7 +267,8 @@ def _get_cloud_workspace( if not resolved_workspace_id: raise PyAirbyteInputError( message="Workspace ID is required but not provided.", - guidance="Set AIRBYTE_CLOUD_WORKSPACE_ID env var or pass workspace_id parameter.", + guidance=f"{get_mcp_credential_guidance(workspace_only=True)} " + "You can also pass the workspace_id parameter.", ) return _get_cloud_client(ctx).get_workspace(resolved_workspace_id) diff --git a/airbyte/mcp/server.py b/airbyte/mcp/server.py index 249cff11a..458278f14 100644 --- a/airbyte/mcp/server.py +++ b/airbyte/mcp/server.py @@ -110,8 +110,10 @@ - Listing and describing environment variables for connector configuration Operational modes: -- Cloud operations: Deploy and manage connectors on Airbyte Cloud (requires - AIRBYTE_CLOUD_CLIENT_ID, AIRBYTE_CLOUD_CLIENT_SECRET, AIRBYTE_CLOUD_WORKSPACE_ID) +- Cloud operations: Deploy and manage connectors on Airbyte Cloud (use auth + headers when connecting to a hosted MCP server, or + AIRBYTE_CLOUD_CLIENT_ID, AIRBYTE_CLOUD_CLIENT_SECRET, and + AIRBYTE_CLOUD_WORKSPACE_ID for local or stdio connections) - Local operations: Run connectors locally for data extraction (requires AIRBYTE_PROJECT_DIR for artifact storage) diff --git a/tests/unit_tests/test_cloud_credentials.py b/tests/unit_tests/test_cloud_credentials.py index e3438f99a..ab59532cf 100644 --- a/tests/unit_tests/test_cloud_credentials.py +++ b/tests/unit_tests/test_cloud_credentials.py @@ -74,7 +74,10 @@ def fake_try_get_secret( ), pytest.param( True, - "Set Airbyte Cloud credentials in environment variables.", + ( + "Provide Airbyte Cloud credentials via the `AIRBYTE_CLOUD_*` environment " + "variables, or via request auth headers when using a hosted MCP server." + ), id="env_vars", ), ], diff --git a/tests/unit_tests/test_mcp_tool_utils.py b/tests/unit_tests/test_mcp_tool_utils.py index 801cc2041..895e70769 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -13,6 +13,8 @@ _GUIDS_CREATED_IN_SESSION, _resolve_transport_bearer_token, check_guid_created_in_session, + get_mcp_credential_guidance, + is_hosted_mcp_request, register_guid_created_in_session, ) @@ -87,6 +89,62 @@ def test_resolve_transport_bearer_token( mock_get_http_headers.assert_called_once_with(include={"authorization"}) +@pytest.mark.parametrize( + ("access_token", "headers", "expected"), + [ + pytest.param(_FakeAccessToken("token"), {}, True, id="verified_access_token"), + pytest.param( + None, {"x-airbyte-workspace-id": "workspace"}, True, id="http_headers" + ), + pytest.param(None, {}, False, id="no_request"), + ], +) +def test_is_hosted_mcp_request( + access_token: _FakeAccessToken | None, + headers: dict[str, str], + expected: bool, +) -> None: + """Detect hosted requests from the verified token or request headers.""" + with ( + patch("airbyte.mcp._tool_utils.get_access_token", return_value=access_token), + patch("airbyte.mcp._tool_utils.get_http_headers", return_value=headers), + ): + assert is_hosted_mcp_request() is expected + + +def test_mcp_credential_guidance_uses_hosted_headers() -> None: + """Use request headers for credential guidance on hosted requests.""" + with patch( + "airbyte.mcp._tool_utils.is_hosted_mcp_request", + return_value=True, + ): + assert get_mcp_credential_guidance() == ( + "Provide a bearer token via the `Authorization` header, or client " + "credentials via the `X-Airbyte-Cloud-Client-Id` and " + "`X-Airbyte-Cloud-Client-Secret` headers. Provide the workspace ID " + "via the `X-Airbyte-Workspace-Id` header." + ) + assert get_mcp_credential_guidance(workspace_only=True) == ( + "Provide the workspace ID via the `X-Airbyte-Workspace-Id` header." + ) + + +def test_mcp_credential_guidance_uses_local_environment() -> None: + """Use environment variables for credential guidance outside hosted requests.""" + with patch( + "airbyte.mcp._tool_utils.is_hosted_mcp_request", + return_value=False, + ): + assert get_mcp_credential_guidance() == ( + "Set the `AIRBYTE_CLOUD_BEARER_TOKEN` environment variable, or set both " + "`AIRBYTE_CLOUD_CLIENT_ID` and `AIRBYTE_CLOUD_CLIENT_SECRET`. Set the " + "`AIRBYTE_CLOUD_WORKSPACE_ID` environment variable for the workspace." + ) + assert get_mcp_credential_guidance(workspace_only=True) == ( + "Set the `AIRBYTE_CLOUD_WORKSPACE_ID` environment variable." + ) + + @pytest.fixture(autouse=True) def clear_session_guids() -> None: """Clear the session GUIDs before each test.""" From 9164cd1b01aa8011c88f15c0b64e715e278bd898 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:47:52 +0000 Subject: [PATCH 02/21] docs(mcp): clarify workspace header + bearer-token option in cred guidance Co-Authored-By: AJ Steers --- airbyte/mcp/cloud.py | 5 +++-- airbyte/mcp/server.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 00b259ca8..b11625b06 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -51,8 +51,9 @@ "variables." ) WORKSPACE_ID_TIP_TEXT = ( - "Workspace ID. Hosted MCP connections use the auth header; local or stdio " - "connections use the `AIRBYTE_CLOUD_WORKSPACE_ID` environment variable." + "Workspace ID. Hosted MCP connections pass it via the `X-Airbyte-Workspace-Id` " + "header; local or stdio connections use the `AIRBYTE_CLOUD_WORKSPACE_ID` " + "environment variable." ) diff --git a/airbyte/mcp/server.py b/airbyte/mcp/server.py index 458278f14..c0d9db036 100644 --- a/airbyte/mcp/server.py +++ b/airbyte/mcp/server.py @@ -111,8 +111,8 @@ Operational modes: - Cloud operations: Deploy and manage connectors on Airbyte Cloud (use auth - headers when connecting to a hosted MCP server, or - AIRBYTE_CLOUD_CLIENT_ID, AIRBYTE_CLOUD_CLIENT_SECRET, and + headers when connecting to a hosted MCP server, or AIRBYTE_CLOUD_CLIENT_ID + + AIRBYTE_CLOUD_CLIENT_SECRET (or AIRBYTE_CLOUD_BEARER_TOKEN) plus AIRBYTE_CLOUD_WORKSPACE_ID for local or stdio connections) - Local operations: Run connectors locally for data extraction (requires AIRBYTE_PROJECT_DIR for artifact storage) From 2c2bd6280825ce6c26ed04d05c96e2d7bbac6ac4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:57:15 +0000 Subject: [PATCH 03/21] fix: track hosted MCP mode at startup Co-Authored-By: AJ Steers --- airbyte/_util/meta.py | 14 ++++++++++ airbyte/mcp/_tool_utils.py | 19 +++++--------- airbyte/mcp/cloud.py | 20 ++++++++++---- airbyte/mcp/http_main.py | 2 ++ tests/unit_tests/test_mcp_tool_utils.py | 35 +++---------------------- 5 files changed, 41 insertions(+), 49 deletions(-) diff --git a/airbyte/_util/meta.py b/airbyte/_util/meta.py index fa1c9e135..e9d438145 100644 --- a/airbyte/_util/meta.py +++ b/airbyte/_util/meta.py @@ -22,6 +22,9 @@ _MCP_MODE_ENABLED: bool = False """Whether we are running in MCP (Model Context Protocol) mode.""" +_HOSTED_MCP_MODE_ENABLED: bool = False +"""Whether the process is serving MCP over hosted HTTP transport.""" + COLAB_SESSION_URL = "http://172.28.0.12:9000/api/sessions" """URL to get the current Google Colab session information.""" @@ -57,6 +60,17 @@ def is_mcp_mode() -> bool: return _MCP_MODE_ENABLED +def set_hosted_mcp_mode() -> None: + """Set the flag indicating the process serves MCP over hosted HTTP transport.""" + global _HOSTED_MCP_MODE_ENABLED + _HOSTED_MCP_MODE_ENABLED = True + + +def is_hosted_mcp_mode() -> bool: + """Return True if the process serves MCP over hosted HTTP transport.""" + return _HOSTED_MCP_MODE_ENABLED + + @lru_cache def is_langchain() -> bool: """Return True if running in a Langchain environment. diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index fdd29761b..ed7f077e1 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -16,7 +16,11 @@ from typing import TYPE_CHECKING, TypeVar from fastmcp.apps import UI_EXTENSION_ID -from fastmcp.server.dependencies import get_access_token, get_context, get_http_headers +from fastmcp.server.dependencies import ( + get_access_token, + get_context, + get_http_headers, +) from fastmcp_extensions import MCPServerConfigArg, get_mcp_config from fastmcp_extensions import mcp_tool as _mcp_tool from fastmcp_extensions.decorators import ( @@ -31,6 +35,7 @@ get_annotation, ) +from airbyte._util.meta import is_hosted_mcp_mode from airbyte.constants import ( CLOUD_API_ROOT_ENV_VAR, CLOUD_BEARER_TOKEN_ENV_VAR, @@ -182,19 +187,9 @@ def check_guid_created_in_session(guid: str) -> None: """Config arg for workspace ID, supporting both HTTP header and env var.""" -def is_hosted_mcp_request() -> bool: - """Return whether the current call is served over hosted HTTP transport.""" - try: - if get_access_token() is not None: - return True - return bool(get_http_headers()) - except RuntimeError: - return False - - def get_mcp_credential_guidance(*, workspace_only: bool = False) -> str: """Return credential guidance for the current MCP execution mode.""" - if is_hosted_mcp_request(): + if is_hosted_mcp_mode(): if workspace_only: return f"Provide the workspace ID via the `{MCP_WORKSPACE_ID_HEADER}` header." return ( diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index b11625b06..7c2be093c 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -25,12 +25,20 @@ from airbyte.cloud.models import JobTypeEnum from airbyte.cloud.workspaces import CloudWorkspace from airbyte.constants import ( + CLOUD_BEARER_TOKEN_ENV_VAR, + CLOUD_CLIENT_ID_ENV_VAR, + CLOUD_CLIENT_SECRET_ENV_VAR, + CLOUD_WORKSPACE_ID_ENV_VAR, + MCP_BEARER_TOKEN_HEADER, + MCP_CLIENT_ID_HEADER, + MCP_CLIENT_SECRET_HEADER, MCP_CONFIG_API_URL, MCP_CONFIG_BEARER_TOKEN, MCP_CONFIG_CLIENT_ID, MCP_CONFIG_CLIENT_SECRET, MCP_CONFIG_CONFIG_API_URL, MCP_CONFIG_WORKSPACE_ID, + MCP_WORKSPACE_ID_HEADER, ) from airbyte.destinations.util import get_noop_destination from airbyte.exceptions import AirbyteMissingResourceError, PyAirbyteInputError @@ -44,11 +52,13 @@ CLOUD_AUTH_TIP_TEXT = ( - "When connecting to a hosted MCP server, provide Airbyte Cloud credentials " - "via the auth headers. For local or stdio connections, use the " - "`AIRBYTE_CLOUD_CLIENT_ID`, `AIRBYTE_CLOUD_CLIENT_SECRET`, " - "`AIRBYTE_CLOUD_BEARER_TOKEN`, and `AIRBYTE_CLOUD_WORKSPACE_ID` environment " - "variables." + f"When connecting to a hosted MCP server, provide a bearer token via the " + f"`{MCP_BEARER_TOKEN_HEADER}` header, or client credentials via the " + f"`{MCP_CLIENT_ID_HEADER}` and `{MCP_CLIENT_SECRET_HEADER}` headers, plus " + f"the workspace ID via the `{MCP_WORKSPACE_ID_HEADER}` header. For local or " + f"stdio connections, use the `{CLOUD_CLIENT_ID_ENV_VAR}`, " + f"`{CLOUD_CLIENT_SECRET_ENV_VAR}`, `{CLOUD_BEARER_TOKEN_ENV_VAR}`, and " + f"`{CLOUD_WORKSPACE_ID_ENV_VAR}` environment variables." ) WORKSPACE_ID_TIP_TEXT = ( "Workspace ID. Hosted MCP connections pass it via the `X-Airbyte-Workspace-Id` " diff --git a/airbyte/mcp/http_main.py b/airbyte/mcp/http_main.py index 5f51cdc30..506651758 100644 --- a/airbyte/mcp/http_main.py +++ b/airbyte/mcp/http_main.py @@ -50,6 +50,7 @@ register_landing_page, ) +from airbyte._util.meta import set_hosted_mcp_mode from airbyte.mcp.server import ( DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT, @@ -80,6 +81,7 @@ def _get_server_url() -> str: def main() -> None: """Start the Airbyte MCP server with HTTP transport.""" logging.basicConfig(level=logging.INFO) + set_hosted_mcp_mode() # When deployed behind a path-stripping LB (MCP_SERVER_URL has a path # component like /cloud-mcp), serve the MCP endpoint at root so the diff --git a/tests/unit_tests/test_mcp_tool_utils.py b/tests/unit_tests/test_mcp_tool_utils.py index 895e70769..d18da1519 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -14,9 +14,9 @@ _resolve_transport_bearer_token, check_guid_created_in_session, get_mcp_credential_guidance, - is_hosted_mcp_request, register_guid_created_in_session, ) +from airbyte._util import meta @dataclass @@ -89,35 +89,9 @@ def test_resolve_transport_bearer_token( mock_get_http_headers.assert_called_once_with(include={"authorization"}) -@pytest.mark.parametrize( - ("access_token", "headers", "expected"), - [ - pytest.param(_FakeAccessToken("token"), {}, True, id="verified_access_token"), - pytest.param( - None, {"x-airbyte-workspace-id": "workspace"}, True, id="http_headers" - ), - pytest.param(None, {}, False, id="no_request"), - ], -) -def test_is_hosted_mcp_request( - access_token: _FakeAccessToken | None, - headers: dict[str, str], - expected: bool, -) -> None: - """Detect hosted requests from the verified token or request headers.""" - with ( - patch("airbyte.mcp._tool_utils.get_access_token", return_value=access_token), - patch("airbyte.mcp._tool_utils.get_http_headers", return_value=headers), - ): - assert is_hosted_mcp_request() is expected - - def test_mcp_credential_guidance_uses_hosted_headers() -> None: """Use request headers for credential guidance on hosted requests.""" - with patch( - "airbyte.mcp._tool_utils.is_hosted_mcp_request", - return_value=True, - ): + with patch.object(meta, "_HOSTED_MCP_MODE_ENABLED", True): assert get_mcp_credential_guidance() == ( "Provide a bearer token via the `Authorization` header, or client " "credentials via the `X-Airbyte-Cloud-Client-Id` and " @@ -131,10 +105,7 @@ def test_mcp_credential_guidance_uses_hosted_headers() -> None: def test_mcp_credential_guidance_uses_local_environment() -> None: """Use environment variables for credential guidance outside hosted requests.""" - with patch( - "airbyte.mcp._tool_utils.is_hosted_mcp_request", - return_value=False, - ): + with patch.object(meta, "_HOSTED_MCP_MODE_ENABLED", False): assert get_mcp_credential_guidance() == ( "Set the `AIRBYTE_CLOUD_BEARER_TOKEN` environment variable, or set both " "`AIRBYTE_CLOUD_CLIENT_ID` and `AIRBYTE_CLOUD_CLIENT_SECRET`. Set the " From a3d825d11e08ce2eb7ba48e5e7a4cad7c38cb4a9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:29:21 +0000 Subject: [PATCH 04/21] fix(mcp): clarify local auth alternatives and use constants in tip text Co-Authored-By: AJ Steers --- airbyte/mcp/cloud.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 7c2be093c..fa1e52c08 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -56,14 +56,15 @@ f"`{MCP_BEARER_TOKEN_HEADER}` header, or client credentials via the " f"`{MCP_CLIENT_ID_HEADER}` and `{MCP_CLIENT_SECRET_HEADER}` headers, plus " f"the workspace ID via the `{MCP_WORKSPACE_ID_HEADER}` header. For local or " - f"stdio connections, use the `{CLOUD_CLIENT_ID_ENV_VAR}`, " - f"`{CLOUD_CLIENT_SECRET_ENV_VAR}`, `{CLOUD_BEARER_TOKEN_ENV_VAR}`, and " - f"`{CLOUD_WORKSPACE_ID_ENV_VAR}` environment variables." + f"stdio connections, set the `{CLOUD_BEARER_TOKEN_ENV_VAR}` environment " + f"variable, or both `{CLOUD_CLIENT_ID_ENV_VAR}` and " + f"`{CLOUD_CLIENT_SECRET_ENV_VAR}`, plus `{CLOUD_WORKSPACE_ID_ENV_VAR}` for the " + f"workspace." ) WORKSPACE_ID_TIP_TEXT = ( - "Workspace ID. Hosted MCP connections pass it via the `X-Airbyte-Workspace-Id` " - "header; local or stdio connections use the `AIRBYTE_CLOUD_WORKSPACE_ID` " - "environment variable." + f"Workspace ID. Hosted MCP connections pass it via the " + f"`{MCP_WORKSPACE_ID_HEADER}` header; local or stdio connections use the " + f"`{CLOUD_WORKSPACE_ID_ENV_VAR}` environment variable." ) From 01d90a47994b54cc4d0f6c923c80dd11d35c0963 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:38:04 +0000 Subject: [PATCH 05/21] fix(mcp): include explicit-args remediation in cloud credential guidance Co-Authored-By: AJ Steers --- airbyte/cloud/_credentials.py | 7 ++++--- tests/unit_tests/test_cloud_credentials.py | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/airbyte/cloud/_credentials.py b/airbyte/cloud/_credentials.py index 320670d31..6e55e49af 100644 --- a/airbyte/cloud/_credentials.py +++ b/airbyte/cloud/_credentials.py @@ -87,9 +87,10 @@ def from_auth( ) if not resolved_bearer_token and not resolved_client_id: guidance = ( - "Provide Airbyte Cloud credentials via the " - "`AIRBYTE_CLOUD_*` environment variables, or via request auth " - "headers when using a hosted MCP server." + "Provide Airbyte Cloud credentials as explicit arguments " + "(client_id and client_secret, or bearer_token), or via the " + "`AIRBYTE_CLOUD_*` environment variables. When connecting to a " + "hosted MCP server, credentials are supplied via request auth headers." if env_vars else "Provide either bearer_token or both client_id and client_secret." ) diff --git a/tests/unit_tests/test_cloud_credentials.py b/tests/unit_tests/test_cloud_credentials.py index ab59532cf..8ae0e8210 100644 --- a/tests/unit_tests/test_cloud_credentials.py +++ b/tests/unit_tests/test_cloud_credentials.py @@ -75,8 +75,10 @@ def fake_try_get_secret( pytest.param( True, ( - "Provide Airbyte Cloud credentials via the `AIRBYTE_CLOUD_*` environment " - "variables, or via request auth headers when using a hosted MCP server." + "Provide Airbyte Cloud credentials as explicit arguments " + "(client_id and client_secret, or bearer_token), or via the " + "`AIRBYTE_CLOUD_*` environment variables. When connecting to a " + "hosted MCP server, credentials are supplied via request auth headers." ), id="env_vars", ), From 2f580d857cb0f9fead07de1a3f337af59428b0c8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:04:19 +0000 Subject: [PATCH 06/21] test(mcp): patch is_hosted_mcp_mode at point of use in guidance tests Co-Authored-By: AJ Steers --- tests/unit_tests/test_mcp_tool_utils.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/test_mcp_tool_utils.py b/tests/unit_tests/test_mcp_tool_utils.py index d18da1519..ce5c2faed 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -16,7 +16,6 @@ get_mcp_credential_guidance, register_guid_created_in_session, ) -from airbyte._util import meta @dataclass @@ -91,7 +90,10 @@ def test_resolve_transport_bearer_token( def test_mcp_credential_guidance_uses_hosted_headers() -> None: """Use request headers for credential guidance on hosted requests.""" - with patch.object(meta, "_HOSTED_MCP_MODE_ENABLED", True): + with patch( + "airbyte.mcp._tool_utils.is_hosted_mcp_mode", + return_value=True, + ): assert get_mcp_credential_guidance() == ( "Provide a bearer token via the `Authorization` header, or client " "credentials via the `X-Airbyte-Cloud-Client-Id` and " @@ -105,7 +107,10 @@ def test_mcp_credential_guidance_uses_hosted_headers() -> None: def test_mcp_credential_guidance_uses_local_environment() -> None: """Use environment variables for credential guidance outside hosted requests.""" - with patch.object(meta, "_HOSTED_MCP_MODE_ENABLED", False): + with patch( + "airbyte.mcp._tool_utils.is_hosted_mcp_mode", + return_value=False, + ): assert get_mcp_credential_guidance() == ( "Set the `AIRBYTE_CLOUD_BEARER_TOKEN` environment variable, or set both " "`AIRBYTE_CLOUD_CLIENT_ID` and `AIRBYTE_CLOUD_CLIENT_SECRET`. Set the " From 8c18592d8fe7271335081eceaf974a3813224267 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:31:14 +0000 Subject: [PATCH 07/21] docs(mcp): say "request headers" in cloud-ops server instructions Co-Authored-By: AJ Steers --- airbyte/mcp/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airbyte/mcp/server.py b/airbyte/mcp/server.py index c0d9db036..88bd21e58 100644 --- a/airbyte/mcp/server.py +++ b/airbyte/mcp/server.py @@ -110,7 +110,7 @@ - Listing and describing environment variables for connector configuration Operational modes: -- Cloud operations: Deploy and manage connectors on Airbyte Cloud (use auth +- Cloud operations: Deploy and manage connectors on Airbyte Cloud (use request headers when connecting to a hosted MCP server, or AIRBYTE_CLOUD_CLIENT_ID + AIRBYTE_CLOUD_CLIENT_SECRET (or AIRBYTE_CLOUD_BEARER_TOKEN) plus AIRBYTE_CLOUD_WORKSPACE_ID for local or stdio connections) From f62f04432de8e42ed8793d654a40175f53479f8f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:58:03 +0000 Subject: [PATCH 08/21] fix(mcp): keep cloud credential error targeted, not source-enumerated Co-Authored-By: AJ Steers --- airbyte/cloud/_credentials.py | 10 +--------- tests/unit_tests/test_cloud_credentials.py | 7 +------ 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/airbyte/cloud/_credentials.py b/airbyte/cloud/_credentials.py index 6e55e49af..524c9b891 100644 --- a/airbyte/cloud/_credentials.py +++ b/airbyte/cloud/_credentials.py @@ -86,17 +86,9 @@ def from_auth( guidance="Provide both client ID and client secret, or use a bearer token.", ) if not resolved_bearer_token and not resolved_client_id: - guidance = ( - "Provide Airbyte Cloud credentials as explicit arguments " - "(client_id and client_secret, or bearer_token), or via the " - "`AIRBYTE_CLOUD_*` environment variables. When connecting to a " - "hosted MCP server, credentials are supplied via request auth headers." - if env_vars - else "Provide either bearer_token or both client_id and client_secret." - ) raise PyAirbyteInputError( message="No Airbyte credentials found.", - guidance=guidance, + guidance="Provide either bearer_token or both client_id and client_secret.", ) return cls( diff --git a/tests/unit_tests/test_cloud_credentials.py b/tests/unit_tests/test_cloud_credentials.py index 8ae0e8210..f9994219b 100644 --- a/tests/unit_tests/test_cloud_credentials.py +++ b/tests/unit_tests/test_cloud_credentials.py @@ -74,12 +74,7 @@ def fake_try_get_secret( ), pytest.param( True, - ( - "Provide Airbyte Cloud credentials as explicit arguments " - "(client_id and client_secret, or bearer_token), or via the " - "`AIRBYTE_CLOUD_*` environment variables. When connecting to a " - "hosted MCP server, credentials are supplied via request auth headers." - ), + "Provide either bearer_token or both client_id and client_secret.", id="env_vars", ), ], From da57af7e2763fb949950c6b0e20572ff91badf56 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:13:18 +0000 Subject: [PATCH 09/21] refactor(mcp): render cloud credential/workspace guidance via custom exceptions Co-Authored-By: AJ Steers --- airbyte/cloud/_credentials.py | 7 +- airbyte/exceptions.py | 67 ++++++++++++++++- airbyte/mcp/_tool_utils.py | 22 ------ airbyte/mcp/cloud.py | 13 ++-- tests/unit_tests/test_cloud_credentials.py | 14 +++- tests/unit_tests/test_exceptions.py | 84 +++++++++++++++++++++- tests/unit_tests/test_mcp_tool_utils.py | 34 --------- 7 files changed, 169 insertions(+), 72 deletions(-) diff --git a/airbyte/cloud/_credentials.py b/airbyte/cloud/_credentials.py index 524c9b891..6260b2235 100644 --- a/airbyte/cloud/_credentials.py +++ b/airbyte/cloud/_credentials.py @@ -15,7 +15,7 @@ CLOUD_ORGANIZATION_ID_ENV_VAR, CLOUD_WORKSPACE_ID_ENV_VAR, ) -from airbyte.exceptions import PyAirbyteInputError +from airbyte.exceptions import AirbyteNoCloudCredentialsError, PyAirbyteInputError from airbyte.secrets.base import SecretString from airbyte.secrets.util import try_get_secret @@ -86,10 +86,7 @@ def from_auth( guidance="Provide both client ID and client secret, or use a bearer token.", ) if not resolved_bearer_token and not resolved_client_id: - raise PyAirbyteInputError( - message="No Airbyte credentials found.", - guidance="Provide either bearer_token or both client_id and client_secret.", - ) + raise AirbyteNoCloudCredentialsError return cls( client_id=SecretString(resolved_client_id) if resolved_client_id else None, diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index 4b5e8aa52..23b860681 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -44,7 +44,18 @@ from textwrap import indent from typing import TYPE_CHECKING, Any -from airbyte.constants import AIRBYTE_PRINT_FULL_ERROR_LOGS +from airbyte._util.meta import is_hosted_mcp_mode +from airbyte.constants import ( + AIRBYTE_PRINT_FULL_ERROR_LOGS, + CLOUD_BEARER_TOKEN_ENV_VAR, + CLOUD_CLIENT_ID_ENV_VAR, + CLOUD_CLIENT_SECRET_ENV_VAR, + CLOUD_WORKSPACE_ID_ENV_VAR, + MCP_BEARER_TOKEN_HEADER, + MCP_CLIENT_ID_HEADER, + MCP_CLIENT_SECRET_HEADER, + MCP_WORKSPACE_ID_HEADER, +) if TYPE_CHECKING: @@ -222,6 +233,60 @@ class PyAirbyteNoStreamsSelectedError(PyAirbyteInputError): available_streams: list[str] | None = None +@dataclass +class AirbyteNoCloudCredentialsError(PyAirbyteInputError): + """No Airbyte credentials found.""" + + allow_bearer: bool = True + + def __post_init__(self) -> None: + """Set guidance for the current execution mode.""" + if self.guidance is not None: + return + if is_hosted_mcp_mode(): + if self.allow_bearer: + self.guidance = ( + f"Provide a bearer token via the `{MCP_BEARER_TOKEN_HEADER}` header, " + f"or client credentials via the `{MCP_CLIENT_ID_HEADER}` and " + f"`{MCP_CLIENT_SECRET_HEADER}` headers." + ) + else: + self.guidance = ( + f"Provide client credentials via the `{MCP_CLIENT_ID_HEADER}` and " + f"`{MCP_CLIENT_SECRET_HEADER}` headers." + ) + elif self.allow_bearer: + self.guidance = ( + f"Provide `bearer_token`, or both `client_id` and `client_secret`, as " + f"arguments or via the `{CLOUD_BEARER_TOKEN_ENV_VAR}`, " + f"`{CLOUD_CLIENT_ID_ENV_VAR}`, and `{CLOUD_CLIENT_SECRET_ENV_VAR}` " + "environment variables." + ) + else: + self.guidance = ( + f"Provide both `client_id` and `client_secret`, as arguments or via the " + f"`{CLOUD_CLIENT_ID_ENV_VAR}` and `{CLOUD_CLIENT_SECRET_ENV_VAR}` " + "environment variables." + ) + + +@dataclass +class AirbyteMissingWorkspaceContextError(PyAirbyteInputError): + """Workspace ID is required but not provided.""" + + def __post_init__(self) -> None: + """Set guidance for the current execution mode.""" + if self.guidance is not None: + return + if is_hosted_mcp_mode(): + self.guidance = f"Provide the workspace ID via the `{MCP_WORKSPACE_ID_HEADER}` header." + else: + self.guidance = ( + f"Set the `{CLOUD_WORKSPACE_ID_ENV_VAR}` environment variable, or pass " + "the `workspace_id` parameter." + ) + + # MCP Server Errors diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index ed7f077e1..46e4089bd 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -35,7 +35,6 @@ get_annotation, ) -from airbyte._util.meta import is_hosted_mcp_mode from airbyte.constants import ( CLOUD_API_ROOT_ENV_VAR, CLOUD_BEARER_TOKEN_ENV_VAR, @@ -187,27 +186,6 @@ def check_guid_created_in_session(guid: str) -> None: """Config arg for workspace ID, supporting both HTTP header and env var.""" -def get_mcp_credential_guidance(*, workspace_only: bool = False) -> str: - """Return credential guidance for the current MCP execution mode.""" - if is_hosted_mcp_mode(): - if workspace_only: - return f"Provide the workspace ID via the `{MCP_WORKSPACE_ID_HEADER}` header." - return ( - f"Provide a bearer token via the `{MCP_BEARER_TOKEN_HEADER}` header, " - f"or client credentials via the `{MCP_CLIENT_ID_HEADER}` and " - f"`{MCP_CLIENT_SECRET_HEADER}` headers. Provide the workspace ID via " - f"the `{MCP_WORKSPACE_ID_HEADER}` header." - ) - - if workspace_only: - return f"Set the `{CLOUD_WORKSPACE_ID_ENV_VAR}` environment variable." - return ( - f"Set the `{CLOUD_BEARER_TOKEN_ENV_VAR}` environment variable, or set both " - f"`{CLOUD_CLIENT_ID_ENV_VAR}` and `{CLOUD_CLIENT_SECRET_ENV_VAR}`." - f" Set the `{CLOUD_WORKSPACE_ID_ENV_VAR}` environment variable for the workspace." - ) - - def _normalize_bearer_token(value: str) -> str | None: """Strip an optional `Bearer ` prefix from an `Authorization` value. diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index fa1e52c08..eb6a1925f 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -41,12 +41,15 @@ MCP_WORKSPACE_ID_HEADER, ) from airbyte.destinations.util import get_noop_destination -from airbyte.exceptions import AirbyteMissingResourceError, PyAirbyteInputError +from airbyte.exceptions import ( + AirbyteMissingResourceError, + AirbyteMissingWorkspaceContextError, + PyAirbyteInputError, +) from airbyte.mcp._arg_resolvers import resolve_connector_config, resolve_list_of_strings from airbyte.mcp._tool_utils import ( AIRBYTE_CLOUD_WORKSPACE_ID_IS_SET, check_guid_created_in_session, - get_mcp_credential_guidance, register_guid_created_in_session, ) @@ -277,11 +280,7 @@ def _get_cloud_workspace( """ resolved_workspace_id = workspace_id or get_mcp_config(ctx, MCP_CONFIG_WORKSPACE_ID) if not resolved_workspace_id: - raise PyAirbyteInputError( - message="Workspace ID is required but not provided.", - guidance=f"{get_mcp_credential_guidance(workspace_only=True)} " - "You can also pass the workspace_id parameter.", - ) + raise AirbyteMissingWorkspaceContextError return _get_cloud_client(ctx).get_workspace(resolved_workspace_id) diff --git a/tests/unit_tests/test_cloud_credentials.py b/tests/unit_tests/test_cloud_credentials.py index f9994219b..6732db47f 100644 --- a/tests/unit_tests/test_cloud_credentials.py +++ b/tests/unit_tests/test_cloud_credentials.py @@ -69,12 +69,22 @@ def fake_try_get_secret( [ pytest.param( False, - "Provide either bearer_token or both client_id and client_secret.", + ( + "Provide `bearer_token`, or both `client_id` and `client_secret`, as " + "arguments or via the `AIRBYTE_CLOUD_BEARER_TOKEN`, " + "`AIRBYTE_CLOUD_CLIENT_ID`, and `AIRBYTE_CLOUD_CLIENT_SECRET` " + "environment variables." + ), id="explicit_inputs", ), pytest.param( True, - "Provide either bearer_token or both client_id and client_secret.", + ( + "Provide `bearer_token`, or both `client_id` and `client_secret`, as " + "arguments or via the `AIRBYTE_CLOUD_BEARER_TOKEN`, " + "`AIRBYTE_CLOUD_CLIENT_ID`, and `AIRBYTE_CLOUD_CLIENT_SECRET` " + "environment variables." + ), id="env_vars", ), ], diff --git a/tests/unit_tests/test_exceptions.py b/tests/unit_tests/test_exceptions.py index 57545b4cb..9c5aab046 100644 --- a/tests/unit_tests/test_exceptions.py +++ b/tests/unit_tests/test_exceptions.py @@ -2,8 +2,13 @@ from __future__ import annotations import inspect -import pytest import airbyte.exceptions as exceptions_module +import pytest + +from airbyte.exceptions import ( + AirbyteMissingWorkspaceContextError, + AirbyteNoCloudCredentialsError, +) def test_exceptions(): @@ -27,5 +32,82 @@ def test_exceptions(): assert name.endswith("Error") +@pytest.mark.parametrize( + ("hosted", "allow_bearer", "expected_guidance"), + [ + pytest.param( + True, + True, + "Provide a bearer token via the `Authorization` header, or client credentials " + "via the `X-Airbyte-Cloud-Client-Id` and `X-Airbyte-Cloud-Client-Secret` headers.", + id="hosted_with_bearer", + ), + pytest.param( + True, + False, + "Provide client credentials via the `X-Airbyte-Cloud-Client-Id` and " + "`X-Airbyte-Cloud-Client-Secret` headers.", + id="hosted_client_credentials_only", + ), + pytest.param( + False, + True, + "Provide `bearer_token`, or both `client_id` and `client_secret`, as arguments " + "or via the `AIRBYTE_CLOUD_BEARER_TOKEN`, `AIRBYTE_CLOUD_CLIENT_ID`, and " + "`AIRBYTE_CLOUD_CLIENT_SECRET` environment variables.", + id="local_with_bearer", + ), + pytest.param( + False, + False, + "Provide both `client_id` and `client_secret`, as arguments or via the " + "`AIRBYTE_CLOUD_CLIENT_ID` and `AIRBYTE_CLOUD_CLIENT_SECRET` environment variables.", + id="local_client_credentials_only", + ), + ], +) +def test_cloud_credentials_error_guidance( + hosted: bool, + allow_bearer: bool, + expected_guidance: str, +) -> None: + """Render cloud credential guidance for each supported mode.""" + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(exceptions_module, "is_hosted_mcp_mode", lambda: hosted) + error = AirbyteNoCloudCredentialsError(allow_bearer=allow_bearer) + + assert error.get_message() == "No Airbyte credentials found." + assert error.guidance == expected_guidance + + +@pytest.mark.parametrize( + ("hosted", "expected_guidance"), + [ + pytest.param( + True, + "Provide the workspace ID via the `X-Airbyte-Workspace-Id` header.", + id="hosted", + ), + pytest.param( + False, + "Set the `AIRBYTE_CLOUD_WORKSPACE_ID` environment variable, or pass the " + "`workspace_id` parameter.", + id="local", + ), + ], +) +def test_missing_workspace_context_error_guidance( + hosted: bool, + expected_guidance: str, +) -> None: + """Render workspace guidance for each supported mode.""" + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(exceptions_module, "is_hosted_mcp_mode", lambda: hosted) + error = AirbyteMissingWorkspaceContextError() + + assert error.get_message() == "Workspace ID is required but not provided." + assert error.guidance == expected_guidance + + if __name__ == "__main__": pytest.main() diff --git a/tests/unit_tests/test_mcp_tool_utils.py b/tests/unit_tests/test_mcp_tool_utils.py index ce5c2faed..801cc2041 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -13,7 +13,6 @@ _GUIDS_CREATED_IN_SESSION, _resolve_transport_bearer_token, check_guid_created_in_session, - get_mcp_credential_guidance, register_guid_created_in_session, ) @@ -88,39 +87,6 @@ def test_resolve_transport_bearer_token( mock_get_http_headers.assert_called_once_with(include={"authorization"}) -def test_mcp_credential_guidance_uses_hosted_headers() -> None: - """Use request headers for credential guidance on hosted requests.""" - with patch( - "airbyte.mcp._tool_utils.is_hosted_mcp_mode", - return_value=True, - ): - assert get_mcp_credential_guidance() == ( - "Provide a bearer token via the `Authorization` header, or client " - "credentials via the `X-Airbyte-Cloud-Client-Id` and " - "`X-Airbyte-Cloud-Client-Secret` headers. Provide the workspace ID " - "via the `X-Airbyte-Workspace-Id` header." - ) - assert get_mcp_credential_guidance(workspace_only=True) == ( - "Provide the workspace ID via the `X-Airbyte-Workspace-Id` header." - ) - - -def test_mcp_credential_guidance_uses_local_environment() -> None: - """Use environment variables for credential guidance outside hosted requests.""" - with patch( - "airbyte.mcp._tool_utils.is_hosted_mcp_mode", - return_value=False, - ): - assert get_mcp_credential_guidance() == ( - "Set the `AIRBYTE_CLOUD_BEARER_TOKEN` environment variable, or set both " - "`AIRBYTE_CLOUD_CLIENT_ID` and `AIRBYTE_CLOUD_CLIENT_SECRET`. Set the " - "`AIRBYTE_CLOUD_WORKSPACE_ID` environment variable for the workspace." - ) - assert get_mcp_credential_guidance(workspace_only=True) == ( - "Set the `AIRBYTE_CLOUD_WORKSPACE_ID` environment variable." - ) - - @pytest.fixture(autouse=True) def clear_session_guids() -> None: """Clear the session GUIDs before each test.""" From 836126a88e3b035959ada0dddbdaf7c6ea4dc408 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:42:39 +0000 Subject: [PATCH 10/21] fix(mcp): avoid cloud exception import cycle Co-Authored-By: AJ Steers --- airbyte/_util/mcp_mode.py | 19 +++++++++++++++++++ airbyte/_util/meta.py | 15 +-------------- airbyte/exceptions.py | 2 +- 3 files changed, 21 insertions(+), 15 deletions(-) create mode 100644 airbyte/_util/mcp_mode.py diff --git a/airbyte/_util/mcp_mode.py b/airbyte/_util/mcp_mode.py new file mode 100644 index 000000000..d97710262 --- /dev/null +++ b/airbyte/_util/mcp_mode.py @@ -0,0 +1,19 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""MCP execution mode state.""" + +from __future__ import annotations + + +_HOSTED_MCP_MODE_ENABLED: bool = False +"""Whether the process is serving MCP over hosted HTTP transport.""" + + +def set_hosted_mcp_mode() -> None: + """Set the flag indicating the process serves MCP over hosted HTTP transport.""" + global _HOSTED_MCP_MODE_ENABLED + _HOSTED_MCP_MODE_ENABLED = True + + +def is_hosted_mcp_mode() -> bool: + """Return True if the process serves MCP over hosted HTTP transport.""" + return _HOSTED_MCP_MODE_ENABLED diff --git a/airbyte/_util/meta.py b/airbyte/_util/meta.py index e9d438145..414b5fab0 100644 --- a/airbyte/_util/meta.py +++ b/airbyte/_util/meta.py @@ -16,15 +16,13 @@ import requests +from airbyte._util.mcp_mode import is_hosted_mcp_mode, set_hosted_mcp_mode # noqa: F401 from airbyte.version import get_version _MCP_MODE_ENABLED: bool = False """Whether we are running in MCP (Model Context Protocol) mode.""" -_HOSTED_MCP_MODE_ENABLED: bool = False -"""Whether the process is serving MCP over hosted HTTP transport.""" - COLAB_SESSION_URL = "http://172.28.0.12:9000/api/sessions" """URL to get the current Google Colab session information.""" @@ -60,17 +58,6 @@ def is_mcp_mode() -> bool: return _MCP_MODE_ENABLED -def set_hosted_mcp_mode() -> None: - """Set the flag indicating the process serves MCP over hosted HTTP transport.""" - global _HOSTED_MCP_MODE_ENABLED - _HOSTED_MCP_MODE_ENABLED = True - - -def is_hosted_mcp_mode() -> bool: - """Return True if the process serves MCP over hosted HTTP transport.""" - return _HOSTED_MCP_MODE_ENABLED - - @lru_cache def is_langchain() -> bool: """Return True if running in a Langchain environment. diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index 23b860681..1a98b6da7 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -44,7 +44,7 @@ from textwrap import indent from typing import TYPE_CHECKING, Any -from airbyte._util.meta import is_hosted_mcp_mode +from airbyte._util.mcp_mode import is_hosted_mcp_mode from airbyte.constants import ( AIRBYTE_PRINT_FULL_ERROR_LOGS, CLOUD_BEARER_TOKEN_ENV_VAR, From c03a0c1ad21aa29f76cad242195545be812bf4b5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:55:11 +0000 Subject: [PATCH 11/21] fix(mcp): hide control flags and refine cloud credential/workspace guidance Co-Authored-By: AJ Steers --- airbyte/_util/meta.py | 1 - airbyte/cloud/_credentials.py | 2 +- airbyte/exceptions.py | 18 ++++++++---- airbyte/mcp/cloud.py | 2 +- airbyte/mcp/http_main.py | 2 +- tests/unit_tests/test_cloud_credentials.py | 7 +---- tests/unit_tests/test_exceptions.py | 33 ++++++++++++++++++++-- 7 files changed, 47 insertions(+), 18 deletions(-) diff --git a/airbyte/_util/meta.py b/airbyte/_util/meta.py index 414b5fab0..fa1c9e135 100644 --- a/airbyte/_util/meta.py +++ b/airbyte/_util/meta.py @@ -16,7 +16,6 @@ import requests -from airbyte._util.mcp_mode import is_hosted_mcp_mode, set_hosted_mcp_mode # noqa: F401 from airbyte.version import get_version diff --git a/airbyte/cloud/_credentials.py b/airbyte/cloud/_credentials.py index 6260b2235..6fc347cda 100644 --- a/airbyte/cloud/_credentials.py +++ b/airbyte/cloud/_credentials.py @@ -86,7 +86,7 @@ def from_auth( guidance="Provide both client ID and client secret, or use a bearer token.", ) if not resolved_bearer_token and not resolved_client_id: - raise AirbyteNoCloudCredentialsError + raise AirbyteNoCloudCredentialsError(_env_vars=env_vars) return cls( client_id=SecretString(resolved_client_id) if resolved_client_id else None, diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index 1a98b6da7..9f6049b3f 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -237,14 +237,15 @@ class PyAirbyteNoStreamsSelectedError(PyAirbyteInputError): class AirbyteNoCloudCredentialsError(PyAirbyteInputError): """No Airbyte credentials found.""" - allow_bearer: bool = True + _allow_bearer: bool = True + _env_vars: bool = True def __post_init__(self) -> None: """Set guidance for the current execution mode.""" if self.guidance is not None: return if is_hosted_mcp_mode(): - if self.allow_bearer: + if self._allow_bearer: self.guidance = ( f"Provide a bearer token via the `{MCP_BEARER_TOKEN_HEADER}` header, " f"or client credentials via the `{MCP_CLIENT_ID_HEADER}` and " @@ -255,19 +256,23 @@ def __post_init__(self) -> None: f"Provide client credentials via the `{MCP_CLIENT_ID_HEADER}` and " f"`{MCP_CLIENT_SECRET_HEADER}` headers." ) - elif self.allow_bearer: + elif self._allow_bearer and self._env_vars: self.guidance = ( f"Provide `bearer_token`, or both `client_id` and `client_secret`, as " f"arguments or via the `{CLOUD_BEARER_TOKEN_ENV_VAR}`, " f"`{CLOUD_CLIENT_ID_ENV_VAR}`, and `{CLOUD_CLIENT_SECRET_ENV_VAR}` " "environment variables." ) - else: + elif self._allow_bearer: + self.guidance = "Provide `bearer_token`, or both `client_id` and `client_secret`." + elif self._env_vars: self.guidance = ( f"Provide both `client_id` and `client_secret`, as arguments or via the " f"`{CLOUD_CLIENT_ID_ENV_VAR}` and `{CLOUD_CLIENT_SECRET_ENV_VAR}` " "environment variables." ) + else: + self.guidance = "Provide both `client_id` and `client_secret`." @dataclass @@ -279,7 +284,10 @@ def __post_init__(self) -> None: if self.guidance is not None: return if is_hosted_mcp_mode(): - self.guidance = f"Provide the workspace ID via the `{MCP_WORKSPACE_ID_HEADER}` header." + self.guidance = ( + f"Provide the workspace ID via the `{MCP_WORKSPACE_ID_HEADER}` header, " + "or pass the `workspace_id` parameter." + ) else: self.guidance = ( f"Set the `{CLOUD_WORKSPACE_ID_ENV_VAR}` environment variable, or pass " diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index eb6a1925f..ac85e369f 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -280,7 +280,7 @@ def _get_cloud_workspace( """ resolved_workspace_id = workspace_id or get_mcp_config(ctx, MCP_CONFIG_WORKSPACE_ID) if not resolved_workspace_id: - raise AirbyteMissingWorkspaceContextError + raise AirbyteMissingWorkspaceContextError(guidance=None) return _get_cloud_client(ctx).get_workspace(resolved_workspace_id) diff --git a/airbyte/mcp/http_main.py b/airbyte/mcp/http_main.py index 506651758..e20b052d5 100644 --- a/airbyte/mcp/http_main.py +++ b/airbyte/mcp/http_main.py @@ -50,7 +50,7 @@ register_landing_page, ) -from airbyte._util.meta import set_hosted_mcp_mode +from airbyte._util.mcp_mode import set_hosted_mcp_mode from airbyte.mcp.server import ( DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT, diff --git a/tests/unit_tests/test_cloud_credentials.py b/tests/unit_tests/test_cloud_credentials.py index 6732db47f..67f511b84 100644 --- a/tests/unit_tests/test_cloud_credentials.py +++ b/tests/unit_tests/test_cloud_credentials.py @@ -69,12 +69,7 @@ def fake_try_get_secret( [ pytest.param( False, - ( - "Provide `bearer_token`, or both `client_id` and `client_secret`, as " - "arguments or via the `AIRBYTE_CLOUD_BEARER_TOKEN`, " - "`AIRBYTE_CLOUD_CLIENT_ID`, and `AIRBYTE_CLOUD_CLIENT_SECRET` " - "environment variables." - ), + "Provide `bearer_token`, or both `client_id` and `client_secret`.", id="explicit_inputs", ), pytest.param( diff --git a/tests/unit_tests/test_exceptions.py b/tests/unit_tests/test_exceptions.py index 9c5aab046..8dd2c84bd 100644 --- a/tests/unit_tests/test_exceptions.py +++ b/tests/unit_tests/test_exceptions.py @@ -33,9 +33,10 @@ def test_exceptions(): @pytest.mark.parametrize( - ("hosted", "allow_bearer", "expected_guidance"), + ("hosted", "allow_bearer", "env_vars", "expected_guidance"), [ pytest.param( + True, True, True, "Provide a bearer token via the `Authorization` header, or client credentials " @@ -45,6 +46,7 @@ def test_exceptions(): pytest.param( True, False, + False, "Provide client credentials via the `X-Airbyte-Cloud-Client-Id` and " "`X-Airbyte-Cloud-Client-Secret` headers.", id="hosted_client_credentials_only", @@ -52,6 +54,7 @@ def test_exceptions(): pytest.param( False, True, + True, "Provide `bearer_token`, or both `client_id` and `client_secret`, as arguments " "or via the `AIRBYTE_CLOUD_BEARER_TOKEN`, `AIRBYTE_CLOUD_CLIENT_ID`, and " "`AIRBYTE_CLOUD_CLIENT_SECRET` environment variables.", @@ -60,24 +63,45 @@ def test_exceptions(): pytest.param( False, False, + True, "Provide both `client_id` and `client_secret`, as arguments or via the " "`AIRBYTE_CLOUD_CLIENT_ID` and `AIRBYTE_CLOUD_CLIENT_SECRET` environment variables.", id="local_client_credentials_only", ), + pytest.param( + False, + True, + False, + "Provide `bearer_token`, or both `client_id` and `client_secret`.", + id="local_without_env_vars", + ), + pytest.param( + False, + False, + False, + "Provide both `client_id` and `client_secret`.", + id="local_client_credentials_only_without_env_vars", + ), ], ) def test_cloud_credentials_error_guidance( hosted: bool, allow_bearer: bool, + env_vars: bool, expected_guidance: str, ) -> None: """Render cloud credential guidance for each supported mode.""" with pytest.MonkeyPatch.context() as monkeypatch: monkeypatch.setattr(exceptions_module, "is_hosted_mcp_mode", lambda: hosted) - error = AirbyteNoCloudCredentialsError(allow_bearer=allow_bearer) + error = AirbyteNoCloudCredentialsError( + _allow_bearer=allow_bearer, + _env_vars=env_vars, + ) assert error.get_message() == "No Airbyte credentials found." assert error.guidance == expected_guidance + assert "Allow Bearer" not in str(error) + assert "Env Vars" not in str(error) @pytest.mark.parametrize( @@ -85,7 +109,8 @@ def test_cloud_credentials_error_guidance( [ pytest.param( True, - "Provide the workspace ID via the `X-Airbyte-Workspace-Id` header.", + "Provide the workspace ID via the `X-Airbyte-Workspace-Id` header, or pass " + "the `workspace_id` parameter.", id="hosted", ), pytest.param( @@ -107,6 +132,8 @@ def test_missing_workspace_context_error_guidance( assert error.get_message() == "Workspace ID is required but not provided." assert error.guidance == expected_guidance + assert "Allow Bearer" not in str(error) + assert "Env Vars" not in str(error) if __name__ == "__main__": From 3379399a058cc6bc14feaae291a478255a21dcab Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:06:20 +0000 Subject: [PATCH 12/21] fix(mcp): import exceptions module to avoid unsafe cyclic import Use the module-qualified `exceptions as exc` import (matching the rest of the codebase) so cloud exception names resolve at call time, clearing the CodeQL py/unsafe-cyclic-import alert. Co-Authored-By: AJ Steers --- airbyte/cloud/_credentials.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/airbyte/cloud/_credentials.py b/airbyte/cloud/_credentials.py index 6fc347cda..c145bcdb3 100644 --- a/airbyte/cloud/_credentials.py +++ b/airbyte/cloud/_credentials.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, replace +from airbyte import exceptions as exc from airbyte.constants import ( CLOUD_API_ROOT, CLOUD_API_ROOT_ENV_VAR, @@ -15,7 +16,6 @@ CLOUD_ORGANIZATION_ID_ENV_VAR, CLOUD_WORKSPACE_ID_ENV_VAR, ) -from airbyte.exceptions import AirbyteNoCloudCredentialsError, PyAirbyteInputError from airbyte.secrets.base import SecretString from airbyte.secrets.util import try_get_secret @@ -73,7 +73,7 @@ def from_auth( ) if resolved_bearer_token and (resolved_client_id or resolved_client_secret): - raise PyAirbyteInputError( + raise exc.PyAirbyteInputError( message="Cannot use both client credentials and bearer token authentication.", guidance=( "Provide either client_id and client_secret together, " @@ -81,12 +81,12 @@ def from_auth( ), ) if bool(resolved_client_id) != bool(resolved_client_secret): - raise PyAirbyteInputError( + raise exc.PyAirbyteInputError( message="Client ID and client secret are both required.", guidance="Provide both client ID and client secret, or use a bearer token.", ) if not resolved_bearer_token and not resolved_client_id: - raise AirbyteNoCloudCredentialsError(_env_vars=env_vars) + raise exc.AirbyteNoCloudCredentialsError(_env_vars=env_vars) return cls( client_id=SecretString(resolved_client_id) if resolved_client_id else None, From 7bb316751971af7913323876676a909f54745281 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:13:40 +0000 Subject: [PATCH 13/21] fix(mcp): declare explicit None guidance default on cloud exceptions Co-Authored-By: AJ Steers --- airbyte/exceptions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index 9f6049b3f..3949d2e3c 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -237,6 +237,7 @@ class PyAirbyteNoStreamsSelectedError(PyAirbyteInputError): class AirbyteNoCloudCredentialsError(PyAirbyteInputError): """No Airbyte credentials found.""" + guidance: str | None = None _allow_bearer: bool = True _env_vars: bool = True @@ -279,6 +280,8 @@ def __post_init__(self) -> None: class AirbyteMissingWorkspaceContextError(PyAirbyteInputError): """Workspace ID is required but not provided.""" + guidance: str | None = None + def __post_init__(self) -> None: """Set guidance for the current execution mode.""" if self.guidance is not None: From f8ceb1215b6b4ae0d4c095cd4f262b14860a46f7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:37:06 +0000 Subject: [PATCH 14/21] refactor(exceptions): move hosted-MCP-mode flag into constants to break import cycle Co-Authored-By: AJ Steers --- airbyte/_util/mcp_mode.py | 19 ------------------- airbyte/constants.py | 15 +++++++++++++++ airbyte/exceptions.py | 2 +- airbyte/mcp/http_main.py | 2 +- 4 files changed, 17 insertions(+), 21 deletions(-) delete mode 100644 airbyte/_util/mcp_mode.py diff --git a/airbyte/_util/mcp_mode.py b/airbyte/_util/mcp_mode.py deleted file mode 100644 index d97710262..000000000 --- a/airbyte/_util/mcp_mode.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) 2026 Airbyte, Inc., all rights reserved. -"""MCP execution mode state.""" - -from __future__ import annotations - - -_HOSTED_MCP_MODE_ENABLED: bool = False -"""Whether the process is serving MCP over hosted HTTP transport.""" - - -def set_hosted_mcp_mode() -> None: - """Set the flag indicating the process serves MCP over hosted HTTP transport.""" - global _HOSTED_MCP_MODE_ENABLED - _HOSTED_MCP_MODE_ENABLED = True - - -def is_hosted_mcp_mode() -> bool: - """Return True if the process serves MCP over hosted HTTP transport.""" - return _HOSTED_MCP_MODE_ENABLED diff --git a/airbyte/constants.py b/airbyte/constants.py index af5d03429..68a4520bb 100644 --- a/airbyte/constants.py +++ b/airbyte/constants.py @@ -261,6 +261,21 @@ def _str_to_bool(value: str) -> bool: # MCP (Model Context Protocol) Constants +_HOSTED_MCP_MODE_ENABLED: bool = False +"""Whether the process is serving MCP over hosted HTTP transport.""" + + +def set_hosted_mcp_mode() -> None: + """Set the flag indicating the process serves MCP over hosted HTTP transport.""" + global _HOSTED_MCP_MODE_ENABLED + _HOSTED_MCP_MODE_ENABLED = True + + +def is_hosted_mcp_mode() -> bool: + """Return True if the process serves MCP over hosted HTTP transport.""" + return _HOSTED_MCP_MODE_ENABLED + + MCP_READONLY_MODE_ENV_VAR: str = "AIRBYTE_CLOUD_MCP_READONLY_MODE" """Environment variable to enable read-only mode for the MCP server. diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index 3949d2e3c..075df057c 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -44,7 +44,6 @@ from textwrap import indent from typing import TYPE_CHECKING, Any -from airbyte._util.mcp_mode import is_hosted_mcp_mode from airbyte.constants import ( AIRBYTE_PRINT_FULL_ERROR_LOGS, CLOUD_BEARER_TOKEN_ENV_VAR, @@ -55,6 +54,7 @@ MCP_CLIENT_ID_HEADER, MCP_CLIENT_SECRET_HEADER, MCP_WORKSPACE_ID_HEADER, + is_hosted_mcp_mode, ) diff --git a/airbyte/mcp/http_main.py b/airbyte/mcp/http_main.py index e20b052d5..0ca1e61ca 100644 --- a/airbyte/mcp/http_main.py +++ b/airbyte/mcp/http_main.py @@ -50,7 +50,7 @@ register_landing_page, ) -from airbyte._util.mcp_mode import set_hosted_mcp_mode +from airbyte.constants import set_hosted_mcp_mode from airbyte.mcp.server import ( DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT, From c2a55f514a577e48488efa4cb44b542bc84f89d6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:44:18 +0000 Subject: [PATCH 15/21] refactor(mcp): drop redundant guidance=None kwarg Co-Authored-By: AJ Steers --- airbyte/mcp/cloud.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index ac85e369f..eb6a1925f 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -280,7 +280,7 @@ def _get_cloud_workspace( """ resolved_workspace_id = workspace_id or get_mcp_config(ctx, MCP_CONFIG_WORKSPACE_ID) if not resolved_workspace_id: - raise AirbyteMissingWorkspaceContextError(guidance=None) + raise AirbyteMissingWorkspaceContextError return _get_cloud_client(ctx).get_workspace(resolved_workspace_id) From 8148d77da4e7d22e2e02348623fcc63c2684d0d6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:13:14 +0000 Subject: [PATCH 16/21] fix(mcp): avoid package-level exception import cycle Co-Authored-By: AJ Steers --- airbyte/cloud/_credentials.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/airbyte/cloud/_credentials.py b/airbyte/cloud/_credentials.py index c145bcdb3..6fc347cda 100644 --- a/airbyte/cloud/_credentials.py +++ b/airbyte/cloud/_credentials.py @@ -5,7 +5,6 @@ from dataclasses import dataclass, replace -from airbyte import exceptions as exc from airbyte.constants import ( CLOUD_API_ROOT, CLOUD_API_ROOT_ENV_VAR, @@ -16,6 +15,7 @@ CLOUD_ORGANIZATION_ID_ENV_VAR, CLOUD_WORKSPACE_ID_ENV_VAR, ) +from airbyte.exceptions import AirbyteNoCloudCredentialsError, PyAirbyteInputError from airbyte.secrets.base import SecretString from airbyte.secrets.util import try_get_secret @@ -73,7 +73,7 @@ def from_auth( ) if resolved_bearer_token and (resolved_client_id or resolved_client_secret): - raise exc.PyAirbyteInputError( + raise PyAirbyteInputError( message="Cannot use both client credentials and bearer token authentication.", guidance=( "Provide either client_id and client_secret together, " @@ -81,12 +81,12 @@ def from_auth( ), ) if bool(resolved_client_id) != bool(resolved_client_secret): - raise exc.PyAirbyteInputError( + raise PyAirbyteInputError( message="Client ID and client secret are both required.", guidance="Provide both client ID and client secret, or use a bearer token.", ) if not resolved_bearer_token and not resolved_client_id: - raise exc.AirbyteNoCloudCredentialsError(_env_vars=env_vars) + raise AirbyteNoCloudCredentialsError(_env_vars=env_vars) return cls( client_id=SecretString(resolved_client_id) if resolved_client_id else None, From 9ca44204ae478427f3126ea046255d46ba50d8cb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:28:34 +0000 Subject: [PATCH 17/21] fix(cloud): import _credentials module to clear unsafe cyclic import organizations.py and workspaces.py imported the _AirbyteCredentials name directly, which CodeQL flags as py/unsafe-cyclic-import (the name may be undefined mid-cycle). Import the _credentials module instead so the class is resolved lazily at use time, downgrading the alert to a benign module-level cyclic import. Co-Authored-By: AJ Steers --- airbyte/cloud/organizations.py | 4 ++-- airbyte/cloud/workspaces.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/airbyte/cloud/organizations.py b/airbyte/cloud/organizations.py index 1ab5e6a86..2cef03826 100644 --- a/airbyte/cloud/organizations.py +++ b/airbyte/cloud/organizations.py @@ -7,7 +7,7 @@ from typing import Any from airbyte._util import api_util -from airbyte.cloud._credentials import _AirbyteCredentials +from airbyte.cloud import _credentials as _cred from airbyte.secrets.base import SecretString @@ -43,7 +43,7 @@ def __init__( self._email = email """Email associated with the organization.""" - self._credentials = _AirbyteCredentials( + self._credentials = _cred._AirbyteCredentials( # noqa: SLF001 client_id=SecretString(client_id) if client_id else None, client_secret=SecretString(client_secret) if client_secret else None, bearer_token=SecretString(bearer_token) if bearer_token else None, diff --git a/airbyte/cloud/workspaces.py b/airbyte/cloud/workspaces.py index 58f5539e7..f96fdf97f 100644 --- a/airbyte/cloud/workspaces.py +++ b/airbyte/cloud/workspaces.py @@ -45,7 +45,7 @@ from airbyte import exceptions as exc from airbyte._util import api_util, text_util from airbyte._util.api_util import get_web_url_root -from airbyte.cloud._credentials import _AirbyteCredentials +from airbyte.cloud import _credentials as _cred from airbyte.cloud.client_config import CloudClientConfig from airbyte.cloud.connections import CloudConnection from airbyte.cloud.connectors import ( @@ -104,7 +104,7 @@ class CloudWorkspace: bearer_token: SecretString | None # Internal credentials objects (set in __init__, excluded from repr) - _credentials: _AirbyteCredentials = field(init=False, repr=False) + _credentials: _cred._AirbyteCredentials = field(init=False, repr=False) _client_config: CloudClientConfig = field(init=False, repr=False) def __init__( @@ -119,7 +119,7 @@ def __init__( ) -> None: """Validate and initialize credentials.""" env_vars = not (client_id or client_secret or bearer_token) - credentials = _AirbyteCredentials.from_auth( + credentials = _cred._AirbyteCredentials.from_auth( # noqa: SLF001 workspace_id=workspace_id, client_id=client_id, client_secret=client_secret, From 74c3ce547ca4f8a4650c09c2b7fd55bf70d5cbcf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:33:54 +0000 Subject: [PATCH 18/21] Revert "fix(cloud): import _credentials module to clear unsafe cyclic import" This reverts commit 9ca44204ae478427f3126ea046255d46ba50d8cb. --- airbyte/cloud/organizations.py | 4 ++-- airbyte/cloud/workspaces.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/airbyte/cloud/organizations.py b/airbyte/cloud/organizations.py index 2cef03826..1ab5e6a86 100644 --- a/airbyte/cloud/organizations.py +++ b/airbyte/cloud/organizations.py @@ -7,7 +7,7 @@ from typing import Any from airbyte._util import api_util -from airbyte.cloud import _credentials as _cred +from airbyte.cloud._credentials import _AirbyteCredentials from airbyte.secrets.base import SecretString @@ -43,7 +43,7 @@ def __init__( self._email = email """Email associated with the organization.""" - self._credentials = _cred._AirbyteCredentials( # noqa: SLF001 + self._credentials = _AirbyteCredentials( client_id=SecretString(client_id) if client_id else None, client_secret=SecretString(client_secret) if client_secret else None, bearer_token=SecretString(bearer_token) if bearer_token else None, diff --git a/airbyte/cloud/workspaces.py b/airbyte/cloud/workspaces.py index f96fdf97f..58f5539e7 100644 --- a/airbyte/cloud/workspaces.py +++ b/airbyte/cloud/workspaces.py @@ -45,7 +45,7 @@ from airbyte import exceptions as exc from airbyte._util import api_util, text_util from airbyte._util.api_util import get_web_url_root -from airbyte.cloud import _credentials as _cred +from airbyte.cloud._credentials import _AirbyteCredentials from airbyte.cloud.client_config import CloudClientConfig from airbyte.cloud.connections import CloudConnection from airbyte.cloud.connectors import ( @@ -104,7 +104,7 @@ class CloudWorkspace: bearer_token: SecretString | None # Internal credentials objects (set in __init__, excluded from repr) - _credentials: _cred._AirbyteCredentials = field(init=False, repr=False) + _credentials: _AirbyteCredentials = field(init=False, repr=False) _client_config: CloudClientConfig = field(init=False, repr=False) def __init__( @@ -119,7 +119,7 @@ def __init__( ) -> None: """Validate and initialize credentials.""" env_vars = not (client_id or client_secret or bearer_token) - credentials = _cred._AirbyteCredentials.from_auth( # noqa: SLF001 + credentials = _AirbyteCredentials.from_auth( workspace_id=workspace_id, client_id=client_id, client_secret=client_secret, From 276cd0ee7089354b41fcdb9bc7fa65b917cc970a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:34:28 +0000 Subject: [PATCH 19/21] fix(mcp): remove cloud type import from exception cycle Co-Authored-By: AJ Steers --- airbyte/exceptions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index 075df057c..67dda58e7 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -60,7 +60,6 @@ if TYPE_CHECKING: from airbyte._util.api_duck_types import AirbyteApiResponseDuckType - from airbyte.cloud.workspaces import CloudWorkspace NEW_ISSUE_URL = "https://github.com/airbytehq/airbyte/issues/new/choose" @@ -539,7 +538,7 @@ class AirbyteError(PyAirbyteError): response: AirbyteApiResponseDuckType | None = None """The API response from the failed request.""" - workspace: CloudWorkspace | None = None + workspace: Any | None = None """The workspace where the error occurred.""" @property From a58ff2d273c8787964f9c7218575c245febb186c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:42:47 +0000 Subject: [PATCH 20/21] refactor(exceptions): type workspace via local Protocol instead of Any AirbyteError.workspace was downgraded to Any when its CloudWorkspace import was removed, losing static checking of the workspace_url property. Declare a local _WorkspaceWithUrl Protocol that structurally describes the one attribute exceptions needs, so the field is properly typed without importing airbyte.cloud (duck typing, no shared ancestor required). Co-Authored-By: AJ Steers --- airbyte/exceptions.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index 67dda58e7..7335bc1f7 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -42,7 +42,7 @@ from dataclasses import dataclass from pathlib import Path from textwrap import indent -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol from airbyte.constants import ( AIRBYTE_PRINT_FULL_ERROR_LOGS, @@ -531,6 +531,20 @@ class PyAirbyteSecretNotFoundError(PyAirbyteError): # Airbyte API Errors +class _WorkspaceWithUrl(Protocol): + """Structural type for a workspace that exposes a `workspace_url`. + + Declared locally so `exceptions` does not need to import `airbyte.cloud`, which + would create an import cycle. Any object with a `workspace_url` attribute (e.g. + `CloudWorkspace`) satisfies this via structural (duck) typing. + """ + + @property + def workspace_url(self) -> str | None: + """The web URL of the workspace.""" + ... + + @dataclass class AirbyteError(PyAirbyteError): """An error occurred while communicating with the hosted Airbyte instance.""" @@ -538,7 +552,7 @@ class AirbyteError(PyAirbyteError): response: AirbyteApiResponseDuckType | None = None """The API response from the failed request.""" - workspace: Any | None = None + workspace: _WorkspaceWithUrl | None = None """The workspace where the error occurred.""" @property From 622c26875cf3dd7c395296568ff8fa7d70d277ff Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:44:35 +0000 Subject: [PATCH 21/21] style(exceptions): drop redundant ellipsis in Protocol method body Co-Authored-By: AJ Steers --- airbyte/exceptions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index 7335bc1f7..96117b140 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -542,7 +542,6 @@ class _WorkspaceWithUrl(Protocol): @property def workspace_url(self) -> str | None: """The web URL of the workspace.""" - ... @dataclass