diff --git a/airbyte/mcp/__init__.py b/airbyte/mcp/__init__.py index 3834190de..c5c6eae9d 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 diff --git a/pyproject.toml b/pyproject.toml index e4a006c1a..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.35.0,<1.0.0", + "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/tests/unit_tests/test_mcp_http.py b/tests/unit_tests/test_mcp_http.py index 8d5c261a8..eafd553c3 100644 --- a/tests/unit_tests/test_mcp_http.py +++ b/tests/unit_tests/test_mcp_http.py @@ -20,11 +20,15 @@ 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_EXTENSIONS_HEADER, + MCP_READONLY_MODE_ENV_VAR, +) from airbyte.mcp.server import app 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": {}} @@ -143,6 +147,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, @@ -169,7 +174,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 +188,63 @@ 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, + ) + ) + ) + + +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.""" + monkeypatch.delenv(MCP_READONLY_MODE_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 + + +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 27b1dbfbe..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.35.0,<1.0.0" }, + { 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.35.0" +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/74/45/dcf55ab5ba27c238654c71d92af320eec2312ae3fad62572b78ea0b9f8fc/fastmcp_extensions-0.35.0.tar.gz", hash = "sha256:bad04d428433c424407277928bc44d77e356f9c2ff73b42aa9e058ad54173038", size = 357967, upload-time = "2026-10-14T05:17:25.382Z" } +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/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" }, + { 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]]