Skip to content
Merged
3 changes: 1 addition & 2 deletions .github/mcp-ci-tests.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
}
}
}
Expand Down
13 changes: 10 additions & 3 deletions airbyte/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,16 +298,23 @@ 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."""

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.
Expand Down Expand Up @@ -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."""
Expand Down
11 changes: 5 additions & 6 deletions airbyte/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""


Expand All @@ -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`."
)
Expand All @@ -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."
)


Expand Down
34 changes: 11 additions & 23 deletions airbyte/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions airbyte/mcp/_error_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@
AirbyteExternalAccessDisabledError,
AirbyteLibError,
AirbyteLibInputError,
AirbyteLocalDemoModeError,
AirbyteMCPError,
AirbyteMissingResourceError,
AirbytePipelineChangesDisabledError,
AirbyteSafeModeError,
)


Expand Down Expand Up @@ -79,7 +79,7 @@
MCP_TOOL_USER_FACING_ERRORS: tuple[type[AirbyteLibError], ...] = (
AirbyteLibInputError,
AirbyteMCPError,
AirbyteSafeModeError,
AirbyteLocalDemoModeError,
AirbytePipelineChangesDisabledError,
AirbyteExternalAccessDisabledError,
AirbyteAgentsUnavailableError,
Expand Down
17 changes: 15 additions & 2 deletions airbyte/mcp/_policy_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@
AirbytePipelineChangesDisabledError,
)
from airbyte.mcp._tool_utils import (
check_legacy_safe_mode_header,
external_access_allowed,
get_tool_policy,
pipeline_changes_allowed,
)


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):
Expand All @@ -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)
Expand All @@ -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)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Loading
Loading