From 16bd0d52a6d27647ff858c9e1421d57941c98a84 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:29:17 +0000 Subject: [PATCH 1/8] feat(mcp)!: remove Cloud MCP session safe mode Co-Authored-By: AJ Steers --- .github/mcp-ci-tests.mcp.json | 3 +- airbyte/constants.py | 2 - airbyte/exceptions.py | 10 +--- airbyte/mcp/__init__.py | 25 +------- airbyte/mcp/_error_handling.py | 2 - airbyte/mcp/_tool_utils.py | 60 ++----------------- airbyte/mcp/cloud.py | 56 +++++------------ airbyte/mcp/server.py | 3 +- docs/CONTRIBUTING.md | 3 +- .../test_deferred_cloud_credentials.py | 10 +--- tests/unit_tests/test_mcp_cloud.py | 12 ---- .../test_mcp_connection_schedules.py | 18 ------ tests/unit_tests/test_mcp_datadog.py | 10 ---- tests/unit_tests/test_mcp_http_main.py | 2 +- .../unit_tests/test_mcp_policy_middleware.py | 27 +-------- tests/unit_tests/test_mcp_tool_errors.py | 6 +- tests/unit_tests/test_mcp_tool_filters.py | 25 +++----- tests/unit_tests/test_mcp_tool_utils.py | 57 ------------------ 18 files changed, 45 insertions(+), 286 deletions(-) diff --git a/.github/mcp-ci-tests.mcp.json b/.github/mcp-ci-tests.mcp.json index ce5cbc276..e3705b371 100644 --- a/.github/mcp-ci-tests.mcp.json +++ b/.github/mcp-ci-tests.mcp.json @@ -8,8 +8,7 @@ "airbyte-mcp" ], "env": { - "GCP_GSM_CREDENTIALS": "${GCP_GSM_CREDENTIALS}", - "AIRBYTE_CLOUD_MCP_SAFE_MODE": "1" + "GCP_GSM_CREDENTIALS": "${GCP_GSM_CREDENTIALS}" } } } diff --git a/airbyte/constants.py b/airbyte/constants.py index 575b73dc4..d40cf175a 100644 --- a/airbyte/constants.py +++ b/airbyte/constants.py @@ -306,8 +306,6 @@ def is_hosted_mcp_mode() -> bool: MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR: str = "AIRBYTE_CLOUD_MCP_ALLOW_EXTERNAL_ACCESS" """Environment variable controlling access to external-access Cloud tools.""" -CLOUD_MCP_SAFE_MODE_ENV_VAR: str = "AIRBYTE_CLOUD_MCP_SAFE_MODE" -"""Environment variable controlling safe mode for destructive Cloud operations.""" MCP_DOMAINS_DISABLED_ENV_VAR: str = "AIRBYTE_MCP_DOMAINS_DISABLED" """Environment variable to disable specific MCP tool domains. diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index c45575f73..86e38d2c9 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -332,13 +332,6 @@ class AirbyteTrustedExecutionRequiredError(AirbyteMCPError): feature: str | None = None -class AirbyteSafeModeError(AirbyteMCPError): - """A destructive operation was blocked by MCP safe mode. - - Safe mode only allows destructive operations on objects created in the current session. - """ - - @dataclass class AirbytePipelineChangesDisabledError(AirbyteMCPError): """A pipeline-changing operation was blocked by MCP policy.""" @@ -357,8 +350,7 @@ class AirbyteExternalAccessDisabledError(AirbyteMCPError): guidance: str | None = ( "Set `AIRBYTE_CLOUD_MCP_ALLOW_EXTERNAL_ACCESS=1` and ensure the " "`X-MCP-Allow-External-Access` request header is not `0`. Disabled pipeline changes " - "(including legacy read-only mode) and an explicit `AIRBYTE_CLOUD_MCP_SAFE_MODE=1` " - "also disable external access when this setting is unset." + "(including legacy read-only mode) also disable external access when this setting is unset." ) diff --git a/airbyte/mcp/__init__.py b/airbyte/mcp/__init__.py index 077aebe58..2547a94ec 100644 --- a/airbyte/mcp/__init__.py +++ b/airbyte/mcp/__init__.py @@ -86,7 +86,6 @@ ], "env": { "AIRBYTE_MCP_ENV_FILE": "/path/to/my/.mcp/airbyte_mcp.env", - "AIRBYTE_CLOUD_MCP_SAFE_MODE": "1", "AIRBYTE_CLOUD_MCP_READONLY_MODE": "0" } } @@ -118,23 +117,6 @@ **Important:** The below settings only affect Cloud operations; local operations are not affected. -### Airbyte Cloud Safe Mode - -Safe mode is enabled by default and is controlled by the `AIRBYTE_CLOUD_MCP_SAFE_MODE` environment -variable. - -When enabled, write operations are allowed but destructive operations (updates, deletions) are -only allowed for objects created within the same session. For example, you can create a new -connector and then delete it, but you cannot delete an existing connector that was not created in -the current session. Modifications to configurations are likewise treated as potentially destructive -and are only allowed for objects created in the current session. - -Set the environment variable `AIRBYTE_CLOUD_MCP_SAFE_MODE=0` to disable safe mode. -When `AIRBYTE_CLOUD_MCP_SAFE_MODE` is explicitly set to a truthy value, external-access tools -(for example `execute_external_api_query`) are also disabled unless -`AIRBYTE_CLOUD_MCP_ALLOW_EXTERNAL_ACCESS=1` explicitly allows them. Safe mode's default-on -behavior does not by itself disable external access. - ### Airbyte Cloud Read-Only Mode Read-only mode is not enabled by default and is controlled by the @@ -163,10 +145,9 @@ be narrowed by a request header denial. If the environment setting is unset, the request header decides. The legacy `AIRBYTE_CLOUD_MCP_READONLY_MODE=1` also disables pipeline changes. -When external access is unset, it is disabled if pipeline changes are disabled or -`AIRBYTE_CLOUD_MCP_SAFE_MODE=1` is explicitly set; otherwise external-access tools remain -available. Explicitly allowing external access overrides these calculated defaults. Running or -cancelling a sync is not a pipeline change. +When external access is unset, it is disabled if pipeline changes are disabled; +otherwise external-access tools remain available. Explicitly allowing external access +overrides these calculated defaults. Running or cancelling a sync is not a pipeline change. ## Authentication for Remote (HTTP) Servers diff --git a/airbyte/mcp/_error_handling.py b/airbyte/mcp/_error_handling.py index bcbbac45a..5566a91cb 100644 --- a/airbyte/mcp/_error_handling.py +++ b/airbyte/mcp/_error_handling.py @@ -41,7 +41,6 @@ AirbyteMCPError, AirbyteMissingResourceError, AirbytePipelineChangesDisabledError, - AirbyteSafeModeError, ) @@ -79,7 +78,6 @@ MCP_TOOL_USER_FACING_ERRORS: tuple[type[AirbyteLibError], ...] = ( AirbyteLibInputError, AirbyteMCPError, - AirbyteSafeModeError, AirbytePipelineChangesDisabledError, AirbyteExternalAccessDisabledError, AirbyteAgentsUnavailableError, diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index fe22eb23b..f4dc047a9 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -1,9 +1,7 @@ # Copyright (c) 2024 Airbyte, Inc., all rights reserved. -"""MCP tool utility functions for safe mode and config args. +"""MCP tool utility functions for config args and tool filters. This module provides: -- Safe mode functionality for MCP tools, allowing tracking of resources created - during a session to prevent accidental deletion of pre-existing resources. - Config args and filters for backward compatibility with legacy Airbyte env vars. """ @@ -44,7 +42,6 @@ CLOUD_CLIENT_ID_ENV_VAR, CLOUD_CLIENT_SECRET_ENV_VAR, CLOUD_CONFIG_API_ROOT_ENV_VAR, - CLOUD_MCP_SAFE_MODE_ENV_VAR, CLOUD_ORGANIZATION_ID_ENV_VAR, CLOUD_WORKSPACE_ID_ENV_VAR, MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, @@ -76,7 +73,7 @@ MCP_WORKSPACE_ID_HEADER, _str_to_bool, ) -from airbyte.exceptions import AirbyteLibInputError, AirbyteSafeModeError +from airbyte.exceptions import AirbyteLibInputError if TYPE_CHECKING: @@ -88,55 +85,15 @@ _MCP_TOOL_FUNC = TypeVar("_MCP_TOOL_FUNC", bound=Callable[..., object]) # ============================================================================= -# Safe Mode Configuration +# Environment-Derived Flags # ============================================================================= -AIRBYTE_CLOUD_MCP_SAFE_MODE = os.environ.get("AIRBYTE_CLOUD_MCP_SAFE_MODE", "1").strip() != "0" -"""Whether safe mode is enabled for cloud operations. - -When enabled (default), destructive operations are only allowed on resources -created during the current session. -""" - AIRBYTE_CLOUD_WORKSPACE_ID_IS_SET = bool(os.environ.get("AIRBYTE_CLOUD_WORKSPACE_ID", "").strip()) """Whether the AIRBYTE_CLOUD_WORKSPACE_ID environment variable is set. When set, the workspace_id parameter is hidden from cloud tools. """ -_GUIDS_CREATED_IN_SESSION: set[str] = set() - - -def register_guid_created_in_session(guid: str) -> None: - """Register a GUID as created in this session. - - Args: - guid: The GUID to register - """ - _GUIDS_CREATED_IN_SESSION.add(guid) - - -def check_guid_created_in_session(guid: str) -> None: - """Check if a GUID was created in this session. - - This is a no-op if AIRBYTE_CLOUD_MCP_SAFE_MODE is set to "0". - - Raises `AirbyteSafeModeError` if the GUID was not created in this session and - AIRBYTE_CLOUD_MCP_SAFE_MODE is set to 1. - - Args: - guid: The GUID to check - """ - if AIRBYTE_CLOUD_MCP_SAFE_MODE and guid not in _GUIDS_CREATED_IN_SESSION: - raise AirbyteSafeModeError( - message=( - f"Cannot perform destructive operation on '{guid}': " - f"Object was not created in this session. " - f"AIRBYTE_CLOUD_MCP_SAFE_MODE is set to '1'." - ), - ) - - # ============================================================================= # Backward-Compatible Config Args # ============================================================================= @@ -516,8 +473,8 @@ def pipeline_changes_allowed(app_or_ctx: FastMCP | Context) -> bool | None: def external_access_allowed(app_or_ctx: FastMCP | Context) -> bool | None: """Resolve explicit permission first, then derive the external-access default. - Disabled pipeline changes and explicitly enabled safe mode disable external access - when no explicit permission is configured; otherwise the result remains unset. + Disabled pipeline changes disable external access when no explicit permission + is configured; otherwise the result remains unset. """ explicit_value = _resolve_policy( app_or_ctx, @@ -526,12 +483,7 @@ def external_access_allowed(app_or_ctx: FastMCP | Context) -> bool | None: ) if explicit_value is not None: return explicit_value - explicitly_enabled_safe_mode = _str_to_bool(os.environ.get(CLOUD_MCP_SAFE_MODE_ENV_VAR)) is True - return ( - False - if pipeline_changes_allowed(app_or_ctx) is False or explicitly_enabled_safe_mode - else None - ) + return False if pipeline_changes_allowed(app_or_ctx) is False else None def _insiders_mode(app: FastMCP) -> bool | None: diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 256b536b7..8525ab7c9 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -69,7 +69,6 @@ AirbyteCloudApiError, AirbyteCloudError, AirbyteConnectorNotRegisteredError, - AirbyteDeferredSetupError, AirbyteLibError, AirbyteLibInputError, AirbyteMissingResourceError, @@ -89,9 +88,7 @@ from airbyte.mcp._scope import record_default_workspace, record_resolved_organization from airbyte.mcp._tool_utils import ( AIRBYTE_CLOUD_WORKSPACE_ID_IS_SET, - check_guid_created_in_session, mcp_tool, - register_guid_created_in_session, ) from airbyte.mcp._trace_attributes import agent_action_attributes from airbyte.mcp._user_identity import forget_cached_airbyte_user @@ -692,7 +689,6 @@ def deploy_connector_to_cloud( # noqa: PLR0913 # Mirrors the API surface. else workspace.deploy_destination(name=name, destination=connector, unique=unique) ) - register_guid_created_in_session(deployed.connector_id) return ( f"Successfully deployed {resolved_type.value} '{name}' with ID " f"'{deployed.connector_id}' and URL: {deployed.connector_url}" @@ -728,28 +724,22 @@ def _deploy_deferred_to_cloud( workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) deployed: CloudSource | CloudDestination - try: - if connector_type == ConnectorType.SOURCE: - deployed = workspace.deploy_source( - name=name, - source=config_dict, - unique=unique, - definition_id=metadata.definition_id, - defer_credentials=True, - ) - else: - deployed = workspace.deploy_destination( - name=name, - destination=config_dict, - unique=unique, - definition_id=metadata.definition_id, - defer_credentials=True, - ) - except AirbyteDeferredSetupError as ex: - if ex.actor_id is not None: - register_guid_created_in_session(ex.actor_id) - raise - register_guid_created_in_session(deployed.connector_id) + if connector_type == ConnectorType.SOURCE: + deployed = workspace.deploy_source( + name=name, + source=config_dict, + unique=unique, + definition_id=metadata.definition_id, + defer_credentials=True, + ) + else: + deployed = workspace.deploy_destination( + name=name, + destination=config_dict, + unique=unique, + definition_id=metadata.definition_id, + defer_credentials=True, + ) return DeferredDeployResult( connector_id=deployed.connector_id, connector_type=connector_type, @@ -868,7 +858,6 @@ def create_connection_on_cloud( table_prefix=table_prefix, ) - register_guid_created_in_session(deployed_connection.connection_id) result = ( f"Successfully created connection '{connection_name}' " f"with ID '{deployed_connection.connection_id}' and " @@ -957,7 +946,6 @@ def deploy_noop_destination_to_cloud( destination=destination, unique=unique, ) - register_guid_created_in_session(deployed_destination.connector_id) return ( f"Successfully deployed No-op Destination " f"with ID '{deployed_destination.connector_id}' and " @@ -1169,7 +1157,6 @@ def cancel_cloud_sync( """Cancel a running sync job on an Airbyte Cloud connection.""" workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) connection = workspace.get_connection(connection_id=connection_id) - # Deliberately omit check_guid_created_in_session: cancelling a sync is reversible. sync_result = connection.cancel_sync(job_id=job_id) return SyncJobResult( job_id=sync_result.job_id, @@ -3030,7 +3017,6 @@ def publish_custom_source_definition( pre_validate=pre_validate, testing_values=testing_values_dict, ) - register_guid_created_in_session(custom_source.definition_id) return ( "Successfully published custom YAML source definition:\n" + _get_custom_source_definition_description( @@ -3268,8 +3254,6 @@ def update_custom_source_definition( At least one of name, manifest_yaml, testing_values, or testing_values_secret_name must be provided. """ - check_guid_created_in_session(definition_id) - workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) if ( @@ -3361,7 +3345,6 @@ def permanently_delete_custom_source_definition( Note: Only YAML (declarative) connectors are currently supported. Docker-based custom sources are not yet available. """ - check_guid_created_in_session(definition_id) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) definition = workspace.get_custom_source_definition( definition_id=definition_id, @@ -3427,7 +3410,6 @@ def permanently_delete_cloud_connector( The provided name must match the actual name of the connector for the operation to proceed. This is a safety measure to ensure you are deleting the correct resource. """ - check_guid_created_in_session(connector_id) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) connector = _get_cloud_connector(workspace, connector_id, connector_type) actual_name: str = cast(str, connector.name) @@ -3511,7 +3493,6 @@ def permanently_delete_cloud_connection( The provided name must match the actual name of the connection for the operation to proceed. This is a safety measure to ensure you are deleting the correct resource. """ - check_guid_created_in_session(connection_id) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) connection = workspace.get_connection(connection_id=connection_id) actual_name: str = cast(str, connection.name) @@ -3625,7 +3606,6 @@ def update_cloud_connector_config( This is a destructive operation that can break existing connections if the configuration is changed incorrectly. Use with caution. """ - check_guid_created_in_session(connector_id) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) connector = _get_typed_cloud_connector(workspace, connector_id, connector_type) @@ -3703,7 +3683,6 @@ def set_cloud_connection_table_prefix( This is a destructive operation that can break downstream dependencies if the table prefix is changed incorrectly. Use with caution. """ - check_guid_created_in_session(connection_id) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) connection = workspace.get_connection(connection_id=connection_id) connection.set_table_prefix(prefix=prefix) @@ -3747,7 +3726,6 @@ def set_cloud_connection_selected_streams( This is a destructive operation that can break existing connections if the stream selection is changed incorrectly. Use with caution. """ - check_guid_created_in_session(connection_id) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) connection = workspace.get_connection(connection_id=connection_id) @@ -3849,8 +3827,6 @@ def update_cloud_connection( At least one setting must be provided. `interval_hours`, `cron_expression`, and `manual_schedule=True` are mutually exclusive. """ - check_guid_created_in_session(connection_id) - # Validate that at least one setting is provided if ( enabled is None diff --git a/airbyte/mcp/server.py b/airbyte/mcp/server.py index 9a7d948f6..a14c689bd 100644 --- a/airbyte/mcp/server.py +++ b/airbyte/mcp/server.py @@ -179,8 +179,7 @@ _INSTRUCTIONS_SAFETY = """ Safety features: -- Safe mode (default): Restricts destructive operations to objects created in - the current session +- Deletion guard: deleting a resource requires "delete-me" or "deleteme" in its name - Read-only mode: Disables all write operations for cloud resources""" diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 22b924f01..d9c4e0ff7 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -106,8 +106,7 @@ In your MCP config, you can test your development updates using `uv` as the entr ], "env": { "AIRBYTE_MCP_ENV_FILE": "/path/to/my/.mcp/airbyte_mcp.env", - "AIRBYTE_CLOUD_MCP_READONLY_MODE": "0", - "AIRBYTE_CLOUD_MCP_SAFE_MODE": "0" + "AIRBYTE_CLOUD_MCP_READONLY_MODE": "0" } } } diff --git a/tests/unit_tests/test_deferred_cloud_credentials.py b/tests/unit_tests/test_deferred_cloud_credentials.py index 6742c5168..5ffe485cf 100644 --- a/tests/unit_tests/test_deferred_cloud_credentials.py +++ b/tests/unit_tests/test_deferred_cloud_credentials.py @@ -669,15 +669,11 @@ def test_mcp_deploy_with_deferred_credentials_returns_handoff( assert call[connector_type] == {"count": 10} -def test_mcp_deploy_deferred_registers_unacknowledged_actor_for_cleanup( +def test_mcp_deploy_deferred_propagates_unacknowledged_actor_error( workspace_like: _WorkspaceLike, monkeypatch: pytest.MonkeyPatch, ) -> None: - """An actor created without deferral acknowledgement is still tracked for safe-mode cleanup.""" - registered: list[str] = [] - monkeypatch.setattr( - cloud_mcp, "register_guid_created_in_session", registered.append - ) + """A deferral-acknowledgement failure propagates to the caller.""" def _unacknowledged(**kwargs: Any) -> _DeployedLike: # noqa: ANN401 raise exc.AirbyteDeferredSetupError( @@ -699,8 +695,6 @@ def _unacknowledged(**kwargs: Any) -> _DeployedLike: # noqa: ANN401 defer_credentials=True, ) - assert registered == [ACTOR_ID] - def test_mcp_deploy_deferred_rejects_connector_type_mismatch( workspace_like: _WorkspaceLike, diff --git a/tests/unit_tests/test_mcp_cloud.py b/tests/unit_tests/test_mcp_cloud.py index cf89d558e..9cf079cea 100644 --- a/tests/unit_tests/test_mcp_cloud.py +++ b/tests/unit_tests/test_mcp_cloud.py @@ -879,7 +879,6 @@ def fake_get_cloud_workspace( return workspace monkeypatch.setattr(cloud_mcp, "_get_cloud_workspace", fake_get_cloud_workspace) - monkeypatch.setattr(cloud_mcp, "check_guid_created_in_session", lambda _: None) result = tool( cast(Context, object()), @@ -936,7 +935,6 @@ def test_permanently_delete_cloud_connector_surfaces_library_behavior( **{deleter: delete}, ) monkeypatch.setattr(cloud_mcp, "_get_cloud_workspace", lambda *_args: workspace) - monkeypatch.setattr(cloud_mcp, "check_guid_created_in_session", lambda _: None) if in_use: with pytest.raises(AirbyteConnectorInUseError) as exc_info: @@ -1081,7 +1079,6 @@ def test_set_cloud_connection_selected_streams_surfaces_library_error( ) workspace = SimpleNamespace(get_connection=lambda **_: connection) monkeypatch.setattr(cloud_mcp, "_get_cloud_workspace", lambda *_args: workspace) - monkeypatch.setattr(cloud_mcp, "check_guid_created_in_session", lambda _: None) with pytest.raises(AirbyteLibInputError) as exc_info: cloud_mcp.set_cloud_connection_selected_streams( @@ -1152,11 +1149,6 @@ def fake_deploy(**kwargs: object) -> SimpleNamespace: monkeypatch.setattr( cloud_mcp, "resolve_connector_config", lambda **_: {"resolved": True} ) - registered: list[str] = [] - monkeypatch.setattr( - cloud_mcp, "register_guid_created_in_session", registered.append - ) - result = cloud_mcp.deploy_connector_to_cloud( cast(Context, object()), name="My Connector", @@ -1174,7 +1166,6 @@ def fake_deploy(**kwargs: object) -> SimpleNamespace: assert deploy_calls[0]["name"] == "My Connector" assert deploy_calls[0]["unique"] is True assert deploy_calls[0][connector_type.value] is connector - assert registered == ["deployed-id"] assert f"deployed {connector_type.value} 'My Connector'" in result assert "deployed-id" in result @@ -1250,9 +1241,6 @@ def _patch_deploy_connection_workspace( ) -> _DeployConnectionWorkspace: workspace = _DeployConnectionWorkspace(missing_ids=missing_ids) monkeypatch.setattr(cloud_mcp, "_get_cloud_workspace", lambda *_: workspace) - monkeypatch.setattr( - cloud_mcp, "register_guid_created_in_session", lambda *args: None - ) return workspace diff --git a/tests/unit_tests/test_mcp_connection_schedules.py b/tests/unit_tests/test_mcp_connection_schedules.py index d3addc411..376720a0c 100644 --- a/tests/unit_tests/test_mcp_connection_schedules.py +++ b/tests/unit_tests/test_mcp_connection_schedules.py @@ -63,7 +63,6 @@ def schedule_backend( ) patch = MagicMock(return_value=_connection_response()) monkeypatch.setattr(api_util, "patch_connection", patch) - monkeypatch.setattr(_tool_utils, "AIRBYTE_CLOUD_MCP_SAFE_MODE", False) with responses.RequestsMock(assert_all_requests_are_fired=False) as http: http.post( "https://config.example/custom/web_backend/connections/update", @@ -268,23 +267,6 @@ def test_update_existing_settings_remain_supported( assert _connection_api_calls(http) == [] -def test_update_interval_obeys_safe_mode( - schedule_backend: tuple[FastMCP, responses.RequestsMock, MagicMock, MagicMock], - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Safe mode blocks interval updates to connections outside the current session.""" - server, http, lookup, patch = schedule_backend - monkeypatch.setattr(_tool_utils, "AIRBYTE_CLOUD_MCP_SAFE_MODE", True) - monkeypatch.setattr(_tool_utils, "_GUIDS_CREATED_IN_SESSION", set()) - - with pytest.raises(ToolError, match="not created in this session"): - asyncio.run(_update(server, interval_hours=24)) - - lookup.assert_not_called() - patch.assert_not_called() - assert _connection_api_calls(http) == [] - - def test_update_remains_hidden_in_readonly_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit_tests/test_mcp_datadog.py b/tests/unit_tests/test_mcp_datadog.py index 3494a978c..d0da3f2ee 100644 --- a/tests/unit_tests/test_mcp_datadog.py +++ b/tests/unit_tests/test_mcp_datadog.py @@ -21,7 +21,6 @@ AirbyteCloudApiError, AirbyteConnectionSyncError, AirbyteConnectionSyncTimeoutError, - AirbyteSafeModeError, ) from airbyte.mcp import _datadog, _telemetry from airbyte.version import get_version @@ -445,15 +444,6 @@ def test_cloud_error_trace_message_ignores_missing_resource_without_http_error() }, id="config-api-error-response-text", ), - pytest.param( - AirbyteSafeModeError(message="SENTINEL message"), - { - "airbyte.mcp.error_type": "AirbyteSafeModeError", - "airbyte.mcp.error.category": "user_error", - "airbyte.mcp.error.fault": "caller", - }, - id="user-facing-error", - ), pytest.param( AirbyteConnectionSyncError( connection_id="SENTINEL", job_id=1, job_status="failed" diff --git a/tests/unit_tests/test_mcp_http_main.py b/tests/unit_tests/test_mcp_http_main.py index 5c2070fe9..62eb5c489 100644 --- a/tests/unit_tests/test_mcp_http_main.py +++ b/tests/unit_tests/test_mcp_http_main.py @@ -214,7 +214,7 @@ def test_main_sets_hosted_instructions(monkeypatch: pytest.MonkeyPatch) -> None: ): assert local_only not in instructions assert "get_default_cloud_context" in instructions - assert "Safe mode (default)" in instructions + assert "Deletion guard" in instructions assert "Read-only mode" in instructions assert "list_cloud_organizations" in instructions diff --git a/tests/unit_tests/test_mcp_policy_middleware.py b/tests/unit_tests/test_mcp_policy_middleware.py index 16feeef16..236a2a27d 100644 --- a/tests/unit_tests/test_mcp_policy_middleware.py +++ b/tests/unit_tests/test_mcp_policy_middleware.py @@ -13,7 +13,6 @@ from fastmcp_extensions import mcp_server from airbyte.constants import ( - CLOUD_MCP_SAFE_MODE_ENV_VAR, MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, MCP_READONLY_MODE_ENV_VAR, @@ -43,7 +42,6 @@ async def get_tool(self, name: str) -> Any: @pytest.fixture(autouse=True) def clear_policy_environment(monkeypatch: pytest.MonkeyPatch) -> None: for env_var in ( - CLOUD_MCP_SAFE_MODE_ENV_VAR, MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, MCP_READONLY_MODE_ENV_VAR, @@ -111,19 +109,7 @@ def clear_policy_environment(monkeypatch: pytest.MonkeyPatch) -> None: "external_query", ToolPolicy(pipeline_change=False, external_access=True), False, - {CLOUD_MCP_SAFE_MODE_ENV_VAR: "1"}, - {}, - AirbyteExternalAccessDisabledError, - id="explicit-safe-mode-default-denial", - ), - pytest.param( - "external_query", - ToolPolicy(pipeline_change=False, external_access=True), - False, - { - CLOUD_MCP_SAFE_MODE_ENV_VAR: "1", - MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR: "1", - }, + {MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR: "1"}, {}, None, id="explicit-external-access-allow", @@ -135,16 +121,7 @@ def clear_policy_environment(monkeypatch: pytest.MonkeyPatch) -> None: {}, {}, None, - id="unset-safe-mode-default-pass-through", - ), - pytest.param( - "external_query", - ToolPolicy(pipeline_change=False, external_access=True), - False, - {CLOUD_MCP_SAFE_MODE_ENV_VAR: "auto"}, - {}, - None, - id="auto-safe-mode-default-pass-through", + id="unset-default-pass-through", ), pytest.param( "external_query", diff --git a/tests/unit_tests/test_mcp_tool_errors.py b/tests/unit_tests/test_mcp_tool_errors.py index 0c9cf5484..7f5be4f9c 100644 --- a/tests/unit_tests/test_mcp_tool_errors.py +++ b/tests/unit_tests/test_mcp_tool_errors.py @@ -33,7 +33,6 @@ format_user_facing_error, mcp_tool_error_reason, ) -from airbyte.mcp._tool_utils import check_guid_created_in_session from fastmcp_extensions import UserFacingErrorMiddleware, mcp_server @@ -367,8 +366,9 @@ def test_user_facing_error_echoed_id_is_masked() -> None: @server.tool def unsafe(connection_id: str) -> str: - check_guid_created_in_session(connection_id) - return "done" + raise AirbyteLibInputError( + message=f"Cannot perform destructive operation on {connection_id}" + ) text = _call_error( server, diff --git a/tests/unit_tests/test_mcp_tool_filters.py b/tests/unit_tests/test_mcp_tool_filters.py index 504d9b401..ead7e37dd 100644 --- a/tests/unit_tests/test_mcp_tool_filters.py +++ b/tests/unit_tests/test_mcp_tool_filters.py @@ -11,7 +11,6 @@ from fastmcp_extensions.tool_filters import CONFIG_INCLUDE_MODULES from airbyte.constants import ( - CLOUD_MCP_SAFE_MODE_ENV_VAR, MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, MCP_CONFIG_ALLOW_EXTERNAL_ACCESS, @@ -44,7 +43,6 @@ def mcp_config(monkeypatch: pytest.MonkeyPatch) -> dict[str, str]: """Patch `get_mcp_config` so tests can set MCP config values directly.""" monkeypatch.delenv(MCP_INSIDERS_ENV_VAR, raising=False) for env_var in ( - CLOUD_MCP_SAFE_MODE_ENV_VAR, MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, MCP_READONLY_MODE_ENV_VAR, @@ -254,22 +252,17 @@ def test_pipeline_change_filter( "header", "pipeline_env", "legacy_readonly", - "safe_mode", "visible", ), [ - pytest.param("0", None, None, None, None, False, id="external-env-deny"), - pytest.param( - "1", "0", None, None, None, False, id="header-narrows-external-allow" - ), - pytest.param("0", "1", None, None, None, False, id="external-env-deny-wins"), - pytest.param(None, "0", None, None, None, False, id="external-header-deny"), - pytest.param("1", None, None, None, "1", True, id="explicit-external-allow"), - pytest.param(None, None, None, None, "1", False, id="explicit-safe-mode-deny"), - pytest.param(None, None, "0", None, None, False, id="pipeline-deny-default"), - pytest.param(None, None, None, "1", None, False, id="legacy-readonly-default"), - pytest.param(None, None, None, None, None, True, id="unset-default-allows"), - pytest.param(None, None, None, None, "auto", True, id="auto-safe-mode-allows"), + pytest.param("0", None, None, None, False, id="external-env-deny"), + pytest.param("1", "0", None, None, False, id="header-narrows-external-allow"), + pytest.param("0", "1", None, None, False, id="external-env-deny-wins"), + pytest.param(None, "0", None, None, False, id="external-header-deny"), + pytest.param("1", None, None, "1", True, id="explicit-external-allow"), + pytest.param(None, None, "0", None, False, id="pipeline-deny-default"), + pytest.param(None, None, None, "1", False, id="legacy-readonly-default"), + pytest.param(None, None, None, None, True, id="unset-default-allows"), ], ) def test_external_access_filter( @@ -279,14 +272,12 @@ def test_external_access_filter( header: str | None, pipeline_env: str | None, legacy_readonly: str | None, - safe_mode: str | None, visible: bool, ) -> None: for env_var, value in ( (MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, external_env), (MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, pipeline_env), (MCP_READONLY_MODE_ENV_VAR, legacy_readonly), - (CLOUD_MCP_SAFE_MODE_ENV_VAR, safe_mode), ): if value is None: monkeypatch.delenv(env_var, raising=False) diff --git a/tests/unit_tests/test_mcp_tool_utils.py b/tests/unit_tests/test_mcp_tool_utils.py index 06ff87d17..e4324d7fa 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -9,7 +9,6 @@ import pytest from fastmcp_extensions import MCPServerConfigArg -from airbyte.exceptions import AirbyteSafeModeError from airbyte.mcp._tool_utils import ( API_URL_CONFIG_ARG, ALLOW_EXTERNAL_ACCESS_CONFIG_ARG, @@ -18,13 +17,10 @@ CLIENT_SECRET_CONFIG_ARG, CONFIG_API_URL_CONFIG_ARG, TRUSTED_EXECUTION_CONFIG_ARG, - _GUIDS_CREATED_IN_SESSION, _TOOL_POLICIES, ToolPolicy, _resolve_transport_bearer_token, - check_guid_created_in_session, mcp_tool, - register_guid_created_in_session, ) from airbyte.mcp import server as server_module @@ -99,59 +95,6 @@ def test_resolve_transport_bearer_token( mock_get_http_headers.assert_called_once_with(include={"authorization"}) -@pytest.fixture(autouse=True) -def clear_session_guids() -> None: - """Clear the session GUIDs before each test.""" - _GUIDS_CREATED_IN_SESSION.clear() - - -def test_register_guid_created_in_session() -> None: - """Test that GUIDs can be registered as created in session.""" - assert "test-guid-123" not in _GUIDS_CREATED_IN_SESSION - register_guid_created_in_session("test-guid-123") - assert "test-guid-123" in _GUIDS_CREATED_IN_SESSION - - -def test_check_guid_created_in_session_passes_for_registered_guid() -> None: - """Test that check passes for GUIDs registered in session.""" - register_guid_created_in_session("test-guid-456") - # Should not raise - check_guid_created_in_session("test-guid-456") - - -def test_check_guid_created_in_session_raises_for_unregistered_guid() -> None: - """Test that check raises AirbyteSafeModeError for unregistered GUIDs when safe mode is enabled.""" - with patch("airbyte.mcp._tool_utils.AIRBYTE_CLOUD_MCP_SAFE_MODE", True): - with pytest.raises(AirbyteSafeModeError) as exc_info: - check_guid_created_in_session("unregistered-guid") - assert "unregistered-guid" in str(exc_info.value) - assert "not created in this session" in str(exc_info.value) - - -def test_check_guid_created_in_session_passes_when_safe_mode_disabled() -> None: - """Test that check passes for any GUID when safe mode is disabled.""" - with patch("airbyte.mcp._tool_utils.AIRBYTE_CLOUD_MCP_SAFE_MODE", False): - # Should not raise even for unregistered GUID - check_guid_created_in_session("any-guid-at-all") - - -def test_multiple_guids_can_be_registered() -> None: - """Test that multiple GUIDs can be registered in the same session.""" - guids = ["guid-1", "guid-2", "guid-3"] - for guid in guids: - register_guid_created_in_session(guid) - - for guid in guids: - assert guid in _GUIDS_CREATED_IN_SESSION - - -def test_duplicate_guid_registration_is_idempotent() -> None: - """Test that registering the same GUID multiple times is safe.""" - register_guid_created_in_session("duplicate-guid") - register_guid_created_in_session("duplicate-guid") - assert "duplicate-guid" in _GUIDS_CREATED_IN_SESSION - - @pytest.mark.parametrize( "config_arg", [ From 5af3a829ad50378a9fa9c9c634cad428fe4f51ce Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:51:07 +0000 Subject: [PATCH 2/8] feat(mcp): add opt-in STDIO local demo mode for session-scoped changes Co-Authored-By: AJ Steers --- airbyte/constants.py | 3 + airbyte/exceptions.py | 7 ++ airbyte/mcp/__init__.py | 7 ++ airbyte/mcp/_error_handling.py | 2 + airbyte/mcp/_tool_utils.py | 50 +++++++++++++- airbyte/mcp/cloud.py | 56 ++++++++++----- .../test_deferred_cloud_credentials.py | 10 ++- tests/unit_tests/test_mcp_cloud.py | 11 +++ .../test_mcp_connection_schedules.py | 24 ++++++- tests/unit_tests/test_mcp_datadog.py | 10 +++ tests/unit_tests/test_mcp_tool_utils.py | 69 +++++++++++++++++++ 11 files changed, 229 insertions(+), 20 deletions(-) diff --git a/airbyte/constants.py b/airbyte/constants.py index d40cf175a..7c6e92b3d 100644 --- a/airbyte/constants.py +++ b/airbyte/constants.py @@ -306,6 +306,9 @@ def is_hosted_mcp_mode() -> bool: MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR: str = "AIRBYTE_CLOUD_MCP_ALLOW_EXTERNAL_ACCESS" """Environment variable controlling access to external-access Cloud tools.""" +MCP_LOCAL_DEMO_MODE_ENV_VAR: str = "AIRBYTE_CLOUD_MCP_LOCAL_DEMO_MODE" +"""Environment variable enabling opt-in local demo mode for STDIO MCP sessions.""" + MCP_DOMAINS_DISABLED_ENV_VAR: str = "AIRBYTE_MCP_DOMAINS_DISABLED" """Environment variable to disable specific MCP tool domains. diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index 86e38d2c9..258bcc2d9 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -332,6 +332,13 @@ class AirbyteTrustedExecutionRequiredError(AirbyteMCPError): feature: str | None = None +class AirbyteLocalDemoModeError(AirbyteMCPError): + """A change was blocked by MCP local demo mode. + + Local demo mode only allows changes to resources created in the current session. + """ + + @dataclass class AirbytePipelineChangesDisabledError(AirbyteMCPError): """A pipeline-changing operation was blocked by MCP policy.""" diff --git a/airbyte/mcp/__init__.py b/airbyte/mcp/__init__.py index 2547a94ec..07cdc0242 100644 --- a/airbyte/mcp/__init__.py +++ b/airbyte/mcp/__init__.py @@ -130,6 +130,13 @@ Set the environment variable `AIRBYTE_CLOUD_MCP_READONLY_MODE=1` to enable read-only mode. +### Local Demo Mode + +Local demo mode is opt-in and STDIO-only: set `AIRBYTE_CLOUD_MCP_LOCAL_DEMO_MODE=1` to +enable it (it is ignored over the hosted HTTP transport). When enabled, changes and +deletions are only allowed on resources created in the current session. The `delete-me` +name guard for deletions still applies. + ## Airbyte Cloud Pipeline and External Access Permissions Pipeline changes and external access can be controlled independently. The diff --git a/airbyte/mcp/_error_handling.py b/airbyte/mcp/_error_handling.py index 5566a91cb..f428982f5 100644 --- a/airbyte/mcp/_error_handling.py +++ b/airbyte/mcp/_error_handling.py @@ -38,6 +38,7 @@ AirbyteExternalAccessDisabledError, AirbyteLibError, AirbyteLibInputError, + AirbyteLocalDemoModeError, AirbyteMCPError, AirbyteMissingResourceError, AirbytePipelineChangesDisabledError, @@ -78,6 +79,7 @@ MCP_TOOL_USER_FACING_ERRORS: tuple[type[AirbyteLibError], ...] = ( AirbyteLibInputError, AirbyteMCPError, + AirbyteLocalDemoModeError, AirbytePipelineChangesDisabledError, AirbyteExternalAccessDisabledError, AirbyteAgentsUnavailableError, diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index f4dc047a9..689373218 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -2,6 +2,8 @@ """MCP tool utility functions for config args and tool filters. This module provides: +- Local demo mode: opt-in STDIO-only tracking that restricts changes to resources + created in the current session. - Config args and filters for backward compatibility with legacy Airbyte env vars. """ @@ -36,6 +38,7 @@ ) from mcp.types import Tool +from airbyte._util.meta import is_hosted_mcp_mode from airbyte.constants import ( CLOUD_API_ROOT_ENV_VAR, CLOUD_BEARER_TOKEN_ENV_VAR, @@ -67,13 +70,14 @@ MCP_INSIDERS_ENV_VAR, MCP_INSIDERS_HEADER, MCP_INSIDERS_MODULES, + MCP_LOCAL_DEMO_MODE_ENV_VAR, MCP_ORGANIZATION_ID_HEADER, MCP_READONLY_MODE_ENV_VAR, MCP_TRUSTED_EXECUTION_ENV_VAR, MCP_WORKSPACE_ID_HEADER, _str_to_bool, ) -from airbyte.exceptions import AirbyteLibInputError +from airbyte.exceptions import AirbyteLibInputError, AirbyteLocalDemoModeError if TYPE_CHECKING: @@ -94,6 +98,50 @@ When set, the workspace_id parameter is hidden from cloud tools. """ +_GUIDS_CREATED_IN_SESSION: set[str] = set() + + +def local_demo_mode_enabled() -> bool: + """Return whether local demo mode is active for this session. + + Local demo mode is opt-in via `AIRBYTE_CLOUD_MCP_LOCAL_DEMO_MODE` and only + applies to local (STDIO) sessions; it is ignored over the hosted HTTP transport. + """ + return ( + _str_to_bool(os.environ.get(MCP_LOCAL_DEMO_MODE_ENV_VAR)) is True + and not is_hosted_mcp_mode() + ) + + +def register_guid_created_in_session(guid: str) -> None: + """Register a GUID as created in this session. + + Args: + guid: The GUID to register + """ + _GUIDS_CREATED_IN_SESSION.add(guid) + + +def check_guid_created_in_session(guid: str) -> None: + """Check if a GUID was created in this session. + + This is a no-op unless local demo mode is enabled. + + Raises `AirbyteLocalDemoModeError` if local demo mode is enabled and the GUID + was not created in this session. + + Args: + guid: The GUID to check + """ + if local_demo_mode_enabled() and guid not in _GUIDS_CREATED_IN_SESSION: + raise AirbyteLocalDemoModeError( + message=( + f"Cannot modify '{guid}': local demo mode only allows changes " + "to resources created in this session." + ), + ) + + # ============================================================================= # Backward-Compatible Config Args # ============================================================================= diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 8525ab7c9..256b536b7 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -69,6 +69,7 @@ AirbyteCloudApiError, AirbyteCloudError, AirbyteConnectorNotRegisteredError, + AirbyteDeferredSetupError, AirbyteLibError, AirbyteLibInputError, AirbyteMissingResourceError, @@ -88,7 +89,9 @@ from airbyte.mcp._scope import record_default_workspace, record_resolved_organization from airbyte.mcp._tool_utils import ( AIRBYTE_CLOUD_WORKSPACE_ID_IS_SET, + check_guid_created_in_session, mcp_tool, + register_guid_created_in_session, ) from airbyte.mcp._trace_attributes import agent_action_attributes from airbyte.mcp._user_identity import forget_cached_airbyte_user @@ -689,6 +692,7 @@ def deploy_connector_to_cloud( # noqa: PLR0913 # Mirrors the API surface. else workspace.deploy_destination(name=name, destination=connector, unique=unique) ) + register_guid_created_in_session(deployed.connector_id) return ( f"Successfully deployed {resolved_type.value} '{name}' with ID " f"'{deployed.connector_id}' and URL: {deployed.connector_url}" @@ -724,22 +728,28 @@ def _deploy_deferred_to_cloud( workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) deployed: CloudSource | CloudDestination - if connector_type == ConnectorType.SOURCE: - deployed = workspace.deploy_source( - name=name, - source=config_dict, - unique=unique, - definition_id=metadata.definition_id, - defer_credentials=True, - ) - else: - deployed = workspace.deploy_destination( - name=name, - destination=config_dict, - unique=unique, - definition_id=metadata.definition_id, - defer_credentials=True, - ) + try: + if connector_type == ConnectorType.SOURCE: + deployed = workspace.deploy_source( + name=name, + source=config_dict, + unique=unique, + definition_id=metadata.definition_id, + defer_credentials=True, + ) + else: + deployed = workspace.deploy_destination( + name=name, + destination=config_dict, + unique=unique, + definition_id=metadata.definition_id, + defer_credentials=True, + ) + except AirbyteDeferredSetupError as ex: + if ex.actor_id is not None: + register_guid_created_in_session(ex.actor_id) + raise + register_guid_created_in_session(deployed.connector_id) return DeferredDeployResult( connector_id=deployed.connector_id, connector_type=connector_type, @@ -858,6 +868,7 @@ def create_connection_on_cloud( table_prefix=table_prefix, ) + register_guid_created_in_session(deployed_connection.connection_id) result = ( f"Successfully created connection '{connection_name}' " f"with ID '{deployed_connection.connection_id}' and " @@ -946,6 +957,7 @@ def deploy_noop_destination_to_cloud( destination=destination, unique=unique, ) + register_guid_created_in_session(deployed_destination.connector_id) return ( f"Successfully deployed No-op Destination " f"with ID '{deployed_destination.connector_id}' and " @@ -1157,6 +1169,7 @@ def cancel_cloud_sync( """Cancel a running sync job on an Airbyte Cloud connection.""" workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) connection = workspace.get_connection(connection_id=connection_id) + # Deliberately omit check_guid_created_in_session: cancelling a sync is reversible. sync_result = connection.cancel_sync(job_id=job_id) return SyncJobResult( job_id=sync_result.job_id, @@ -3017,6 +3030,7 @@ def publish_custom_source_definition( pre_validate=pre_validate, testing_values=testing_values_dict, ) + register_guid_created_in_session(custom_source.definition_id) return ( "Successfully published custom YAML source definition:\n" + _get_custom_source_definition_description( @@ -3254,6 +3268,8 @@ def update_custom_source_definition( At least one of name, manifest_yaml, testing_values, or testing_values_secret_name must be provided. """ + check_guid_created_in_session(definition_id) + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) if ( @@ -3345,6 +3361,7 @@ def permanently_delete_custom_source_definition( Note: Only YAML (declarative) connectors are currently supported. Docker-based custom sources are not yet available. """ + check_guid_created_in_session(definition_id) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) definition = workspace.get_custom_source_definition( definition_id=definition_id, @@ -3410,6 +3427,7 @@ def permanently_delete_cloud_connector( The provided name must match the actual name of the connector for the operation to proceed. This is a safety measure to ensure you are deleting the correct resource. """ + check_guid_created_in_session(connector_id) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) connector = _get_cloud_connector(workspace, connector_id, connector_type) actual_name: str = cast(str, connector.name) @@ -3493,6 +3511,7 @@ def permanently_delete_cloud_connection( The provided name must match the actual name of the connection for the operation to proceed. This is a safety measure to ensure you are deleting the correct resource. """ + check_guid_created_in_session(connection_id) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) connection = workspace.get_connection(connection_id=connection_id) actual_name: str = cast(str, connection.name) @@ -3606,6 +3625,7 @@ def update_cloud_connector_config( This is a destructive operation that can break existing connections if the configuration is changed incorrectly. Use with caution. """ + check_guid_created_in_session(connector_id) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) connector = _get_typed_cloud_connector(workspace, connector_id, connector_type) @@ -3683,6 +3703,7 @@ def set_cloud_connection_table_prefix( This is a destructive operation that can break downstream dependencies if the table prefix is changed incorrectly. Use with caution. """ + check_guid_created_in_session(connection_id) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) connection = workspace.get_connection(connection_id=connection_id) connection.set_table_prefix(prefix=prefix) @@ -3726,6 +3747,7 @@ def set_cloud_connection_selected_streams( This is a destructive operation that can break existing connections if the stream selection is changed incorrectly. Use with caution. """ + check_guid_created_in_session(connection_id) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) connection = workspace.get_connection(connection_id=connection_id) @@ -3827,6 +3849,8 @@ def update_cloud_connection( At least one setting must be provided. `interval_hours`, `cron_expression`, and `manual_schedule=True` are mutually exclusive. """ + check_guid_created_in_session(connection_id) + # Validate that at least one setting is provided if ( enabled is None diff --git a/tests/unit_tests/test_deferred_cloud_credentials.py b/tests/unit_tests/test_deferred_cloud_credentials.py index 5ffe485cf..0dc492307 100644 --- a/tests/unit_tests/test_deferred_cloud_credentials.py +++ b/tests/unit_tests/test_deferred_cloud_credentials.py @@ -669,11 +669,15 @@ def test_mcp_deploy_with_deferred_credentials_returns_handoff( assert call[connector_type] == {"count": 10} -def test_mcp_deploy_deferred_propagates_unacknowledged_actor_error( +def test_mcp_deploy_deferred_registers_unacknowledged_actor_for_cleanup( workspace_like: _WorkspaceLike, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A deferral-acknowledgement failure propagates to the caller.""" + """An actor created without deferral acknowledgement is still tracked for session cleanup.""" + registered: list[str] = [] + monkeypatch.setattr( + cloud_mcp, "register_guid_created_in_session", registered.append + ) def _unacknowledged(**kwargs: Any) -> _DeployedLike: # noqa: ANN401 raise exc.AirbyteDeferredSetupError( @@ -695,6 +699,8 @@ def _unacknowledged(**kwargs: Any) -> _DeployedLike: # noqa: ANN401 defer_credentials=True, ) + assert registered == [ACTOR_ID] + def test_mcp_deploy_deferred_rejects_connector_type_mismatch( workspace_like: _WorkspaceLike, diff --git a/tests/unit_tests/test_mcp_cloud.py b/tests/unit_tests/test_mcp_cloud.py index 9cf079cea..f67f1cd0c 100644 --- a/tests/unit_tests/test_mcp_cloud.py +++ b/tests/unit_tests/test_mcp_cloud.py @@ -879,6 +879,7 @@ def fake_get_cloud_workspace( return workspace monkeypatch.setattr(cloud_mcp, "_get_cloud_workspace", fake_get_cloud_workspace) + monkeypatch.setattr(cloud_mcp, "check_guid_created_in_session", lambda _: None) result = tool( cast(Context, object()), @@ -935,6 +936,7 @@ def test_permanently_delete_cloud_connector_surfaces_library_behavior( **{deleter: delete}, ) monkeypatch.setattr(cloud_mcp, "_get_cloud_workspace", lambda *_args: workspace) + monkeypatch.setattr(cloud_mcp, "check_guid_created_in_session", lambda _: None) if in_use: with pytest.raises(AirbyteConnectorInUseError) as exc_info: @@ -1079,6 +1081,7 @@ def test_set_cloud_connection_selected_streams_surfaces_library_error( ) workspace = SimpleNamespace(get_connection=lambda **_: connection) monkeypatch.setattr(cloud_mcp, "_get_cloud_workspace", lambda *_args: workspace) + monkeypatch.setattr(cloud_mcp, "check_guid_created_in_session", lambda _: None) with pytest.raises(AirbyteLibInputError) as exc_info: cloud_mcp.set_cloud_connection_selected_streams( @@ -1149,6 +1152,10 @@ def fake_deploy(**kwargs: object) -> SimpleNamespace: monkeypatch.setattr( cloud_mcp, "resolve_connector_config", lambda **_: {"resolved": True} ) + registered: list[str] = [] + monkeypatch.setattr( + cloud_mcp, "register_guid_created_in_session", registered.append + ) result = cloud_mcp.deploy_connector_to_cloud( cast(Context, object()), name="My Connector", @@ -1166,6 +1173,7 @@ def fake_deploy(**kwargs: object) -> SimpleNamespace: assert deploy_calls[0]["name"] == "My Connector" assert deploy_calls[0]["unique"] is True assert deploy_calls[0][connector_type.value] is connector + assert registered == ["deployed-id"] assert f"deployed {connector_type.value} 'My Connector'" in result assert "deployed-id" in result @@ -1241,6 +1249,9 @@ def _patch_deploy_connection_workspace( ) -> _DeployConnectionWorkspace: workspace = _DeployConnectionWorkspace(missing_ids=missing_ids) monkeypatch.setattr(cloud_mcp, "_get_cloud_workspace", lambda *_: workspace) + monkeypatch.setattr( + cloud_mcp, "register_guid_created_in_session", lambda *args: None + ) return workspace diff --git a/tests/unit_tests/test_mcp_connection_schedules.py b/tests/unit_tests/test_mcp_connection_schedules.py index 376720a0c..d1849040e 100644 --- a/tests/unit_tests/test_mcp_connection_schedules.py +++ b/tests/unit_tests/test_mcp_connection_schedules.py @@ -18,7 +18,10 @@ from airbyte._util import api_util from airbyte.cloud.workspaces import CloudWorkspace -from airbyte.constants import MCP_READONLY_MODE_ENV_VAR +from airbyte.constants import ( + MCP_LOCAL_DEMO_MODE_ENV_VAR, + MCP_READONLY_MODE_ENV_VAR, +) from airbyte.exceptions import AirbyteLibInputError from airbyte.mcp import _tool_utils from airbyte.mcp import cloud as cloud_mcp @@ -63,6 +66,7 @@ def schedule_backend( ) patch = MagicMock(return_value=_connection_response()) monkeypatch.setattr(api_util, "patch_connection", patch) + monkeypatch.delenv(MCP_LOCAL_DEMO_MODE_ENV_VAR, raising=False) with responses.RequestsMock(assert_all_requests_are_fired=False) as http: http.post( "https://config.example/custom/web_backend/connections/update", @@ -267,6 +271,24 @@ def test_update_existing_settings_remain_supported( assert _connection_api_calls(http) == [] +def test_update_interval_obeys_local_demo_mode( + schedule_backend: tuple[FastMCP, responses.RequestsMock, MagicMock, MagicMock], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Local demo mode blocks interval updates to connections outside the current session.""" + server, http, lookup, patch = schedule_backend + monkeypatch.setenv(MCP_LOCAL_DEMO_MODE_ENV_VAR, "1") + monkeypatch.setattr(_tool_utils, "is_hosted_mcp_mode", lambda: False) + monkeypatch.setattr(_tool_utils, "_GUIDS_CREATED_IN_SESSION", set()) + + with pytest.raises(ToolError, match="local demo mode only allows changes"): + asyncio.run(_update(server, interval_hours=24)) + + lookup.assert_not_called() + patch.assert_not_called() + assert _connection_api_calls(http) == [] + + def test_update_remains_hidden_in_readonly_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit_tests/test_mcp_datadog.py b/tests/unit_tests/test_mcp_datadog.py index d0da3f2ee..0e8d27d4e 100644 --- a/tests/unit_tests/test_mcp_datadog.py +++ b/tests/unit_tests/test_mcp_datadog.py @@ -21,6 +21,7 @@ AirbyteCloudApiError, AirbyteConnectionSyncError, AirbyteConnectionSyncTimeoutError, + AirbyteLocalDemoModeError, ) from airbyte.mcp import _datadog, _telemetry from airbyte.version import get_version @@ -444,6 +445,15 @@ def test_cloud_error_trace_message_ignores_missing_resource_without_http_error() }, id="config-api-error-response-text", ), + pytest.param( + AirbyteLocalDemoModeError(message="SENTINEL message"), + { + "airbyte.mcp.error_type": "AirbyteLocalDemoModeError", + "airbyte.mcp.error.category": "user_error", + "airbyte.mcp.error.fault": "caller", + }, + id="user-facing-error", + ), pytest.param( AirbyteConnectionSyncError( connection_id="SENTINEL", job_id=1, job_status="failed" diff --git a/tests/unit_tests/test_mcp_tool_utils.py b/tests/unit_tests/test_mcp_tool_utils.py index e4324d7fa..59440837f 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -9,6 +9,8 @@ import pytest from fastmcp_extensions import MCPServerConfigArg +from airbyte.constants import MCP_LOCAL_DEMO_MODE_ENV_VAR +from airbyte.exceptions import AirbyteLocalDemoModeError from airbyte.mcp._tool_utils import ( API_URL_CONFIG_ARG, ALLOW_EXTERNAL_ACCESS_CONFIG_ARG, @@ -17,10 +19,13 @@ CLIENT_SECRET_CONFIG_ARG, CONFIG_API_URL_CONFIG_ARG, TRUSTED_EXECUTION_CONFIG_ARG, + _GUIDS_CREATED_IN_SESSION, _TOOL_POLICIES, ToolPolicy, _resolve_transport_bearer_token, + check_guid_created_in_session, mcp_tool, + register_guid_created_in_session, ) from airbyte.mcp import server as server_module @@ -95,6 +100,70 @@ def test_resolve_transport_bearer_token( mock_get_http_headers.assert_called_once_with(include={"authorization"}) +@pytest.fixture(autouse=True) +def clear_session_guids() -> None: + """Clear the session GUIDs before each test.""" + _GUIDS_CREATED_IN_SESSION.clear() + + +def test_register_guid_created_in_session() -> None: + """Test that GUIDs can be registered as created in session.""" + assert "test-guid-123" not in _GUIDS_CREATED_IN_SESSION + register_guid_created_in_session("test-guid-123") + assert "test-guid-123" in _GUIDS_CREATED_IN_SESSION + + +def test_check_guid_created_in_session_passes_when_demo_mode_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that check passes for any GUID when local demo mode is unset.""" + monkeypatch.delenv(MCP_LOCAL_DEMO_MODE_ENV_VAR, raising=False) + # Should not raise even for unregistered GUID + check_guid_created_in_session("any-guid-at-all") + + +def test_check_guid_created_in_session_local_demo_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that local demo mode blocks unregistered GUIDs and allows registered ones.""" + monkeypatch.setenv(MCP_LOCAL_DEMO_MODE_ENV_VAR, "1") + monkeypatch.setattr("airbyte.mcp._tool_utils.is_hosted_mcp_mode", lambda: False) + register_guid_created_in_session("registered-guid") + # Should not raise for a GUID registered in this session + check_guid_created_in_session("registered-guid") + + with pytest.raises(AirbyteLocalDemoModeError) as exc_info: + check_guid_created_in_session("unregistered-guid") + assert "unregistered-guid" in str(exc_info.value) + + +def test_check_guid_created_in_session_passes_when_hosted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that local demo mode is ignored over the hosted HTTP transport.""" + monkeypatch.setenv(MCP_LOCAL_DEMO_MODE_ENV_VAR, "1") + monkeypatch.setattr("airbyte.mcp._tool_utils.is_hosted_mcp_mode", lambda: True) + # Should not raise even for unregistered GUID + check_guid_created_in_session("any-guid-at-all") + + +def test_multiple_guids_can_be_registered() -> None: + """Test that multiple GUIDs can be registered in the same session.""" + guids = ["guid-1", "guid-2", "guid-3"] + for guid in guids: + register_guid_created_in_session(guid) + + for guid in guids: + assert guid in _GUIDS_CREATED_IN_SESSION + + +def test_duplicate_guid_registration_is_idempotent() -> None: + """Test that registering the same GUID multiple times is safe.""" + register_guid_created_in_session("duplicate-guid") + register_guid_created_in_session("duplicate-guid") + assert "duplicate-guid" in _GUIDS_CREATED_IN_SESSION + + @pytest.mark.parametrize( "config_arg", [ From 7f132ff89e61c3504f9800c6f2839677aacf07a3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:55:45 +0000 Subject: [PATCH 3/8] docs(mcp): clarify local demo mode scope Co-Authored-By: AJ Steers --- airbyte/mcp/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/airbyte/mcp/__init__.py b/airbyte/mcp/__init__.py index 07cdc0242..47fb213c4 100644 --- a/airbyte/mcp/__init__.py +++ b/airbyte/mcp/__init__.py @@ -133,9 +133,9 @@ ### Local Demo Mode Local demo mode is opt-in and STDIO-only: set `AIRBYTE_CLOUD_MCP_LOCAL_DEMO_MODE=1` to -enable it (it is ignored over the hosted HTTP transport). When enabled, changes and -deletions are only allowed on resources created in the current session. The `delete-me` -name guard for deletions still applies. +enable it (it is ignored over the hosted HTTP transport). When enabled, updates and +deletions are only allowed on resources created in the current session; renames are +still allowed. The `delete-me` name guard for deletions still applies. ## Airbyte Cloud Pipeline and External Access Permissions From a2fbf8c48ab588cca5e1e13d737e1009142be6de Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:04:23 +0000 Subject: [PATCH 4/8] feat(mcp): fail fast on legacy safe mode env var and header Co-Authored-By: AJ Steers --- airbyte/constants.py | 6 ++ airbyte/mcp/_policy_middleware.py | 17 ++++- airbyte/mcp/_tool_utils.py | 55 +++++++++++++++ airbyte/mcp/http_main.py | 2 + airbyte/mcp/server.py | 2 + .../unit_tests/test_mcp_policy_middleware.py | 25 +++++++ tests/unit_tests/test_mcp_tool_utils.py | 69 ++++++++++++++++++- 7 files changed, 172 insertions(+), 4 deletions(-) diff --git a/airbyte/constants.py b/airbyte/constants.py index 7c6e92b3d..13f3f3279 100644 --- a/airbyte/constants.py +++ b/airbyte/constants.py @@ -309,6 +309,12 @@ def is_hosted_mcp_mode() -> bool: MCP_LOCAL_DEMO_MODE_ENV_VAR: str = "AIRBYTE_CLOUD_MCP_LOCAL_DEMO_MODE" """Environment variable enabling opt-in local demo mode for STDIO MCP sessions.""" +CLOUD_MCP_SAFE_MODE_ENV_VAR: str = "AIRBYTE_CLOUD_MCP_SAFE_MODE" +"""Removed legacy env var; setting it fails MCP server startup.""" + +MCP_LEGACY_SAFE_MODE_HEADER: str = "X-MCP-Safe-Mode" +"""Removed legacy header; sending it fails the request.""" + MCP_DOMAINS_DISABLED_ENV_VAR: str = "AIRBYTE_MCP_DOMAINS_DISABLED" """Environment variable to disable specific MCP tool domains. diff --git a/airbyte/mcp/_policy_middleware.py b/airbyte/mcp/_policy_middleware.py index 9bc152d41..1c7486f73 100644 --- a/airbyte/mcp/_policy_middleware.py +++ b/airbyte/mcp/_policy_middleware.py @@ -12,6 +12,7 @@ AirbytePipelineChangesDisabledError, ) from airbyte.mcp._tool_utils import ( + check_legacy_safe_mode_header, external_access_allowed, get_tool_policy, pipeline_changes_allowed, @@ -19,9 +20,11 @@ if TYPE_CHECKING: + from collections.abc import Sequence + from fastmcp.server.middleware import CallNext, MiddlewareContext - from fastmcp.tools import ToolResult - from mcp.types import CallToolRequestParams + from fastmcp.tools import Tool, ToolResult + from mcp.types import CallToolRequestParams, ListToolsRequest class PolicyGuardMiddleware(Middleware): @@ -33,6 +36,7 @@ async def on_call_tool( call_next: CallNext[CallToolRequestParams, ToolResult], ) -> ToolResult: """Reject calls denied by effective pipeline or external-access settings.""" + check_legacy_safe_mode_header() fastmcp_context = context.fastmcp_context if fastmcp_context is None: return await call_next(context) @@ -51,3 +55,12 @@ async def on_call_tool( message="External-access tools are disabled by the MCP server policy." ) return await call_next(context) + + async def on_list_tools( + self, + context: MiddlewareContext[ListToolsRequest], + call_next: CallNext[ListToolsRequest, Sequence[Tool]], + ) -> Sequence[Tool]: + """Reject listings when the removed `X-MCP-Safe-Mode` header is present.""" + check_legacy_safe_mode_header() + return await call_next(context) diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index 689373218..4e6c0d754 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -45,6 +45,7 @@ CLOUD_CLIENT_ID_ENV_VAR, CLOUD_CLIENT_SECRET_ENV_VAR, CLOUD_CONFIG_API_ROOT_ENV_VAR, + CLOUD_MCP_SAFE_MODE_ENV_VAR, CLOUD_ORGANIZATION_ID_ENV_VAR, CLOUD_WORKSPACE_ID_ENV_VAR, MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, @@ -70,6 +71,7 @@ MCP_INSIDERS_ENV_VAR, MCP_INSIDERS_HEADER, MCP_INSIDERS_MODULES, + MCP_LEGACY_SAFE_MODE_HEADER, MCP_LOCAL_DEMO_MODE_ENV_VAR, MCP_ORGANIZATION_ID_HEADER, MCP_READONLY_MODE_ENV_VAR, @@ -142,6 +144,59 @@ def check_guid_created_in_session(guid: str) -> None: ) +def check_legacy_safe_mode_env() -> None: + """Fail fast if the removed `AIRBYTE_CLOUD_MCP_SAFE_MODE` env var is set. + + Raises `AirbyteLibInputError` if the variable is present in the environment + with any value, including an empty string. + """ + if CLOUD_MCP_SAFE_MODE_ENV_VAR not in os.environ: + return + demo_mode_line = ( + "" + if is_hosted_mcp_mode() + else ( + "- `AIRBYTE_CLOUD_MCP_LOCAL_DEMO_MODE=1`: only allow changes to " + "resources created in this session (not recommended except for demos).\n" + ) + ) + raise AirbyteLibInputError( + message="`AIRBYTE_CLOUD_MCP_SAFE_MODE` is no longer supported.", + guidance=( + "- `AIRBYTE_CLOUD_MCP_ALLOW_PIPELINE_CHANGES=0`: block changes to " + "sources, destinations, and connections.\n" + "- `AIRBYTE_CLOUD_MCP_ALLOW_EXTERNAL_ACCESS=0`: block SQL and API " + "passthrough tools.\n" + "- `AIRBYTE_CLOUD_MCP_READONLY_MODE=1`: only expose read-only tools.\n" + "- `MCP_NO_DESTRUCTIVE_TOOLS=1`: hide destructive tools.\n" + f"{demo_mode_line}" + "Remove `AIRBYTE_CLOUD_MCP_SAFE_MODE` to start the server." + ), + ) + + +def check_legacy_safe_mode_header() -> None: + """Fail fast if the removed `X-MCP-Safe-Mode` request header is present. + + Raises `AirbyteLibInputError` if the header is present with any value. + Outside an HTTP request there are no headers, so this is a no-op. + """ + headers = get_http_headers() + if MCP_LEGACY_SAFE_MODE_HEADER.lower() not in headers: + return + raise AirbyteLibInputError( + message="The `X-MCP-Safe-Mode` header is no longer supported.", + guidance=( + "- `X-MCP-Allow-Pipeline-Changes: 0`: block changes to sources, " + "destinations, and connections.\n" + "- `X-MCP-Allow-External-Access: 0`: block SQL and API passthrough tools.\n" + "- `X-MCP-Readonly-Mode: 1`: only expose read-only tools.\n" + "- `X-MCP-No-Destructive-Tools: 1`: hide destructive tools.\n" + "Remove the `X-MCP-Safe-Mode` header." + ), + ) + + # ============================================================================= # Backward-Compatible Config Args # ============================================================================= diff --git a/airbyte/mcp/http_main.py b/airbyte/mcp/http_main.py index 100cc1b22..c48176776 100644 --- a/airbyte/mcp/http_main.py +++ b/airbyte/mcp/http_main.py @@ -163,6 +163,7 @@ resolve_log_format, ) from airbyte.mcp._telemetry import McpRequestTelemetryMiddleware +from airbyte.mcp._tool_utils import check_legacy_safe_mode_env from airbyte.mcp._transport_security import ( HTTP_HOST_ENV, HostOriginGuardMiddleware, @@ -291,6 +292,7 @@ def main() -> None: log_format = resolve_log_format() uvicorn_config = configure_logging(log_format) set_hosted_mcp_mode() + check_legacy_safe_mode_env() app.instructions = build_mcp_server_instructions(hosted=True) install(app) if log_format == "json": diff --git a/airbyte/mcp/server.py b/airbyte/mcp/server.py index a14c689bd..e91932e08 100644 --- a/airbyte/mcp/server.py +++ b/airbyte/mcp/server.py @@ -108,6 +108,7 @@ airbyte_external_access_filter, airbyte_module_filter, airbyte_readonly_mode_filter, + check_legacy_safe_mode_env, validate_airbyte_domains, ) from airbyte.mcp._user_identity import ( @@ -612,6 +613,7 @@ def main() -> None: It should not be called directly; instead, consult the MCP client documentation for instructions on how to connect to the server. """ + check_legacy_safe_mode_env() print("Starting Airbyte MCP server.", file=sys.stderr) try: asyncio.run(app.run_stdio_async()) diff --git a/tests/unit_tests/test_mcp_policy_middleware.py b/tests/unit_tests/test_mcp_policy_middleware.py index 236a2a27d..b272ad2ba 100644 --- a/tests/unit_tests/test_mcp_policy_middleware.py +++ b/tests/unit_tests/test_mcp_policy_middleware.py @@ -19,6 +19,7 @@ ) from airbyte.exceptions import ( AirbyteExternalAccessDisabledError, + AirbyteLibInputError, AirbytePipelineChangesDisabledError, ) from airbyte.mcp import _tool_utils @@ -176,6 +177,30 @@ async def invoke() -> str: asyncio.run(invoke()) +def test_legacy_safe_mode_header_blocks_list_and_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The removed `X-MCP-Safe-Mode` header fails both list_tools and call_tool.""" + monkeypatch.setattr( + _tool_utils, + "get_http_headers", + lambda **_: {"x-mcp-safe-mode": "1"}, + ) + middleware = PolicyGuardMiddleware() + context = SimpleNamespace( + fastmcp_context=None, + message=SimpleNamespace(name="any-tool"), + ) + + async def call_next(_context: Any) -> str: + return "called" + + with pytest.raises(AirbyteLibInputError, match="X-MCP-Safe-Mode"): + asyncio.run(middleware.on_call_tool(context, call_next)) + with pytest.raises(AirbyteLibInputError, match="X-MCP-Safe-Mode"): + asyncio.run(middleware.on_list_tools(context, call_next)) + + def test_blocked_policy_error_is_formatted_for_mcp_client( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit_tests/test_mcp_tool_utils.py b/tests/unit_tests/test_mcp_tool_utils.py index 59440837f..c2065f56e 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -9,8 +9,11 @@ import pytest from fastmcp_extensions import MCPServerConfigArg -from airbyte.constants import MCP_LOCAL_DEMO_MODE_ENV_VAR -from airbyte.exceptions import AirbyteLocalDemoModeError +from airbyte.constants import ( + CLOUD_MCP_SAFE_MODE_ENV_VAR, + MCP_LOCAL_DEMO_MODE_ENV_VAR, +) +from airbyte.exceptions import AirbyteLibInputError, AirbyteLocalDemoModeError from airbyte.mcp._tool_utils import ( API_URL_CONFIG_ARG, ALLOW_EXTERNAL_ACCESS_CONFIG_ARG, @@ -24,6 +27,8 @@ ToolPolicy, _resolve_transport_bearer_token, check_guid_created_in_session, + check_legacy_safe_mode_env, + check_legacy_safe_mode_header, mcp_tool, register_guid_created_in_session, ) @@ -147,6 +152,66 @@ def test_check_guid_created_in_session_passes_when_hosted( check_guid_created_in_session("any-guid-at-all") +def test_check_legacy_safe_mode_env_passes_when_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that startup check passes when the legacy env var is unset.""" + monkeypatch.delenv(CLOUD_MCP_SAFE_MODE_ENV_VAR, raising=False) + check_legacy_safe_mode_env() + + +@pytest.mark.parametrize("value", ["1", "0", ""]) +def test_check_legacy_safe_mode_env_raises_on_stdio( + monkeypatch: pytest.MonkeyPatch, + value: str, +) -> None: + """Test that any value of the legacy env var fails startup on STDIO.""" + monkeypatch.setenv(CLOUD_MCP_SAFE_MODE_ENV_VAR, value) + monkeypatch.setattr("airbyte.mcp._tool_utils.is_hosted_mcp_mode", lambda: False) + with pytest.raises(AirbyteLibInputError) as exc_info: + check_legacy_safe_mode_env() + assert "no longer supported" in str(exc_info.value) + guidance = exc_info.value.guidance or "" + bullets = [line for line in guidance.splitlines() if line.startswith("- ")] + assert "AIRBYTE_CLOUD_MCP_LOCAL_DEMO_MODE" in bullets[-1] + assert "not recommended except for demos" in bullets[-1] + + +def test_check_legacy_safe_mode_env_raises_when_hosted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that the hosted guidance omits the STDIO-only demo mode bullet.""" + monkeypatch.setenv(CLOUD_MCP_SAFE_MODE_ENV_VAR, "1") + monkeypatch.setattr("airbyte.mcp._tool_utils.is_hosted_mcp_mode", lambda: True) + with pytest.raises(AirbyteLibInputError) as exc_info: + check_legacy_safe_mode_env() + assert "DEMO" not in (exc_info.value.guidance or "") + + +def test_check_legacy_safe_mode_header_passes_when_absent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that the header check passes when the legacy header is absent.""" + monkeypatch.setattr("airbyte.mcp._tool_utils.get_http_headers", lambda **_: {}) + check_legacy_safe_mode_header() + + +def test_check_legacy_safe_mode_header_raises_when_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that the removed header fails the request with migration guidance.""" + monkeypatch.setattr( + "airbyte.mcp._tool_utils.get_http_headers", + lambda **_: {"x-mcp-safe-mode": "1"}, + ) + with pytest.raises(AirbyteLibInputError) as exc_info: + check_legacy_safe_mode_header() + assert "X-MCP-Safe-Mode" in str(exc_info.value) + guidance = exc_info.value.guidance or "" + assert "X-MCP-Allow-Pipeline-Changes" in guidance + assert "DEMO" not in guidance + + def test_multiple_guids_can_be_registered() -> None: """Test that multiple GUIDs can be registered in the same session.""" guids = ["guid-1", "guid-2", "guid-3"] From af4731c763029569d5a585ddd4a9ae7ee17f35d8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:07:06 +0000 Subject: [PATCH 5/8] refactor(mcp): rename legacy env guard to check_if_demo_mode_guards Co-Authored-By: AJ Steers --- airbyte/mcp/_tool_utils.py | 2 +- airbyte/mcp/http_main.py | 4 ++-- airbyte/mcp/server.py | 4 ++-- tests/unit_tests/test_mcp_tool_utils.py | 14 +++++++------- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index 4e6c0d754..3c301844c 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -144,7 +144,7 @@ def check_guid_created_in_session(guid: str) -> None: ) -def check_legacy_safe_mode_env() -> None: +def check_if_demo_mode_guards() -> None: """Fail fast if the removed `AIRBYTE_CLOUD_MCP_SAFE_MODE` env var is set. Raises `AirbyteLibInputError` if the variable is present in the environment diff --git a/airbyte/mcp/http_main.py b/airbyte/mcp/http_main.py index c48176776..918da85cb 100644 --- a/airbyte/mcp/http_main.py +++ b/airbyte/mcp/http_main.py @@ -163,7 +163,7 @@ resolve_log_format, ) from airbyte.mcp._telemetry import McpRequestTelemetryMiddleware -from airbyte.mcp._tool_utils import check_legacy_safe_mode_env +from airbyte.mcp._tool_utils import check_if_demo_mode_guards from airbyte.mcp._transport_security import ( HTTP_HOST_ENV, HostOriginGuardMiddleware, @@ -292,7 +292,7 @@ def main() -> None: log_format = resolve_log_format() uvicorn_config = configure_logging(log_format) set_hosted_mcp_mode() - check_legacy_safe_mode_env() + check_if_demo_mode_guards() app.instructions = build_mcp_server_instructions(hosted=True) install(app) if log_format == "json": diff --git a/airbyte/mcp/server.py b/airbyte/mcp/server.py index e91932e08..dadb8304d 100644 --- a/airbyte/mcp/server.py +++ b/airbyte/mcp/server.py @@ -108,7 +108,7 @@ airbyte_external_access_filter, airbyte_module_filter, airbyte_readonly_mode_filter, - check_legacy_safe_mode_env, + check_if_demo_mode_guards, validate_airbyte_domains, ) from airbyte.mcp._user_identity import ( @@ -613,7 +613,7 @@ def main() -> None: It should not be called directly; instead, consult the MCP client documentation for instructions on how to connect to the server. """ - check_legacy_safe_mode_env() + check_if_demo_mode_guards() print("Starting Airbyte MCP server.", file=sys.stderr) try: asyncio.run(app.run_stdio_async()) diff --git a/tests/unit_tests/test_mcp_tool_utils.py b/tests/unit_tests/test_mcp_tool_utils.py index c2065f56e..046bd794e 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -27,7 +27,7 @@ ToolPolicy, _resolve_transport_bearer_token, check_guid_created_in_session, - check_legacy_safe_mode_env, + check_if_demo_mode_guards, check_legacy_safe_mode_header, mcp_tool, register_guid_created_in_session, @@ -152,16 +152,16 @@ def test_check_guid_created_in_session_passes_when_hosted( check_guid_created_in_session("any-guid-at-all") -def test_check_legacy_safe_mode_env_passes_when_unset( +def test_check_if_demo_mode_guards_passes_when_unset( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test that startup check passes when the legacy env var is unset.""" monkeypatch.delenv(CLOUD_MCP_SAFE_MODE_ENV_VAR, raising=False) - check_legacy_safe_mode_env() + check_if_demo_mode_guards() @pytest.mark.parametrize("value", ["1", "0", ""]) -def test_check_legacy_safe_mode_env_raises_on_stdio( +def test_check_if_demo_mode_guards_raises_on_stdio( monkeypatch: pytest.MonkeyPatch, value: str, ) -> None: @@ -169,7 +169,7 @@ def test_check_legacy_safe_mode_env_raises_on_stdio( monkeypatch.setenv(CLOUD_MCP_SAFE_MODE_ENV_VAR, value) monkeypatch.setattr("airbyte.mcp._tool_utils.is_hosted_mcp_mode", lambda: False) with pytest.raises(AirbyteLibInputError) as exc_info: - check_legacy_safe_mode_env() + check_if_demo_mode_guards() assert "no longer supported" in str(exc_info.value) guidance = exc_info.value.guidance or "" bullets = [line for line in guidance.splitlines() if line.startswith("- ")] @@ -177,14 +177,14 @@ def test_check_legacy_safe_mode_env_raises_on_stdio( assert "not recommended except for demos" in bullets[-1] -def test_check_legacy_safe_mode_env_raises_when_hosted( +def test_check_if_demo_mode_guards_raises_when_hosted( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test that the hosted guidance omits the STDIO-only demo mode bullet.""" monkeypatch.setenv(CLOUD_MCP_SAFE_MODE_ENV_VAR, "1") monkeypatch.setattr("airbyte.mcp._tool_utils.is_hosted_mcp_mode", lambda: True) with pytest.raises(AirbyteLibInputError) as exc_info: - check_legacy_safe_mode_env() + check_if_demo_mode_guards() assert "DEMO" not in (exc_info.value.guidance or "") From 72bf38ed84236b2ca959661c43682fc0bcf4d52d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:11:04 +0000 Subject: [PATCH 6/8] docs(mcp): stop calling read-only mode legacy Co-Authored-By: AJ Steers --- airbyte/constants.py | 4 ++-- airbyte/exceptions.py | 4 ++-- airbyte/mcp/__init__.py | 2 +- airbyte/mcp/_tool_utils.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/airbyte/constants.py b/airbyte/constants.py index 13f3f3279..72fe7c9f5 100644 --- a/airbyte/constants.py +++ b/airbyte/constants.py @@ -298,7 +298,7 @@ def is_hosted_mcp_mode() -> bool: MCP_READONLY_MODE_ENV_VAR: str = "AIRBYTE_CLOUD_MCP_READONLY_MODE" -"""Legacy environment variable that disables pipeline-changing MCP tools when truthy.""" +"""Environment variable that disables pipeline-changing MCP tools when truthy.""" MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR: str = "AIRBYTE_CLOUD_MCP_ALLOW_PIPELINE_CHANGES" """Environment variable controlling access to Cloud pipeline-changing tools.""" @@ -382,7 +382,7 @@ def is_hosted_mcp_mode() -> bool: # MCP Config Arg Names (used with get_mcp_config) MCP_CONFIG_READONLY_MODE: str = "airbyte_readonly_mode" -"""Config arg name for the legacy AIRBYTE_CLOUD_MCP_READONLY_MODE setting.""" +"""Config arg name for the AIRBYTE_CLOUD_MCP_READONLY_MODE setting.""" MCP_CONFIG_ALLOW_PIPELINE_CHANGES: str = "allow_pipeline_changes" """Config arg name for the Cloud pipeline-change permission.""" diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index 258bcc2d9..cc1848048 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -344,7 +344,7 @@ class AirbytePipelineChangesDisabledError(AirbyteMCPError): """A pipeline-changing operation was blocked by MCP policy.""" guidance: str | None = ( - "Set `AIRBYTE_CLOUD_MCP_ALLOW_PIPELINE_CHANGES=1`, unset legacy " + "Set `AIRBYTE_CLOUD_MCP_ALLOW_PIPELINE_CHANGES=1`, unset " "`AIRBYTE_CLOUD_MCP_READONLY_MODE`, and ensure the " "`X-MCP-Allow-Pipeline-Changes` request header is not `0`." ) @@ -357,7 +357,7 @@ class AirbyteExternalAccessDisabledError(AirbyteMCPError): guidance: str | None = ( "Set `AIRBYTE_CLOUD_MCP_ALLOW_EXTERNAL_ACCESS=1` and ensure the " "`X-MCP-Allow-External-Access` request header is not `0`. Disabled pipeline changes " - "(including legacy read-only mode) also disable external access when this setting is unset." + "(including read-only mode) also disable external access when this setting is unset." ) diff --git a/airbyte/mcp/__init__.py b/airbyte/mcp/__init__.py index 47fb213c4..105c56668 100644 --- a/airbyte/mcp/__init__.py +++ b/airbyte/mcp/__init__.py @@ -150,7 +150,7 @@ Each setting accepts `1`, `true`, or `yes` to allow, and `0`, `false`, or `no` to deny. An explicit environment denial cannot be overridden by a request header. An environment allow can be narrowed by a request header denial. If the environment setting is unset, the request header -decides. The legacy `AIRBYTE_CLOUD_MCP_READONLY_MODE=1` also disables pipeline changes. +decides. `AIRBYTE_CLOUD_MCP_READONLY_MODE=1` also disables pipeline changes. When external access is unset, it is disabled if pipeline changes are disabled; otherwise external-access tools remain available. Explicitly allowing external access diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index 3c301844c..953b5e6a6 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -211,7 +211,7 @@ def check_legacy_safe_mode_header() -> None: default="0", required=False, ) -"""Config arg for legacy AIRBYTE_CLOUD_MCP_READONLY_MODE env var.""" +"""Config arg for the AIRBYTE_CLOUD_MCP_READONLY_MODE env var.""" AIRBYTE_EXCLUDE_MODULES_CONFIG_ARG = MCPServerConfigArg( name=MCP_CONFIG_EXCLUDE_MODULES, From 149e7459450bf34840a74ffe59beb69d77476211 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:11:35 +0000 Subject: [PATCH 7/8] feat(mcp): open legacy safe mode guidance with the removal step Co-Authored-By: AJ Steers --- airbyte/mcp/_tool_utils.py | 6 ++++-- tests/unit_tests/test_mcp_tool_utils.py | 12 ++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index 953b5e6a6..85aee6c4d 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -163,6 +163,8 @@ def check_if_demo_mode_guards() -> None: raise AirbyteLibInputError( message="`AIRBYTE_CLOUD_MCP_SAFE_MODE` is no longer supported.", guidance=( + "Remove `AIRBYTE_CLOUD_MCP_SAFE_MODE` and consider using one of these " + "alternatives:\n" "- `AIRBYTE_CLOUD_MCP_ALLOW_PIPELINE_CHANGES=0`: block changes to " "sources, destinations, and connections.\n" "- `AIRBYTE_CLOUD_MCP_ALLOW_EXTERNAL_ACCESS=0`: block SQL and API " @@ -170,7 +172,6 @@ def check_if_demo_mode_guards() -> None: "- `AIRBYTE_CLOUD_MCP_READONLY_MODE=1`: only expose read-only tools.\n" "- `MCP_NO_DESTRUCTIVE_TOOLS=1`: hide destructive tools.\n" f"{demo_mode_line}" - "Remove `AIRBYTE_CLOUD_MCP_SAFE_MODE` to start the server." ), ) @@ -187,12 +188,13 @@ def check_legacy_safe_mode_header() -> None: raise AirbyteLibInputError( message="The `X-MCP-Safe-Mode` header is no longer supported.", guidance=( + "Remove the `X-MCP-Safe-Mode` header and consider using one of these " + "alternatives:\n" "- `X-MCP-Allow-Pipeline-Changes: 0`: block changes to sources, " "destinations, and connections.\n" "- `X-MCP-Allow-External-Access: 0`: block SQL and API passthrough tools.\n" "- `X-MCP-Readonly-Mode: 1`: only expose read-only tools.\n" "- `X-MCP-No-Destructive-Tools: 1`: hide destructive tools.\n" - "Remove the `X-MCP-Safe-Mode` header." ), ) diff --git a/tests/unit_tests/test_mcp_tool_utils.py b/tests/unit_tests/test_mcp_tool_utils.py index 046bd794e..7b593c707 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -171,7 +171,11 @@ def test_check_if_demo_mode_guards_raises_on_stdio( with pytest.raises(AirbyteLibInputError) as exc_info: check_if_demo_mode_guards() assert "no longer supported" in str(exc_info.value) - guidance = exc_info.value.guidance or "" + guidance = exc_info.value.guidance + assert isinstance(guidance, str) + assert guidance.startswith( + "Remove `AIRBYTE_CLOUD_MCP_SAFE_MODE` and consider using one of these alternatives:\n" + ) bullets = [line for line in guidance.splitlines() if line.startswith("- ")] assert "AIRBYTE_CLOUD_MCP_LOCAL_DEMO_MODE" in bullets[-1] assert "not recommended except for demos" in bullets[-1] @@ -207,7 +211,11 @@ def test_check_legacy_safe_mode_header_raises_when_present( with pytest.raises(AirbyteLibInputError) as exc_info: check_legacy_safe_mode_header() assert "X-MCP-Safe-Mode" in str(exc_info.value) - guidance = exc_info.value.guidance or "" + guidance = exc_info.value.guidance + assert isinstance(guidance, str) + assert guidance.startswith( + "Remove the `X-MCP-Safe-Mode` header and consider using one of these alternatives:\n" + ) assert "X-MCP-Allow-Pipeline-Changes" in guidance assert "DEMO" not in guidance From 8e5943ff42d8313943616d2fd60818d9808de4fc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:13:48 +0000 Subject: [PATCH 8/8] docs(mcp): clarify external_access_allowed docstring Co-Authored-By: AJ Steers --- airbyte/mcp/_tool_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index 85aee6c4d..d0e01ca2a 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -578,8 +578,8 @@ def pipeline_changes_allowed(app_or_ctx: FastMCP | Context) -> bool | None: def external_access_allowed(app_or_ctx: FastMCP | Context) -> bool | None: """Resolve explicit permission first, then derive the external-access default. - Disabled pipeline changes disable external access when no explicit permission - is configured; otherwise the result remains unset. + If external access is not explicitly configured, it is denied when pipeline + changes are disabled and left unset otherwise. """ explicit_value = _resolve_policy( app_or_ctx,