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..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.""" @@ -306,8 +306,15 @@ 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.""" + CLOUD_MCP_SAFE_MODE_ENV_VAR: str = "AIRBYTE_CLOUD_MCP_SAFE_MODE" -"""Environment variable controlling safe mode for destructive Cloud operations.""" +"""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. @@ -375,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 c45575f73..cc1848048 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -332,10 +332,10 @@ class AirbyteTrustedExecutionRequiredError(AirbyteMCPError): feature: str | None = None -class AirbyteSafeModeError(AirbyteMCPError): - """A destructive operation was blocked by MCP safe mode. +class AirbyteLocalDemoModeError(AirbyteMCPError): + """A change was blocked by MCP local demo mode. - Safe mode only allows destructive operations on objects created in the current session. + Local demo mode only allows changes to resources created in the current session. """ @@ -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,8 +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) and an explicit `AIRBYTE_CLOUD_MCP_SAFE_MODE=1` " - "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 077aebe58..105c56668 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 @@ -148,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, 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 Pipeline changes and external access can be controlled independently. The @@ -161,12 +150,11 @@ 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 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..f428982f5 100644 --- a/airbyte/mcp/_error_handling.py +++ b/airbyte/mcp/_error_handling.py @@ -38,10 +38,10 @@ AirbyteExternalAccessDisabledError, AirbyteLibError, AirbyteLibInputError, + AirbyteLocalDemoModeError, AirbyteMCPError, AirbyteMissingResourceError, AirbytePipelineChangesDisabledError, - AirbyteSafeModeError, ) @@ -79,7 +79,7 @@ MCP_TOOL_USER_FACING_ERRORS: tuple[type[AirbyteLibError], ...] = ( AirbyteLibInputError, AirbyteMCPError, - AirbyteSafeModeError, + AirbyteLocalDemoModeError, AirbytePipelineChangesDisabledError, AirbyteExternalAccessDisabledError, AirbyteAgentsUnavailableError, 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 fe22eb23b..d0e01ca2a 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -1,9 +1,9 @@ # 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. +- 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. """ @@ -38,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, @@ -70,13 +71,15 @@ 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, MCP_TRUSTED_EXECUTION_ENV_VAR, MCP_WORKSPACE_ID_HEADER, _str_to_bool, ) -from airbyte.exceptions import AirbyteLibInputError, AirbyteSafeModeError +from airbyte.exceptions import AirbyteLibInputError, AirbyteLocalDemoModeError if TYPE_CHECKING: @@ -88,16 +91,9 @@ _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. @@ -107,6 +103,18 @@ _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. @@ -119,24 +127,78 @@ def register_guid_created_in_session(guid: str) -> None: 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". + This is a no-op unless local demo mode is enabled. - Raises `AirbyteSafeModeError` if the GUID was not created in this session and - AIRBYTE_CLOUD_MCP_SAFE_MODE is set to 1. + Raises `AirbyteLocalDemoModeError` if local demo mode is enabled and the GUID + was not created in this session. Args: guid: The GUID to check """ - if AIRBYTE_CLOUD_MCP_SAFE_MODE and guid not in _GUIDS_CREATED_IN_SESSION: - raise AirbyteSafeModeError( + if local_demo_mode_enabled() and guid not in _GUIDS_CREATED_IN_SESSION: + raise AirbyteLocalDemoModeError( 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'." + f"Cannot modify '{guid}': local demo mode only allows changes " + "to resources created in this session." ), ) +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 + 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=( + "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 " + "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}" + ), + ) + + +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=( + "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" + ), + ) + + # ============================================================================= # Backward-Compatible Config Args # ============================================================================= @@ -151,7 +213,7 @@ def check_guid_created_in_session(guid: str) -> 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, @@ -516,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 and explicitly enabled safe mode 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, @@ -526,12 +588,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/http_main.py b/airbyte/mcp/http_main.py index 100cc1b22..918da85cb 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_if_demo_mode_guards 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_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 9a7d948f6..dadb8304d 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_if_demo_mode_guards, validate_airbyte_domains, ) from airbyte.mcp._user_identity import ( @@ -179,8 +180,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""" @@ -613,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_if_demo_mode_guards() print("Starting Airbyte MCP server.", file=sys.stderr) try: asyncio.run(app.run_stdio_async()) 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..0dc492307 100644 --- a/tests/unit_tests/test_deferred_cloud_credentials.py +++ b/tests/unit_tests/test_deferred_cloud_credentials.py @@ -673,7 +673,7 @@ def test_mcp_deploy_deferred_registers_unacknowledged_actor_for_cleanup( workspace_like: _WorkspaceLike, monkeypatch: pytest.MonkeyPatch, ) -> None: - """An actor created without deferral acknowledgement is still tracked for safe-mode cleanup.""" + """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 diff --git a/tests/unit_tests/test_mcp_cloud.py b/tests/unit_tests/test_mcp_cloud.py index cf89d558e..f67f1cd0c 100644 --- a/tests/unit_tests/test_mcp_cloud.py +++ b/tests/unit_tests/test_mcp_cloud.py @@ -1156,7 +1156,6 @@ def fake_deploy(**kwargs: object) -> SimpleNamespace: monkeypatch.setattr( cloud_mcp, "register_guid_created_in_session", registered.append ) - result = cloud_mcp.deploy_connector_to_cloud( cast(Context, object()), name="My Connector", diff --git a/tests/unit_tests/test_mcp_connection_schedules.py b/tests/unit_tests/test_mcp_connection_schedules.py index d3addc411..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,7 +66,7 @@ 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) + 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", @@ -268,16 +271,17 @@ def test_update_existing_settings_remain_supported( assert _connection_api_calls(http) == [] -def test_update_interval_obeys_safe_mode( +def test_update_interval_obeys_local_demo_mode( schedule_backend: tuple[FastMCP, responses.RequestsMock, MagicMock, MagicMock], monkeypatch: pytest.MonkeyPatch, ) -> None: - """Safe mode blocks interval updates to connections outside the current session.""" + """Local demo 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.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="not created in this session"): + with pytest.raises(ToolError, match="local demo mode only allows changes"): asyncio.run(_update(server, interval_hours=24)) lookup.assert_not_called() diff --git a/tests/unit_tests/test_mcp_datadog.py b/tests/unit_tests/test_mcp_datadog.py index 3494a978c..0e8d27d4e 100644 --- a/tests/unit_tests/test_mcp_datadog.py +++ b/tests/unit_tests/test_mcp_datadog.py @@ -21,7 +21,7 @@ AirbyteCloudApiError, AirbyteConnectionSyncError, AirbyteConnectionSyncTimeoutError, - AirbyteSafeModeError, + AirbyteLocalDemoModeError, ) from airbyte.mcp import _datadog, _telemetry from airbyte.version import get_version @@ -446,9 +446,9 @@ def test_cloud_error_trace_message_ignores_missing_resource_without_http_error() id="config-api-error-response-text", ), pytest.param( - AirbyteSafeModeError(message="SENTINEL message"), + AirbyteLocalDemoModeError(message="SENTINEL message"), { - "airbyte.mcp.error_type": "AirbyteSafeModeError", + "airbyte.mcp.error_type": "AirbyteLocalDemoModeError", "airbyte.mcp.error.category": "user_error", "airbyte.mcp.error.fault": "caller", }, 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..b272ad2ba 100644 --- a/tests/unit_tests/test_mcp_policy_middleware.py +++ b/tests/unit_tests/test_mcp_policy_middleware.py @@ -13,13 +13,13 @@ 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, ) from airbyte.exceptions import ( AirbyteExternalAccessDisabledError, + AirbyteLibInputError, AirbytePipelineChangesDisabledError, ) from airbyte.mcp import _tool_utils @@ -43,7 +43,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 +110,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 +122,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", @@ -199,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_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..7b593c707 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -9,7 +9,11 @@ import pytest from fastmcp_extensions import MCPServerConfigArg -from airbyte.exceptions import AirbyteSafeModeError +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, @@ -23,6 +27,8 @@ ToolPolicy, _resolve_transport_bearer_token, check_guid_created_in_session, + check_if_demo_mode_guards, + check_legacy_safe_mode_header, mcp_tool, register_guid_created_in_session, ) @@ -112,27 +118,106 @@ def test_register_guid_created_in_session() -> None: 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_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_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_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_if_demo_mode_guards() -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") +@pytest.mark.parametrize("value", ["1", "0", ""]) +def test_check_if_demo_mode_guards_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_if_demo_mode_guards() + assert "no longer supported" in str(exc_info.value) + 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] + + +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_if_demo_mode_guards() + 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 + 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 def test_multiple_guids_can_be_registered() -> None: