From fa8d042a1cb2d1fdd5a6dd0b8b7ea5bd41a31fc3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:26:35 +0000 Subject: [PATCH 1/6] feat: support MCP URL query policy parameters Co-Authored-By: AJ Steers --- airbyte/constants.py | 20 ++- airbyte/exceptions.py | 9 +- airbyte/mcp/__init__.py | 27 ++-- airbyte/mcp/_tool_utils.py | 29 +++-- pyproject.toml | 3 + tests/unit_tests/test_mcp_http.py | 206 +++++++++++++++++++++++++++++- uv.lock | 10 +- 7 files changed, 268 insertions(+), 36 deletions(-) diff --git a/airbyte/constants.py b/airbyte/constants.py index f7341d67c..bff080919 100644 --- a/airbyte/constants.py +++ b/airbyte/constants.py @@ -373,8 +373,9 @@ def is_hosted_mcp_mode() -> bool: Set to `0`/`false`/`no` to hide insiders tools (see `MCP_INSIDERS_MODULES`) from every caller, regardless of `MCP_INSIDERS_HEADER`. Set to `1`/`true`/`yes` to advertise them by -default; a caller can still opt out with `MCP_INSIDERS_HEADER: 0`. Any other value, -including an empty string, leaves the decision to that header. +default; a caller can still opt out with `MCP_INSIDERS_HEADER: 0` or +`?insiders=0`. Any other value, including an empty string, leaves the decision +to the caller. """ MCP_INSIDERS_HEADER: str = "X-MCP-Insiders" @@ -384,9 +385,13 @@ def is_hosted_mcp_mode() -> bool: tool surface. This selects which tools are advertised and is not an access-control boundary: every insiders tool authorizes each call against the Airbyte API. A falsy `MCP_INSIDERS_ENV_VAR` overrides this header; a truthy one makes insiders tools -the default, which this header can only narrow by setting it to `0`. +the default, which this header or `MCP_INSIDERS_URL_PARAM` can only narrow by setting it to +`0`. """ +MCP_INSIDERS_URL_PARAM: str = "insiders" +"""URL query parameter for advertising insiders tools.""" + # MCP Config Arg Names (used with get_mcp_config) MCP_CONFIG_READONLY_MODE: str = "airbyte_readonly_mode" @@ -445,12 +450,21 @@ def is_hosted_mcp_mode() -> bool: MCP_ALLOW_PIPELINE_CHANGES_HEADER: str = "X-MCP-Allow-Pipeline-Changes" """HTTP header key for narrowing Cloud pipeline-change permission.""" +MCP_ALLOW_PIPELINE_CHANGES_URL_PARAM: str = "allow-pipeline-changes" +"""URL query parameter for narrowing Cloud pipeline-change permission.""" + MCP_ALLOW_EXTERNAL_ACCESS_HEADER: str = "X-MCP-Allow-External-Access" """HTTP header key for narrowing external-access permission.""" +MCP_ALLOW_EXTERNAL_ACCESS_URL_PARAM: str = "allow-external-access" +"""URL query parameter for narrowing external-access permission.""" + MCP_ALLOW_DESTRUCTIVE_TOOLS_HEADER: str = "X-MCP-Allow-Destructive-Tools" """HTTP header key for narrowing destructive-tools permission.""" +MCP_ALLOW_DESTRUCTIVE_TOOLS_URL_PARAM: str = "allow-destructive-tools" +"""URL query parameter for narrowing destructive-tools permission.""" + MCP_DISABLE_DELETE_GUARD_HEADER: str = "X-MCP-Disable-Delete-Guard" """HTTP header key for disabling the MCP delete guard.""" diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index db5762446..4e50fe812 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -375,7 +375,8 @@ class AirbytePipelineChangesDisabledError(AirbyteMCPError): guidance: str | None = ( "Set `AIRBYTE_CLOUD_MCP_ALLOW_PIPELINE_CHANGES=1` and ensure the " - "`X-MCP-Allow-Pipeline-Changes` request header is not `0`." + "`X-MCP-Allow-Pipeline-Changes` request header or `?allow-pipeline-changes=0` " + "URL query parameter is not set." ) @@ -385,7 +386,8 @@ class AirbyteDestructiveToolsDisabledError(AirbyteMCPError): guidance: str | None = ( "Set `AIRBYTE_CLOUD_MCP_ALLOW_DESTRUCTIVE_TOOLS=1` and ensure the " - "`X-MCP-Allow-Destructive-Tools` request header is not `0`." + "`X-MCP-Allow-Destructive-Tools` request header or " + "`?allow-destructive-tools=0` URL query parameter is not set." ) @@ -395,7 +397,8 @@ 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`." + "`X-MCP-Allow-External-Access` request header or " + "`?allow-external-access=0` URL query parameter is not set." ) diff --git a/airbyte/mcp/__init__.py b/airbyte/mcp/__init__.py index 3834190de..23dbf9455 100644 --- a/airbyte/mcp/__init__.py +++ b/airbyte/mcp/__init__.py @@ -126,6 +126,9 @@ disabled. Running and cancelling syncs are also disabled. Set the environment variable `AIRBYTE_CLOUD_MCP_READONLY_MODE=1` to enable read-only mode. +HTTP clients that cannot send custom headers can use the shared standard filter's +`?read-only=true` URL parameter, for example +`https://mcp.airbyte.com/mcp?read-only=true`. ### Local Demo Mode @@ -140,24 +143,32 @@ insensitive). To opt out, set `AIRBYTE_CLOUD_MCP_DISABLE_DELETE_GUARD=1` or send the `X-MCP-Disable-Delete-Guard: 1` request header; the same environment and header rules as the permission settings below apply. The stronger option is to disable deletions entirely with -`AIRBYTE_CLOUD_MCP_ALLOW_DESTRUCTIVE_TOOLS=0` or `X-MCP-Allow-Destructive-Tools: 0`. +`AIRBYTE_CLOUD_MCP_ALLOW_DESTRUCTIVE_TOOLS=0`, `X-MCP-Allow-Destructive-Tools: 0`, or +`?allow-destructive-tools=0`. ## Airbyte Cloud Pipeline and External Access Permissions Pipeline changes and external access can be controlled independently. The `AIRBYTE_CLOUD_MCP_ALLOW_PIPELINE_CHANGES` environment variable and -`X-MCP-Allow-Pipeline-Changes` request header control pipeline-changing tools. The +`X-MCP-Allow-Pipeline-Changes` request header or `?allow-pipeline-changes=...` URL +parameter control pipeline-changing tools. The `AIRBYTE_CLOUD_MCP_ALLOW_EXTERNAL_ACCESS` environment variable and -`X-MCP-Allow-External-Access` request header control external-access tools, such as +`X-MCP-Allow-External-Access` request header or `?allow-external-access=...` URL +parameter control external-access tools, such as `execute_external_api_query`, which can run connector passthrough queries, search, and inspect external data or skill documentation. The `AIRBYTE_CLOUD_MCP_ALLOW_DESTRUCTIVE_TOOLS` -environment variable and `X-MCP-Allow-Destructive-Tools` request header control -destructive-annotated tools. +environment variable and `X-MCP-Allow-Destructive-Tools` request header or +`?allow-destructive-tools=...` URL parameter control destructive-annotated tools. 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. `AIRBYTE_CLOUD_MCP_READONLY_MODE=1` also disables pipeline changes. +explicit environment denial cannot be overridden by a request header or URL parameter. An +environment allow can be narrowed by either. If the environment setting is unset, the request +header takes precedence over the URL parameter. `AIRBYTE_CLOUD_MCP_READONLY_MODE=1` also +disables pipeline changes. + +Insiders tools can be advertised with the `AIRBYTE_MCP_INSIDERS` environment variable, +`X-MCP-Insiders` request header, or `?insiders=1` URL parameter. The environment can deny +insiders tools outright; request values can only narrow an environment allow. Read-only mode blocks both pipeline changes and running or cancelling syncs. However, running or cancelling a sync is _not_ considered a pipeline change, so those tools stay available when diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index 94f163b06..cd0d45334 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -50,10 +50,13 @@ CLOUD_WORKSPACE_ID_ENV_VAR, MCP_ALLOW_DESTRUCTIVE_TOOLS_ENV_VAR, MCP_ALLOW_DESTRUCTIVE_TOOLS_HEADER, + MCP_ALLOW_DESTRUCTIVE_TOOLS_URL_PARAM, MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, MCP_ALLOW_EXTERNAL_ACCESS_HEADER, + MCP_ALLOW_EXTERNAL_ACCESS_URL_PARAM, MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, MCP_ALLOW_PIPELINE_CHANGES_HEADER, + MCP_ALLOW_PIPELINE_CHANGES_URL_PARAM, MCP_BEARER_TOKEN_HEADER, MCP_CONFIG_ALLOW_DESTRUCTIVE_TOOLS, MCP_CONFIG_ALLOW_EXTERNAL_ACCESS, @@ -77,6 +80,7 @@ MCP_INSIDERS_ENV_VAR, MCP_INSIDERS_HEADER, MCP_INSIDERS_MODULES, + MCP_INSIDERS_URL_PARAM, MCP_LEGACY_SAFE_MODE_HEADER, MCP_LOCAL_DEMO_MODE_ENV_VAR, MCP_ORGANIZATION_ID_HEADER, @@ -154,11 +158,14 @@ def check_guid_created_in_session(guid: str) -> None: MCP_LEGACY_SAFE_MODE_HEADER: ( "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, " + "- `X-MCP-Allow-Pipeline-Changes: 0` / `?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-Allow-Destructive-Tools: 0`: hide destructive tools.\n" + "- `X-MCP-Allow-External-Access: 0` / `?allow-external-access=0`: " + "block SQL and API passthrough tools.\n" + "- `X-MCP-Readonly-Mode: 1` / `?read-only=true`: only expose read-only tools.\n" + "- `X-MCP-Allow-Destructive-Tools: 0` / `?allow-destructive-tools=0`: " + "hide destructive tools.\n" ), } """Removed MCP request headers, mapped to replacement guidance. @@ -218,6 +225,7 @@ def check_unsupported_mcp_headers() -> None: INSIDERS_CONFIG_ARG = MCPServerConfigArg( name=MCP_CONFIG_INSIDERS, http_header_key=MCP_INSIDERS_HEADER, + url_param_key=MCP_INSIDERS_URL_PARAM, env_var=MCP_INSIDERS_ENV_VAR, default="", required=False, @@ -225,12 +233,14 @@ def check_unsupported_mcp_headers() -> None: """Config arg for the insiders tools gate. The default is empty rather than `0`, because `0` is an explicit denial that also refuses -the include-list opt-in. +the include-list opt-in. Callers can narrow an environment allow with the `X-MCP-Insiders` +header or `?insiders=0` URL parameter. """ ALLOW_PIPELINE_CHANGES_CONFIG_ARG = MCPServerConfigArg( name=MCP_CONFIG_ALLOW_PIPELINE_CHANGES, http_header_key=MCP_ALLOW_PIPELINE_CHANGES_HEADER, + url_param_key=MCP_ALLOW_PIPELINE_CHANGES_URL_PARAM, env_var=MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, default="", required=False, @@ -240,6 +250,7 @@ def check_unsupported_mcp_headers() -> None: ALLOW_EXTERNAL_ACCESS_CONFIG_ARG = MCPServerConfigArg( name=MCP_CONFIG_ALLOW_EXTERNAL_ACCESS, http_header_key=MCP_ALLOW_EXTERNAL_ACCESS_HEADER, + url_param_key=MCP_ALLOW_EXTERNAL_ACCESS_URL_PARAM, env_var=MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, default="", required=False, @@ -249,6 +260,7 @@ def check_unsupported_mcp_headers() -> None: ALLOW_DESTRUCTIVE_TOOLS_CONFIG_ARG = MCPServerConfigArg( name=MCP_CONFIG_ALLOW_DESTRUCTIVE_TOOLS, http_header_key=MCP_ALLOW_DESTRUCTIVE_TOOLS_HEADER, + url_param_key=MCP_ALLOW_DESTRUCTIVE_TOOLS_URL_PARAM, env_var=MCP_ALLOW_DESTRUCTIVE_TOOLS_ENV_VAR, default="", required=False, @@ -476,8 +488,8 @@ def mcp_tool( # noqa: PLR0913 - mirrors the upstream decorator's kwargs """Decorate an MCP tool with deferred Airbyte registration metadata. When `insiders_only` is true, the tool is hidden unless insiders mode is on - (`AIRBYTE_MCP_INSIDERS` / `X-MCP-Insiders`). When `force_disabled` is true, the function - is returned unchanged and never registered. + (`AIRBYTE_MCP_INSIDERS`, `X-MCP-Insiders`, or `?insiders=1`). When `force_disabled` is + true, the function is returned unchanged and never registered. """ base_decorator = _mcp_tool( read_only=read_only, @@ -663,7 +675,8 @@ def _insiders_mode(app: FastMCP) -> bool | None: `AIRBYTE_MCP_INSIDERS` sets the deployment default and callers may only narrow it: a falsy host value denies insiders tools outright, while a truthy one still honors an - explicit `X-MCP-Insiders: 0`. Returns `None` when neither is set to a recognized value. + explicit `X-MCP-Insiders: 0` or `?insiders=0`. Returns `None` when neither is set to a + recognized value. """ hosted_mode = _str_to_bool(os.environ.get(MCP_INSIDERS_ENV_VAR)) caller_mode = _str_to_bool(get_mcp_config(app, MCP_CONFIG_INSIDERS)) diff --git a/pyproject.toml b/pyproject.toml index e4a006c1a..177e32837 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,6 +88,9 @@ dev = [ "pydantic-ai>=0.2.16", ] +[tool.uv.sources] +fastmcp-extensions = { git = "https://github.com/airbytehq/fastmcp-extensions.git", branch = "devin/1793319296-url-param-config-args" } + [project.scripts] pyairbyte = "airbyte.cli.pyab:cli" pyab = "airbyte.cli.pyab:cli" diff --git a/tests/unit_tests/test_mcp_http.py b/tests/unit_tests/test_mcp_http.py index 8d5c261a8..6964b5ad0 100644 --- a/tests/unit_tests/test_mcp_http.py +++ b/tests/unit_tests/test_mcp_http.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any -import httpx +import httpx2 import mcp_types as types import pytest from mcp.client.session import ClientSession @@ -20,7 +20,20 @@ from mcp.client.streamable_http import streamable_http_client from starlette.middleware import Middleware -from airbyte.constants import MCP_EXTENSIONS_HEADER +from airbyte.constants import ( + MCP_ALLOW_DESTRUCTIVE_TOOLS_ENV_VAR, + MCP_ALLOW_DESTRUCTIVE_TOOLS_HEADER, + MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, + MCP_ALLOW_EXTERNAL_ACCESS_HEADER, + MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, + MCP_ALLOW_PIPELINE_CHANGES_HEADER, + MCP_DOMAINS_DISABLED_ENV_VAR, + MCP_DOMAINS_ENV_VAR, + MCP_EXTENSIONS_HEADER, + MCP_INSIDERS_ENV_VAR, + MCP_INSIDERS_HEADER, + MCP_READONLY_MODE_ENV_VAR, +) from airbyte.mcp.server import app from fastmcp_extensions import CapabilityTokenMiddleware from fastmcp_extensions import DEFAULT_EXTENSIONS_HEADER @@ -143,6 +156,7 @@ async def _http_session( *, headers: dict[str, str] | None, configure_client_capabilities: Any, + url: str = "http://testserver/mcp", ui: bool = False, wire_headers: list[list[tuple[bytes, bytes]]] | None = None, response_headers: list[list[tuple[bytes, bytes]]] | None = None, @@ -150,17 +164,17 @@ async def _http_session( configure_client_capabilities(ui) http_app = _http_app() - async def capture_request(request: httpx.Request) -> None: + async def capture_request(request: httpx2.Request) -> None: if wire_headers is not None: wire_headers.append(request.headers.raw) - async def capture_response(response: httpx.Response) -> None: + async def capture_response(response: httpx2.Response) -> None: if response_headers is not None: response_headers.append(response.headers.raw) async with http_app.router.lifespan_context(http_app): - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=http_app), + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=http_app), base_url="http://testserver", headers=headers, event_hooks={ @@ -169,7 +183,7 @@ async def capture_response(response: httpx.Response) -> None: }, ) as http_client: async with streamable_http_client( - "http://testserver/mcp", + url, http_client=http_client, ) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: @@ -183,6 +197,184 @@ async def _tool_names(session_factory: Any) -> set[str]: return {tool.name for tool in result.tools} +def _http_tool_names( + *, + headers: dict[str, str] | None, + configure_client_capabilities: Any, + url: str = "http://testserver/mcp", +) -> set[str]: + return asyncio.run( + _tool_names( + _http_session( + headers=headers, + configure_client_capabilities=configure_client_capabilities, + url=url, + ) + ) + ) + + +@pytest.mark.parametrize( + ("query_param", "header", "header_value", "tool_name", "visible"), + [ + pytest.param( + "allow-pipeline-changes=0", + MCP_ALLOW_PIPELINE_CHANGES_HEADER, + "0", + "create_connection", + False, + id="pipeline-changes", + ), + pytest.param( + "allow-destructive-tools=false", + MCP_ALLOW_DESTRUCTIVE_TOOLS_HEADER, + "false", + "permanently_delete_connection", + False, + id="destructive-tools", + ), + pytest.param( + "allow-external-access=0", + MCP_ALLOW_EXTERNAL_ACCESS_HEADER, + "0", + "execute_external_api_query", + False, + id="external-access", + ), + pytest.param( + "insiders=1", + MCP_INSIDERS_HEADER, + "1", + "create_connector_builder_project", + True, + id="insiders", + ), + ], +) +def test_http_url_policy_params_match_headers( + monkeypatch: pytest.MonkeyPatch, + configure_client_capabilities: Any, + query_param: str, + header: str, + header_value: str, + tool_name: str, + visible: bool, +) -> None: + """URL policy parameters have the same tool-list effects as request headers.""" + for env_var in ( + MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, + MCP_ALLOW_DESTRUCTIVE_TOOLS_ENV_VAR, + MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, + MCP_DOMAINS_DISABLED_ENV_VAR, + MCP_DOMAINS_ENV_VAR, + MCP_INSIDERS_ENV_VAR, + MCP_READONLY_MODE_ENV_VAR, + ): + monkeypatch.delenv(env_var, raising=False) + + query_names = _http_tool_names( + headers={}, + configure_client_capabilities=configure_client_capabilities, + url=f"http://testserver/mcp?{query_param}", + ) + header_names = _http_tool_names( + headers={header: header_value}, + configure_client_capabilities=configure_client_capabilities, + ) + + assert (tool_name in query_names) is visible + assert (tool_name in header_names) is visible + + +@pytest.mark.parametrize( + ("env_var", "query_param", "tool_name"), + [ + pytest.param( + MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, + "allow-pipeline-changes=1", + "create_connection", + id="pipeline-changes", + ), + pytest.param( + MCP_ALLOW_DESTRUCTIVE_TOOLS_ENV_VAR, + "allow-destructive-tools=1", + "permanently_delete_connection", + id="destructive-tools", + ), + pytest.param( + MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, + "allow-external-access=1", + "execute_external_api_query", + id="external-access", + ), + pytest.param( + MCP_INSIDERS_ENV_VAR, + "insiders=1", + "create_connector_builder_project", + id="insiders", + ), + ], +) +def test_http_falsy_environment_overrides_truthy_url_param( + monkeypatch: pytest.MonkeyPatch, + configure_client_capabilities: Any, + env_var: str, + query_param: str, + tool_name: str, +) -> None: + """A falsy deployment setting overrides a truthy per-request URL parameter.""" + for policy_env_var in ( + MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, + MCP_ALLOW_DESTRUCTIVE_TOOLS_ENV_VAR, + MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, + MCP_DOMAINS_DISABLED_ENV_VAR, + MCP_DOMAINS_ENV_VAR, + MCP_INSIDERS_ENV_VAR, + MCP_READONLY_MODE_ENV_VAR, + ): + monkeypatch.delenv(policy_env_var, raising=False) + monkeypatch.setenv(env_var, "0") + + names = _http_tool_names( + headers={}, + configure_client_capabilities=configure_client_capabilities, + url=f"http://testserver/mcp?{query_param}", + ) + + assert tool_name not in names + + +def test_http_read_only_url_param_hides_non_read_only_tools( + monkeypatch: pytest.MonkeyPatch, + configure_client_capabilities: Any, +) -> None: + """The shared read-only filter applies to HTTP `tools/list` requests.""" + for env_var in ( + MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, + MCP_ALLOW_DESTRUCTIVE_TOOLS_ENV_VAR, + MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, + MCP_DOMAINS_DISABLED_ENV_VAR, + MCP_DOMAINS_ENV_VAR, + MCP_INSIDERS_ENV_VAR, + MCP_READONLY_MODE_ENV_VAR, + ): + monkeypatch.delenv(env_var, raising=False) + + unrestricted_names = _http_tool_names( + headers={}, + configure_client_capabilities=configure_client_capabilities, + ) + read_only_names = _http_tool_names( + headers={}, + configure_client_capabilities=configure_client_capabilities, + url="http://testserver/mcp?read-only=true", + ) + + assert "run_connection_sync" in unrestricted_names + assert "run_connection_sync" not in read_only_names + assert "list_workspaces" in read_only_names + + @pytest.mark.parametrize("ui", [True, False], ids=["ui-client", "non-ui-client"]) def test_stdio_ui_tool_visibility( ui: bool, diff --git a/uv.lock b/uv.lock index 27b1dbfbe..0d2b29511 100644 --- a/uv.lock +++ b/uv.lock @@ -248,7 +248,7 @@ requires-dist = [ { name = "duckdb", specifier = "==1.4.3" }, { name = "duckdb-engine", specifier = "==0.17.0" }, { name = "fastmcp", specifier = ">=4.0.9,<5.0" }, - { name = "fastmcp-extensions", specifier = ">=0.35.0,<1.0.0" }, + { name = "fastmcp-extensions", git = "https://github.com/airbytehq/fastmcp-extensions.git?branch=devin%2F1793319296-url-param-config-args" }, { name = "google-auth", specifier = ">=2.27.0,<3.0" }, { name = "google-cloud-bigquery", specifier = ">=3.12.0,<4.0" }, { name = "google-cloud-bigquery-storage", specifier = ">=2.25.0,<3.0" }, @@ -1352,8 +1352,8 @@ wheels = [ [[package]] name = "fastmcp-extensions" -version = "0.35.0" -source = { registry = "https://pypi.org/simple" } +version = "0.36.0.post1.dev0+b2b527b" +source = { git = "https://github.com/airbytehq/fastmcp-extensions.git?branch=devin%2F1793319296-url-param-config-args#b2b527bf84fe90d7afd762aea2d0da26d5019cfd" } dependencies = [ { name = "fastmcp" }, { name = "httpx" }, @@ -1367,10 +1367,6 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/45/dcf55ab5ba27c238654c71d92af320eec2312ae3fad62572b78ea0b9f8fc/fastmcp_extensions-0.35.0.tar.gz", hash = "sha256:bad04d428433c424407277928bc44d77e356f9c2ff73b42aa9e058ad54173038", size = 357967, upload-time = "2026-10-14T05:17:25.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/5f/3836a5aea53632be7a7629b7514003138377cfc5e37958308839cb627d7e/fastmcp_extensions-0.35.0-py3-none-any.whl", hash = "sha256:86d31337876faec30dcc336c68b7103d84f553ce8f1555540888c717ddad9e8f", size = 156435, upload-time = "2026-10-14T05:17:23.977Z" }, -] [[package]] name = "fastmcp-slim" From c55a0178262ac7e8e2b60c6463d27c05f1ee70bd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:34:48 +0000 Subject: [PATCH 2/6] chore(deps): pin fastmcp-extensions prerelease Co-Authored-By: AJ Steers --- pyproject.toml | 5 +---- uv.lock | 10 +++++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 177e32837..34e4f7209 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "typing-extensions", "uuid7>=0.1.0,<1.0", "fastmcp>=4.0.9,<5.0", - "fastmcp-extensions>=0.35.0,<1.0.0", + "fastmcp-extensions==0.36.0.dev15740980248948", "starlette", # Transitive dependency of fastmcp, imported directly "httpx", # Transitive dependency of fastmcp, imported directly "mcp", # Transitive dependency of fastmcp, imported directly @@ -88,9 +88,6 @@ dev = [ "pydantic-ai>=0.2.16", ] -[tool.uv.sources] -fastmcp-extensions = { git = "https://github.com/airbytehq/fastmcp-extensions.git", branch = "devin/1793319296-url-param-config-args" } - [project.scripts] pyairbyte = "airbyte.cli.pyab:cli" pyab = "airbyte.cli.pyab:cli" diff --git a/uv.lock b/uv.lock index 0d2b29511..14e32927c 100644 --- a/uv.lock +++ b/uv.lock @@ -248,7 +248,7 @@ requires-dist = [ { name = "duckdb", specifier = "==1.4.3" }, { name = "duckdb-engine", specifier = "==0.17.0" }, { name = "fastmcp", specifier = ">=4.0.9,<5.0" }, - { name = "fastmcp-extensions", git = "https://github.com/airbytehq/fastmcp-extensions.git?branch=devin%2F1793319296-url-param-config-args" }, + { name = "fastmcp-extensions", specifier = "==0.36.0.dev15740980248948" }, { name = "google-auth", specifier = ">=2.27.0,<3.0" }, { name = "google-cloud-bigquery", specifier = ">=3.12.0,<4.0" }, { name = "google-cloud-bigquery-storage", specifier = ">=2.25.0,<3.0" }, @@ -1352,8 +1352,8 @@ wheels = [ [[package]] name = "fastmcp-extensions" -version = "0.36.0.post1.dev0+b2b527b" -source = { git = "https://github.com/airbytehq/fastmcp-extensions.git?branch=devin%2F1793319296-url-param-config-args#b2b527bf84fe90d7afd762aea2d0da26d5019cfd" } +version = "0.36.0.dev15740980248948" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp" }, { name = "httpx" }, @@ -1367,6 +1367,10 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/40/c1/8e9e2bcaa853d902be333cef96620fe4511d8806e79e158400595f106ffc/fastmcp_extensions-0.36.0.dev15740980248948.tar.gz", hash = "sha256:715060600426d2e8f5c0e2b54ec48a33a6ebb4a7711067d4944f78e35e3291d6", size = 365658, upload-time = "2026-10-30T00:32:29.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/16/452e90d5ea93a7e87ecc4cbbdd230ba2dda8430cc236e411d595c4becdae/fastmcp_extensions-0.36.0.dev15740980248948-py3-none-any.whl", hash = "sha256:e72ac197438a169626ca6733af690195e8eed91427078ca329f939f22b106beb", size = 160064, upload-time = "2026-10-30T00:32:27.576Z" }, +] [[package]] name = "fastmcp-slim" From 29591a7fa3e4cae9667e9b85cfdf2ad804449651 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:41:02 +0000 Subject: [PATCH 3/6] refactor(mcp): limit URL query params to read-only Co-Authored-By: AJ Steers --- airbyte/constants.py | 20 +--- airbyte/exceptions.py | 9 +- airbyte/mcp/__init__.py | 24 ++--- airbyte/mcp/_tool_utils.py | 29 ++---- tests/unit_tests/test_mcp_http.py | 151 +----------------------------- 5 files changed, 23 insertions(+), 210 deletions(-) diff --git a/airbyte/constants.py b/airbyte/constants.py index bff080919..f7341d67c 100644 --- a/airbyte/constants.py +++ b/airbyte/constants.py @@ -373,9 +373,8 @@ def is_hosted_mcp_mode() -> bool: Set to `0`/`false`/`no` to hide insiders tools (see `MCP_INSIDERS_MODULES`) from every caller, regardless of `MCP_INSIDERS_HEADER`. Set to `1`/`true`/`yes` to advertise them by -default; a caller can still opt out with `MCP_INSIDERS_HEADER: 0` or -`?insiders=0`. Any other value, including an empty string, leaves the decision -to the caller. +default; a caller can still opt out with `MCP_INSIDERS_HEADER: 0`. Any other value, +including an empty string, leaves the decision to that header. """ MCP_INSIDERS_HEADER: str = "X-MCP-Insiders" @@ -385,13 +384,9 @@ def is_hosted_mcp_mode() -> bool: tool surface. This selects which tools are advertised and is not an access-control boundary: every insiders tool authorizes each call against the Airbyte API. A falsy `MCP_INSIDERS_ENV_VAR` overrides this header; a truthy one makes insiders tools -the default, which this header or `MCP_INSIDERS_URL_PARAM` can only narrow by setting it to -`0`. +the default, which this header can only narrow by setting it to `0`. """ -MCP_INSIDERS_URL_PARAM: str = "insiders" -"""URL query parameter for advertising insiders tools.""" - # MCP Config Arg Names (used with get_mcp_config) MCP_CONFIG_READONLY_MODE: str = "airbyte_readonly_mode" @@ -450,21 +445,12 @@ def is_hosted_mcp_mode() -> bool: MCP_ALLOW_PIPELINE_CHANGES_HEADER: str = "X-MCP-Allow-Pipeline-Changes" """HTTP header key for narrowing Cloud pipeline-change permission.""" -MCP_ALLOW_PIPELINE_CHANGES_URL_PARAM: str = "allow-pipeline-changes" -"""URL query parameter for narrowing Cloud pipeline-change permission.""" - MCP_ALLOW_EXTERNAL_ACCESS_HEADER: str = "X-MCP-Allow-External-Access" """HTTP header key for narrowing external-access permission.""" -MCP_ALLOW_EXTERNAL_ACCESS_URL_PARAM: str = "allow-external-access" -"""URL query parameter for narrowing external-access permission.""" - MCP_ALLOW_DESTRUCTIVE_TOOLS_HEADER: str = "X-MCP-Allow-Destructive-Tools" """HTTP header key for narrowing destructive-tools permission.""" -MCP_ALLOW_DESTRUCTIVE_TOOLS_URL_PARAM: str = "allow-destructive-tools" -"""URL query parameter for narrowing destructive-tools permission.""" - MCP_DISABLE_DELETE_GUARD_HEADER: str = "X-MCP-Disable-Delete-Guard" """HTTP header key for disabling the MCP delete guard.""" diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index 4e50fe812..db5762446 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -375,8 +375,7 @@ class AirbytePipelineChangesDisabledError(AirbyteMCPError): guidance: str | None = ( "Set `AIRBYTE_CLOUD_MCP_ALLOW_PIPELINE_CHANGES=1` and ensure the " - "`X-MCP-Allow-Pipeline-Changes` request header or `?allow-pipeline-changes=0` " - "URL query parameter is not set." + "`X-MCP-Allow-Pipeline-Changes` request header is not `0`." ) @@ -386,8 +385,7 @@ class AirbyteDestructiveToolsDisabledError(AirbyteMCPError): guidance: str | None = ( "Set `AIRBYTE_CLOUD_MCP_ALLOW_DESTRUCTIVE_TOOLS=1` and ensure the " - "`X-MCP-Allow-Destructive-Tools` request header or " - "`?allow-destructive-tools=0` URL query parameter is not set." + "`X-MCP-Allow-Destructive-Tools` request header is not `0`." ) @@ -397,8 +395,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 or " - "`?allow-external-access=0` URL query parameter is not set." + "`X-MCP-Allow-External-Access` request header is not `0`." ) diff --git a/airbyte/mcp/__init__.py b/airbyte/mcp/__init__.py index 23dbf9455..c5c6eae9d 100644 --- a/airbyte/mcp/__init__.py +++ b/airbyte/mcp/__init__.py @@ -143,32 +143,24 @@ insensitive). To opt out, set `AIRBYTE_CLOUD_MCP_DISABLE_DELETE_GUARD=1` or send the `X-MCP-Disable-Delete-Guard: 1` request header; the same environment and header rules as the permission settings below apply. The stronger option is to disable deletions entirely with -`AIRBYTE_CLOUD_MCP_ALLOW_DESTRUCTIVE_TOOLS=0`, `X-MCP-Allow-Destructive-Tools: 0`, or -`?allow-destructive-tools=0`. +`AIRBYTE_CLOUD_MCP_ALLOW_DESTRUCTIVE_TOOLS=0` or `X-MCP-Allow-Destructive-Tools: 0`. ## Airbyte Cloud Pipeline and External Access Permissions Pipeline changes and external access can be controlled independently. The `AIRBYTE_CLOUD_MCP_ALLOW_PIPELINE_CHANGES` environment variable and -`X-MCP-Allow-Pipeline-Changes` request header or `?allow-pipeline-changes=...` URL -parameter control pipeline-changing tools. The +`X-MCP-Allow-Pipeline-Changes` request header control pipeline-changing tools. The `AIRBYTE_CLOUD_MCP_ALLOW_EXTERNAL_ACCESS` environment variable and -`X-MCP-Allow-External-Access` request header or `?allow-external-access=...` URL -parameter control external-access tools, such as +`X-MCP-Allow-External-Access` request header control external-access tools, such as `execute_external_api_query`, which can run connector passthrough queries, search, and inspect external data or skill documentation. The `AIRBYTE_CLOUD_MCP_ALLOW_DESTRUCTIVE_TOOLS` -environment variable and `X-MCP-Allow-Destructive-Tools` request header or -`?allow-destructive-tools=...` URL parameter control destructive-annotated tools. +environment variable and `X-MCP-Allow-Destructive-Tools` request header control +destructive-annotated tools. 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 or URL parameter. An -environment allow can be narrowed by either. If the environment setting is unset, the request -header takes precedence over the URL parameter. `AIRBYTE_CLOUD_MCP_READONLY_MODE=1` also -disables pipeline changes. - -Insiders tools can be advertised with the `AIRBYTE_MCP_INSIDERS` environment variable, -`X-MCP-Insiders` request header, or `?insiders=1` URL parameter. The environment can deny -insiders tools outright; request values can only narrow an environment allow. +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. `AIRBYTE_CLOUD_MCP_READONLY_MODE=1` also disables pipeline changes. Read-only mode blocks both pipeline changes and running or cancelling syncs. However, running or cancelling a sync is _not_ considered a pipeline change, so those tools stay available when diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index cd0d45334..3b0898246 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -50,13 +50,10 @@ CLOUD_WORKSPACE_ID_ENV_VAR, MCP_ALLOW_DESTRUCTIVE_TOOLS_ENV_VAR, MCP_ALLOW_DESTRUCTIVE_TOOLS_HEADER, - MCP_ALLOW_DESTRUCTIVE_TOOLS_URL_PARAM, MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, MCP_ALLOW_EXTERNAL_ACCESS_HEADER, - MCP_ALLOW_EXTERNAL_ACCESS_URL_PARAM, MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, MCP_ALLOW_PIPELINE_CHANGES_HEADER, - MCP_ALLOW_PIPELINE_CHANGES_URL_PARAM, MCP_BEARER_TOKEN_HEADER, MCP_CONFIG_ALLOW_DESTRUCTIVE_TOOLS, MCP_CONFIG_ALLOW_EXTERNAL_ACCESS, @@ -80,7 +77,6 @@ MCP_INSIDERS_ENV_VAR, MCP_INSIDERS_HEADER, MCP_INSIDERS_MODULES, - MCP_INSIDERS_URL_PARAM, MCP_LEGACY_SAFE_MODE_HEADER, MCP_LOCAL_DEMO_MODE_ENV_VAR, MCP_ORGANIZATION_ID_HEADER, @@ -158,14 +154,11 @@ def check_guid_created_in_session(guid: str) -> None: MCP_LEGACY_SAFE_MODE_HEADER: ( "Remove the `X-MCP-Safe-Mode` header and consider using one of these " "alternatives:\n" - "- `X-MCP-Allow-Pipeline-Changes: 0` / `?allow-pipeline-changes=0`: " - "block changes to sources, " + "- `X-MCP-Allow-Pipeline-Changes: 0`: block changes to sources, " "destinations, and connections.\n" - "- `X-MCP-Allow-External-Access: 0` / `?allow-external-access=0`: " - "block SQL and API passthrough tools.\n" - "- `X-MCP-Readonly-Mode: 1` / `?read-only=true`: only expose read-only tools.\n" - "- `X-MCP-Allow-Destructive-Tools: 0` / `?allow-destructive-tools=0`: " - "hide destructive tools.\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-Allow-Destructive-Tools: 0`: hide destructive tools.\n" ), } """Removed MCP request headers, mapped to replacement guidance. @@ -225,7 +218,6 @@ def check_unsupported_mcp_headers() -> None: INSIDERS_CONFIG_ARG = MCPServerConfigArg( name=MCP_CONFIG_INSIDERS, http_header_key=MCP_INSIDERS_HEADER, - url_param_key=MCP_INSIDERS_URL_PARAM, env_var=MCP_INSIDERS_ENV_VAR, default="", required=False, @@ -233,14 +225,12 @@ def check_unsupported_mcp_headers() -> None: """Config arg for the insiders tools gate. The default is empty rather than `0`, because `0` is an explicit denial that also refuses -the include-list opt-in. Callers can narrow an environment allow with the `X-MCP-Insiders` -header or `?insiders=0` URL parameter. +the include-list opt-in. """ ALLOW_PIPELINE_CHANGES_CONFIG_ARG = MCPServerConfigArg( name=MCP_CONFIG_ALLOW_PIPELINE_CHANGES, http_header_key=MCP_ALLOW_PIPELINE_CHANGES_HEADER, - url_param_key=MCP_ALLOW_PIPELINE_CHANGES_URL_PARAM, env_var=MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, default="", required=False, @@ -250,7 +240,6 @@ def check_unsupported_mcp_headers() -> None: ALLOW_EXTERNAL_ACCESS_CONFIG_ARG = MCPServerConfigArg( name=MCP_CONFIG_ALLOW_EXTERNAL_ACCESS, http_header_key=MCP_ALLOW_EXTERNAL_ACCESS_HEADER, - url_param_key=MCP_ALLOW_EXTERNAL_ACCESS_URL_PARAM, env_var=MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, default="", required=False, @@ -260,7 +249,6 @@ def check_unsupported_mcp_headers() -> None: ALLOW_DESTRUCTIVE_TOOLS_CONFIG_ARG = MCPServerConfigArg( name=MCP_CONFIG_ALLOW_DESTRUCTIVE_TOOLS, http_header_key=MCP_ALLOW_DESTRUCTIVE_TOOLS_HEADER, - url_param_key=MCP_ALLOW_DESTRUCTIVE_TOOLS_URL_PARAM, env_var=MCP_ALLOW_DESTRUCTIVE_TOOLS_ENV_VAR, default="", required=False, @@ -488,8 +476,8 @@ def mcp_tool( # noqa: PLR0913 - mirrors the upstream decorator's kwargs """Decorate an MCP tool with deferred Airbyte registration metadata. When `insiders_only` is true, the tool is hidden unless insiders mode is on - (`AIRBYTE_MCP_INSIDERS`, `X-MCP-Insiders`, or `?insiders=1`). When `force_disabled` is - true, the function is returned unchanged and never registered. + (`AIRBYTE_MCP_INSIDERS` / `X-MCP-Insiders`). When `force_disabled` is true, the + function is returned unchanged and never registered. """ base_decorator = _mcp_tool( read_only=read_only, @@ -675,8 +663,7 @@ def _insiders_mode(app: FastMCP) -> bool | None: `AIRBYTE_MCP_INSIDERS` sets the deployment default and callers may only narrow it: a falsy host value denies insiders tools outright, while a truthy one still honors an - explicit `X-MCP-Insiders: 0` or `?insiders=0`. Returns `None` when neither is set to a - recognized value. + explicit `X-MCP-Insiders: 0`. Returns `None` when neither is set to a recognized value. """ hosted_mode = _str_to_bool(os.environ.get(MCP_INSIDERS_ENV_VAR)) caller_mode = _str_to_bool(get_mcp_config(app, MCP_CONFIG_INSIDERS)) diff --git a/tests/unit_tests/test_mcp_http.py b/tests/unit_tests/test_mcp_http.py index 6964b5ad0..4f076d130 100644 --- a/tests/unit_tests/test_mcp_http.py +++ b/tests/unit_tests/test_mcp_http.py @@ -21,17 +21,7 @@ from starlette.middleware import Middleware from airbyte.constants import ( - MCP_ALLOW_DESTRUCTIVE_TOOLS_ENV_VAR, - MCP_ALLOW_DESTRUCTIVE_TOOLS_HEADER, - MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, - MCP_ALLOW_EXTERNAL_ACCESS_HEADER, - MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, - MCP_ALLOW_PIPELINE_CHANGES_HEADER, - MCP_DOMAINS_DISABLED_ENV_VAR, - MCP_DOMAINS_ENV_VAR, MCP_EXTENSIONS_HEADER, - MCP_INSIDERS_ENV_VAR, - MCP_INSIDERS_HEADER, MCP_READONLY_MODE_ENV_VAR, ) from airbyte.mcp.server import app @@ -214,151 +204,12 @@ def _http_tool_names( ) -@pytest.mark.parametrize( - ("query_param", "header", "header_value", "tool_name", "visible"), - [ - pytest.param( - "allow-pipeline-changes=0", - MCP_ALLOW_PIPELINE_CHANGES_HEADER, - "0", - "create_connection", - False, - id="pipeline-changes", - ), - pytest.param( - "allow-destructive-tools=false", - MCP_ALLOW_DESTRUCTIVE_TOOLS_HEADER, - "false", - "permanently_delete_connection", - False, - id="destructive-tools", - ), - pytest.param( - "allow-external-access=0", - MCP_ALLOW_EXTERNAL_ACCESS_HEADER, - "0", - "execute_external_api_query", - False, - id="external-access", - ), - pytest.param( - "insiders=1", - MCP_INSIDERS_HEADER, - "1", - "create_connector_builder_project", - True, - id="insiders", - ), - ], -) -def test_http_url_policy_params_match_headers( - monkeypatch: pytest.MonkeyPatch, - configure_client_capabilities: Any, - query_param: str, - header: str, - header_value: str, - tool_name: str, - visible: bool, -) -> None: - """URL policy parameters have the same tool-list effects as request headers.""" - for env_var in ( - MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, - MCP_ALLOW_DESTRUCTIVE_TOOLS_ENV_VAR, - MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, - MCP_DOMAINS_DISABLED_ENV_VAR, - MCP_DOMAINS_ENV_VAR, - MCP_INSIDERS_ENV_VAR, - MCP_READONLY_MODE_ENV_VAR, - ): - monkeypatch.delenv(env_var, raising=False) - - query_names = _http_tool_names( - headers={}, - configure_client_capabilities=configure_client_capabilities, - url=f"http://testserver/mcp?{query_param}", - ) - header_names = _http_tool_names( - headers={header: header_value}, - configure_client_capabilities=configure_client_capabilities, - ) - - assert (tool_name in query_names) is visible - assert (tool_name in header_names) is visible - - -@pytest.mark.parametrize( - ("env_var", "query_param", "tool_name"), - [ - pytest.param( - MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, - "allow-pipeline-changes=1", - "create_connection", - id="pipeline-changes", - ), - pytest.param( - MCP_ALLOW_DESTRUCTIVE_TOOLS_ENV_VAR, - "allow-destructive-tools=1", - "permanently_delete_connection", - id="destructive-tools", - ), - pytest.param( - MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, - "allow-external-access=1", - "execute_external_api_query", - id="external-access", - ), - pytest.param( - MCP_INSIDERS_ENV_VAR, - "insiders=1", - "create_connector_builder_project", - id="insiders", - ), - ], -) -def test_http_falsy_environment_overrides_truthy_url_param( - monkeypatch: pytest.MonkeyPatch, - configure_client_capabilities: Any, - env_var: str, - query_param: str, - tool_name: str, -) -> None: - """A falsy deployment setting overrides a truthy per-request URL parameter.""" - for policy_env_var in ( - MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, - MCP_ALLOW_DESTRUCTIVE_TOOLS_ENV_VAR, - MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, - MCP_DOMAINS_DISABLED_ENV_VAR, - MCP_DOMAINS_ENV_VAR, - MCP_INSIDERS_ENV_VAR, - MCP_READONLY_MODE_ENV_VAR, - ): - monkeypatch.delenv(policy_env_var, raising=False) - monkeypatch.setenv(env_var, "0") - - names = _http_tool_names( - headers={}, - configure_client_capabilities=configure_client_capabilities, - url=f"http://testserver/mcp?{query_param}", - ) - - assert tool_name not in names - - def test_http_read_only_url_param_hides_non_read_only_tools( monkeypatch: pytest.MonkeyPatch, configure_client_capabilities: Any, ) -> None: """The shared read-only filter applies to HTTP `tools/list` requests.""" - for env_var in ( - MCP_ALLOW_PIPELINE_CHANGES_ENV_VAR, - MCP_ALLOW_DESTRUCTIVE_TOOLS_ENV_VAR, - MCP_ALLOW_EXTERNAL_ACCESS_ENV_VAR, - MCP_DOMAINS_DISABLED_ENV_VAR, - MCP_DOMAINS_ENV_VAR, - MCP_INSIDERS_ENV_VAR, - MCP_READONLY_MODE_ENV_VAR, - ): - monkeypatch.delenv(env_var, raising=False) + monkeypatch.delenv(MCP_READONLY_MODE_ENV_VAR, raising=False) unrestricted_names = _http_tool_names( headers={}, From 9034f6d2a0f4cc58b1b9e93c1879bffacc2168eb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:44:20 +0000 Subject: [PATCH 4/6] test(mcp): use httpx in HTTP tests Co-Authored-By: AJ Steers --- airbyte/mcp/_tool_utils.py | 4 ++-- tests/unit_tests/test_mcp_http.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index 3b0898246..94f163b06 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -476,8 +476,8 @@ def mcp_tool( # noqa: PLR0913 - mirrors the upstream decorator's kwargs """Decorate an MCP tool with deferred Airbyte registration metadata. When `insiders_only` is true, the tool is hidden unless insiders mode is on - (`AIRBYTE_MCP_INSIDERS` / `X-MCP-Insiders`). When `force_disabled` is true, the - function is returned unchanged and never registered. + (`AIRBYTE_MCP_INSIDERS` / `X-MCP-Insiders`). When `force_disabled` is true, the function + is returned unchanged and never registered. """ base_decorator = _mcp_tool( read_only=read_only, diff --git a/tests/unit_tests/test_mcp_http.py b/tests/unit_tests/test_mcp_http.py index 4f076d130..dbccdcd11 100644 --- a/tests/unit_tests/test_mcp_http.py +++ b/tests/unit_tests/test_mcp_http.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any -import httpx2 +import httpx import mcp_types as types import pytest from mcp.client.session import ClientSession @@ -154,17 +154,17 @@ async def _http_session( configure_client_capabilities(ui) http_app = _http_app() - async def capture_request(request: httpx2.Request) -> None: + async def capture_request(request: httpx.Request) -> None: if wire_headers is not None: wire_headers.append(request.headers.raw) - async def capture_response(response: httpx2.Response) -> None: + async def capture_response(response: httpx.Response) -> None: if response_headers is not None: response_headers.append(response.headers.raw) async with http_app.router.lifespan_context(http_app): - async with httpx2.AsyncClient( - transport=httpx2.ASGITransport(app=http_app), + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=http_app), base_url="http://testserver", headers=headers, event_hooks={ From 1f975e142eda3bce41574e5d235836db915df085 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:00:46 +0000 Subject: [PATCH 5/6] fix(mcp): preserve server-enforced read-only mode Co-Authored-By: AJ Steers --- pyproject.toml | 2 +- tests/unit_tests/test_mcp_http.py | 19 +++++++++++++++++++ uv.lock | 8 ++++---- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 34e4f7209..232f834a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "typing-extensions", "uuid7>=0.1.0,<1.0", "fastmcp>=4.0.9,<5.0", - "fastmcp-extensions==0.36.0.dev15740980248948", + "fastmcp-extensions==0.36.0.dev15741085057043", "starlette", # Transitive dependency of fastmcp, imported directly "httpx", # Transitive dependency of fastmcp, imported directly "mcp", # Transitive dependency of fastmcp, imported directly diff --git a/tests/unit_tests/test_mcp_http.py b/tests/unit_tests/test_mcp_http.py index dbccdcd11..eafd553c3 100644 --- a/tests/unit_tests/test_mcp_http.py +++ b/tests/unit_tests/test_mcp_http.py @@ -28,6 +28,7 @@ from fastmcp_extensions import CapabilityTokenMiddleware from fastmcp_extensions import DEFAULT_EXTENSIONS_HEADER from fastmcp_extensions.capability_tokens import decode_capability_token +from fastmcp_extensions.tool_filters import ENV_READONLY_MODE UI_EXTENSION = {"io.modelcontextprotocol/ui": {}} @@ -226,6 +227,24 @@ def test_http_read_only_url_param_hides_non_read_only_tools( assert "list_workspaces" in read_only_names +def test_http_read_only_url_param_cannot_disable_server_read_only_mode( + monkeypatch: pytest.MonkeyPatch, + configure_client_capabilities: Any, +) -> None: + """A request cannot disable read-only mode enforced by the server.""" + monkeypatch.setenv(ENV_READONLY_MODE, "1") + monkeypatch.delenv(MCP_READONLY_MODE_ENV_VAR, raising=False) + + names = _http_tool_names( + headers={}, + configure_client_capabilities=configure_client_capabilities, + url="http://testserver/mcp?read-only=false", + ) + + assert "run_connection_sync" not in names + assert "list_workspaces" in names + + @pytest.mark.parametrize("ui", [True, False], ids=["ui-client", "non-ui-client"]) def test_stdio_ui_tool_visibility( ui: bool, diff --git a/uv.lock b/uv.lock index 14e32927c..120c4b79f 100644 --- a/uv.lock +++ b/uv.lock @@ -248,7 +248,7 @@ requires-dist = [ { name = "duckdb", specifier = "==1.4.3" }, { name = "duckdb-engine", specifier = "==0.17.0" }, { name = "fastmcp", specifier = ">=4.0.9,<5.0" }, - { name = "fastmcp-extensions", specifier = "==0.36.0.dev15740980248948" }, + { name = "fastmcp-extensions", specifier = "==0.36.0.dev15741085057043" }, { name = "google-auth", specifier = ">=2.27.0,<3.0" }, { name = "google-cloud-bigquery", specifier = ">=3.12.0,<4.0" }, { name = "google-cloud-bigquery-storage", specifier = ">=2.25.0,<3.0" }, @@ -1352,7 +1352,7 @@ wheels = [ [[package]] name = "fastmcp-extensions" -version = "0.36.0.dev15740980248948" +version = "0.36.0.dev15741085057043" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp" }, @@ -1367,9 +1367,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/c1/8e9e2bcaa853d902be333cef96620fe4511d8806e79e158400595f106ffc/fastmcp_extensions-0.36.0.dev15740980248948.tar.gz", hash = "sha256:715060600426d2e8f5c0e2b54ec48a33a6ebb4a7711067d4944f78e35e3291d6", size = 365658, upload-time = "2026-10-30T00:32:29.735Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/f8/c118f37eae2d1a55f1121bb4a97452b6b7cc8f0844248a035d43e8b07a52/fastmcp_extensions-0.36.0.dev15741085057043.tar.gz", hash = "sha256:34f8edd1266c7cc8b223e7cf1c1b788df20d40179d2b080e1d32c3ba27aa84d4", size = 367705, upload-time = "2026-10-30T15:55:55.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/16/452e90d5ea93a7e87ecc4cbbdd230ba2dda8430cc236e411d595c4becdae/fastmcp_extensions-0.36.0.dev15740980248948-py3-none-any.whl", hash = "sha256:e72ac197438a169626ca6733af690195e8eed91427078ca329f939f22b106beb", size = 160064, upload-time = "2026-10-30T00:32:27.576Z" }, + { url = "https://files.pythonhosted.org/packages/75/bd/9b7a9248b7bfae206cf1e4d7677d2ec708160e5c94b7494e7213739300b6/fastmcp_extensions-0.36.0.dev15741085057043-py3-none-any.whl", hash = "sha256:f71c83fbe4f1e693c30ccf435a25d02fd4d041ad19d8d22d0fcec1bec8a88b86", size = 161215, upload-time = "2026-10-30T15:55:53.793Z" }, ] [[package]] From 50d591eb50bf5b585b3e50ca0e6d84016035d4e0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:41:52 +0000 Subject: [PATCH 6/6] chore(deps): bump fastmcp-extensions to 0.37.0 Co-Authored-By: AJ Steers --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 232f834a2..9f3255866 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "typing-extensions", "uuid7>=0.1.0,<1.0", "fastmcp>=4.0.9,<5.0", - "fastmcp-extensions==0.36.0.dev15741085057043", + "fastmcp-extensions>=0.37.0,<1.0.0", "starlette", # Transitive dependency of fastmcp, imported directly "httpx", # Transitive dependency of fastmcp, imported directly "mcp", # Transitive dependency of fastmcp, imported directly diff --git a/uv.lock b/uv.lock index 120c4b79f..7bc6f22a9 100644 --- a/uv.lock +++ b/uv.lock @@ -248,7 +248,7 @@ requires-dist = [ { name = "duckdb", specifier = "==1.4.3" }, { name = "duckdb-engine", specifier = "==0.17.0" }, { name = "fastmcp", specifier = ">=4.0.9,<5.0" }, - { name = "fastmcp-extensions", specifier = "==0.36.0.dev15741085057043" }, + { name = "fastmcp-extensions", specifier = ">=0.37.0,<1.0.0" }, { name = "google-auth", specifier = ">=2.27.0,<3.0" }, { name = "google-cloud-bigquery", specifier = ">=3.12.0,<4.0" }, { name = "google-cloud-bigquery-storage", specifier = ">=2.25.0,<3.0" }, @@ -1352,7 +1352,7 @@ wheels = [ [[package]] name = "fastmcp-extensions" -version = "0.36.0.dev15741085057043" +version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp" }, @@ -1367,9 +1367,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/f8/c118f37eae2d1a55f1121bb4a97452b6b7cc8f0844248a035d43e8b07a52/fastmcp_extensions-0.36.0.dev15741085057043.tar.gz", hash = "sha256:34f8edd1266c7cc8b223e7cf1c1b788df20d40179d2b080e1d32c3ba27aa84d4", size = 367705, upload-time = "2026-10-30T15:55:55.324Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/bb/64b2c70688c66010a5a588fe2215048111d937a7926acffcb16dc7ec59ec/fastmcp_extensions-0.37.0.tar.gz", hash = "sha256:776360581b0a01464423c09ba2132264a309ea744d822a1013642da4f19e465a", size = 367623, upload-time = "2026-10-30T16:39:50.509Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/bd/9b7a9248b7bfae206cf1e4d7677d2ec708160e5c94b7494e7213739300b6/fastmcp_extensions-0.36.0.dev15741085057043-py3-none-any.whl", hash = "sha256:f71c83fbe4f1e693c30ccf435a25d02fd4d041ad19d8d22d0fcec1bec8a88b86", size = 161215, upload-time = "2026-10-30T15:55:53.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/31/27573cdfc92717ba7b8582c2193a3a68ee4a66df9a998a1621c1a941302a/fastmcp_extensions-0.37.0-py3-none-any.whl", hash = "sha256:1ee8ef61719fee1c41ab97923dbed16d43d35204d238c3eb305d0ba2ff9fe4c0", size = 161036, upload-time = "2026-10-30T16:39:49.071Z" }, ] [[package]]