From 6e19b02a0da8b249a6c95fa3449cc24a2a6d8295 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:02:01 +0000 Subject: [PATCH 01/22] feat(mcp): Add tools to modify stream sync settings and refresh catalog on existing connections Adds two new MCP tools: - refresh_connection_catalog: Triggers a discover operation on a connection's source and updates the catalog with latest stream definitions and sync modes - set_stream_sync_mode: Safely changes the sync mode for a specific stream on a connection, with validation that the mode is supported Core logic lives in CloudConnection (connections.py) and api_util.py, with MCP tools as thin wrappers per the presentation layer pattern. Closes #993 Co-Authored-By: AJ Steers --- airbyte/_util/api_util.py | 36 ++++++++++ airbyte/cloud/connections.py | 121 +++++++++++++++++++++++++++++++++ airbyte/mcp/cloud.py | 125 +++++++++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+) diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index 650de345a..9dab3f4ff 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -2235,6 +2235,42 @@ def get_connection_catalog( ) +def get_refreshed_connection_catalog( + connection_id: str, + *, + api_root: str, + client_id: SecretString | None, + client_secret: SecretString | None, + bearer_token: SecretString | None, +) -> dict[str, Any]: + """Get the configured catalog for a connection with a refreshed schema from the source. + + Uses the Config API endpoint: POST /v1/web_backend/connections/get + with ``withRefreshedCatalog: true``, which triggers a discover operation + on the connection's source and returns the updated catalog. + + This is equivalent to clicking "Refresh source schema" in the Airbyte UI. + + Args: + connection_id: The connection ID to get catalog for. + api_root: The API root URL. + client_id: OAuth client ID. + client_secret: OAuth client secret. + bearer_token: Bearer token for authentication (alternative to client credentials). + + Returns: + Dictionary containing the connection info with refreshed syncCatalog. + """ + return _make_config_api_request( + path="/web_backend/connections/get", + json={"connectionId": connection_id, "withRefreshedCatalog": True}, + api_root=api_root, + client_id=client_id, + client_secret=client_secret, + bearer_token=bearer_token, + ) + + def replace_connection_catalog( connection_id: str, configured_catalog_dict: dict[str, Any], diff --git a/airbyte/cloud/connections.py b/airbyte/cloud/connections.py index cfa3149a1..45beff755 100644 --- a/airbyte/cloud/connections.py +++ b/airbyte/cloud/connections.py @@ -648,6 +648,127 @@ def import_raw_catalog(self, catalog: dict[str, Any]) -> None: bearer_token=self.workspace.bearer_token, ) + def refresh_catalog(self) -> dict[str, Any]: + """Refresh the connection's catalog by re-discovering the source schema. + + Triggers a discover operation on the connection's source connector, then + replaces the connection's catalog with the refreshed result. This is + equivalent to clicking "Refresh source schema" in the Airbyte UI. + + This is useful after pinning a new connector version that advertises + new streams or updated sync mode support. + + Returns: + The refreshed syncCatalog dict. + + Raises: + PyAirbyteInputError: If the refreshed catalog is empty or missing. + """ + refreshed_response = api_util.get_refreshed_connection_catalog( + connection_id=self.connection_id, + api_root=self.workspace.api_root, + client_id=self.workspace.client_id, + client_secret=self.workspace.client_secret, + bearer_token=self.workspace.bearer_token, + ) + refreshed_catalog = refreshed_response.get("syncCatalog") + if not refreshed_catalog: + raise PyAirbyteInputError( + message="Refreshed catalog is empty.", + context={"connection_id": self.connection_id}, + ) + + api_util.replace_connection_catalog( + connection_id=self.connection_id, + configured_catalog_dict=refreshed_catalog, + api_root=self.workspace.api_root, + client_id=self.workspace.client_id, + client_secret=self.workspace.client_secret, + bearer_token=self.workspace.bearer_token, + ) + return refreshed_catalog + + def set_stream_sync_mode( + self, + stream_name: str, + *, + sync_mode: str, + destination_sync_mode: str | None = None, + cursor_field: str | None = None, + ) -> None: + """Set the sync mode for a specific stream on this connection. + + Safely modifies only the specified stream in the connection's syncCatalog. + Validates that the requested sync mode is supported by the stream before + applying the change. + + Args: + stream_name: The name of the stream to modify. + sync_mode: The source sync mode to set (``"incremental"`` or ``"full_refresh"``). + destination_sync_mode: The destination sync mode to set + (``"append"``, ``"overwrite"``, or ``"append_dedup"``). If not provided, + the existing destination sync mode is preserved. + cursor_field: The cursor field to use for incremental syncs. Required when + switching to incremental mode if the stream does not have a default + cursor. If not provided, the existing cursor field is preserved. + + Raises: + PyAirbyteInputError: If the stream is not found in the catalog, or if the + requested sync mode is not in the stream's ``supportedSyncModes``. + """ + catalog = self.dump_raw_catalog() + if not catalog or "streams" not in catalog: + raise PyAirbyteInputError( + message="Connection catalog is empty or missing.", + context={"connection_id": self.connection_id}, + ) + + # Find the target stream + target_entry: dict[str, Any] | None = None + for entry in catalog["streams"]: + stream_def = entry.get("stream", {}) + if stream_def.get("name") == stream_name: + target_entry = entry + break + + if target_entry is None: + available_streams = [e.get("stream", {}).get("name") for e in catalog["streams"]] + raise PyAirbyteInputError( + message=f"Stream '{stream_name}' not found in connection catalog.", + context={ + "connection_id": self.connection_id, + "available_streams": available_streams, + }, + ) + + # Validate the sync mode is supported + stream_def = target_entry.get("stream", {}) + supported_sync_modes: list[str] = stream_def.get("supportedSyncModes", []) + if sync_mode not in supported_sync_modes: + raise PyAirbyteInputError( + message=(f"Sync mode '{sync_mode}' is not supported by stream '{stream_name}'."), + context={ + "stream_name": stream_name, + "requested_sync_mode": sync_mode, + "supported_sync_modes": supported_sync_modes, + }, + ) + + # Update the stream config + config = target_entry.get("config", {}) + config["syncMode"] = sync_mode + + if destination_sync_mode is not None: + config["destinationSyncMode"] = destination_sync_mode + + if cursor_field is not None: + config["cursorField"] = [cursor_field] + + target_entry["config"] = config + + # Save the updated catalog + self.import_raw_catalog(catalog) + def rename(self, name: str) -> CloudConnection: """Rename the connection. diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 594bee0db..1df17dfd9 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -2586,6 +2586,131 @@ def update_cloud_connection( ) +@mcp_tool( + destructive=True, + open_world=True, + extra_help_text=CLOUD_AUTH_TIP_TEXT, +) +def refresh_connection_catalog( + ctx: Context, + connection_id: Annotated[ + str, + Field(description="The ID of the connection to refresh the catalog for."), + ], + *, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + ], +) -> str: + """Refresh the catalog for a connection by re-discovering the source schema. + + Triggers a discover operation on the connection's source connector and updates + the connection's catalog with the latest stream definitions and supported sync + modes. This is equivalent to clicking "Refresh source schema" in the Airbyte UI. + + This is useful after pinning a new connector version that advertises new streams + or updated sync mode support. + """ + check_guid_created_in_session(connection_id) + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + connection = workspace.get_connection(connection_id=connection_id) + + refreshed_catalog = connection.refresh_catalog() + stream_count = len(refreshed_catalog.get("streams", [])) + + return ( + f"Successfully refreshed catalog for connection '{connection_id}'. " + f"Catalog now contains {stream_count} stream(s). " + f"URL: {connection.connection_url}" + ) + + +@mcp_tool( + destructive=True, + open_world=True, + extra_help_text=CLOUD_AUTH_TIP_TEXT, +) +def set_stream_sync_mode( + ctx: Context, + connection_id: Annotated[ + str, + Field(description="The ID of the connection to modify."), + ], + stream_name: Annotated[ + str, + Field(description="The name of the stream to change the sync mode for."), + ], + sync_mode: Annotated[ + Literal["incremental", "full_refresh"], + Field(description="The source sync mode to set: 'incremental' or 'full_refresh'."), + ], + *, + destination_sync_mode: Annotated[ + Literal["append", "overwrite", "append_dedup"] | None, + Field( + description=( + "The destination sync mode to set: 'append', 'overwrite', or 'append_dedup'. " + "If not provided, the existing destination sync mode is preserved." + ), + default=None, + ), + ], + cursor_field: Annotated[ + str | None, + Field( + description=( + "The cursor field to use for incremental syncs. " + "Required when switching to incremental mode if the stream does not have " + "a default cursor. If not provided, the existing cursor field is preserved." + ), + default=None, + ), + ], + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + ], +) -> str: + """Set the sync mode for a specific stream on a connection. + + Safely modifies only the specified stream in the connection's syncCatalog. + Validates that the requested sync mode is supported by the stream before + applying the change. + + This is useful when switching a stream from full_refresh to incremental + (or vice versa) after a connector version upgrade that adds incremental support. + """ + check_guid_created_in_session(connection_id) + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + connection = workspace.get_connection(connection_id=connection_id) + + connection.set_stream_sync_mode( + stream_name=stream_name, + sync_mode=sync_mode, + destination_sync_mode=destination_sync_mode, + cursor_field=cursor_field, + ) + + return ( + f"Successfully set sync mode for stream '{stream_name}' " + f"on connection '{connection_id}' to '{sync_mode}'" + + ( + f" with destination sync mode '{destination_sync_mode}'" + if destination_sync_mode + else "" + ) + + (f" and cursor field '{cursor_field}'" if cursor_field else "") + + f". URL: {connection.connection_url}" + ) + + @mcp_tool( read_only=True, idempotent=True, From e6fce67aa9112487e07f9b1922177ef1bf43d772 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:16:59 +0000 Subject: [PATCH 02/22] fix: Add namespace disambiguation and cursor field guard for set_stream_sync_mode Addresses CodeRabbit review feedback: - Add stream_namespace parameter to disambiguate same-named streams in different namespaces. Raises PyAirbyteInputError when name is ambiguous. - Add fail-fast guard when switching to incremental mode without a usable cursor field (no existing cursor, no default cursor, no source-defined cursor). Co-Authored-By: AJ Steers --- airbyte/cloud/connections.py | 65 ++++++++++++++++++++++++++++++------ airbyte/mcp/cloud.py | 11 ++++++ 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/airbyte/cloud/connections.py b/airbyte/cloud/connections.py index 45beff755..47a029ca5 100644 --- a/airbyte/cloud/connections.py +++ b/airbyte/cloud/connections.py @@ -693,6 +693,7 @@ def set_stream_sync_mode( stream_name: str, *, sync_mode: str, + stream_namespace: str | None = None, destination_sync_mode: str | None = None, cursor_field: str | None = None, ) -> None: @@ -705,6 +706,8 @@ def set_stream_sync_mode( Args: stream_name: The name of the stream to modify. sync_mode: The source sync mode to set (``"incremental"`` or ``"full_refresh"``). + stream_namespace: The namespace of the stream to modify. If not provided and + multiple streams share the same name, a ``PyAirbyteInputError`` is raised. destination_sync_mode: The destination sync mode to set (``"append"``, ``"overwrite"``, or ``"append_dedup"``). If not provided, the existing destination sync mode is preserved. @@ -713,8 +716,10 @@ def set_stream_sync_mode( cursor. If not provided, the existing cursor field is preserved. Raises: - PyAirbyteInputError: If the stream is not found in the catalog, or if the - requested sync mode is not in the stream's ``supportedSyncModes``. + PyAirbyteInputError: If the stream is not found in the catalog, the requested + sync mode is not in the stream's ``supportedSyncModes``, the stream name + is ambiguous (multiple matches without a namespace), or incremental mode + is requested without a usable cursor field. """ catalog = self.dump_raw_catalog() if not catalog or "streams" not in catalog: @@ -723,16 +728,21 @@ def set_stream_sync_mode( context={"connection_id": self.connection_id}, ) - # Find the target stream - target_entry: dict[str, Any] | None = None + # Find all matching streams by name (and optionally namespace) + matching_entries: list[dict[str, Any]] = [] for entry in catalog["streams"]: stream_def = entry.get("stream", {}) - if stream_def.get("name") == stream_name: - target_entry = entry - break - - if target_entry is None: - available_streams = [e.get("stream", {}).get("name") for e in catalog["streams"]] + if stream_def.get("name") != stream_name: + continue + if stream_namespace is not None and stream_def.get("namespace") != stream_namespace: + continue + matching_entries.append(entry) + + if not matching_entries: + available_streams = [ + f"{e.get('stream', {}).get('namespace', '')}.{e.get('stream', {}).get('name', '')}" + for e in catalog["streams"] + ] raise PyAirbyteInputError( message=f"Stream '{stream_name}' not found in connection catalog.", context={ @@ -741,6 +751,22 @@ def set_stream_sync_mode( }, ) + if len(matching_entries) > 1: + matching_namespaces = [e.get("stream", {}).get("namespace") for e in matching_entries] + raise PyAirbyteInputError( + message=( + f"Stream name '{stream_name}' is ambiguous " + f"({len(matching_entries)} matches found)." + ), + context={ + "connection_id": self.connection_id, + "stream_name": stream_name, + "matching_namespaces": matching_namespaces, + }, + ) + + target_entry = matching_entries[0] + # Validate the sync mode is supported stream_def = target_entry.get("stream", {}) supported_sync_modes: list[str] = stream_def.get("supportedSyncModes", []) @@ -755,7 +781,24 @@ def set_stream_sync_mode( ) # Update the stream config - config = target_entry.get("config", {}) + config = target_entry.get("config") or {} + + # Guard: fail fast if switching to incremental without a usable cursor + if sync_mode == "incremental": + existing_cursor = config.get("cursorField") or stream_def.get("defaultCursorField") + source_defined_cursor = bool(stream_def.get("sourceDefinedCursor")) + if cursor_field is None and not existing_cursor and not source_defined_cursor: + raise PyAirbyteInputError( + message=( + f"Stream '{stream_name}' needs a cursor field before switching " + "to incremental sync mode." + ), + context={ + "connection_id": self.connection_id, + "stream_name": stream_name, + }, + ) + config["syncMode"] = sync_mode if destination_sync_mode is not None: diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 1df17dfd9..0375204ce 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -2649,6 +2649,16 @@ def set_stream_sync_mode( Field(description="The source sync mode to set: 'incremental' or 'full_refresh'."), ], *, + stream_namespace: Annotated[ + str | None, + Field( + description=( + "The namespace of the stream to modify. Required when multiple streams " + "share the same name but differ by namespace." + ), + default=None, + ), + ], destination_sync_mode: Annotated[ Literal["append", "overwrite", "append_dedup"] | None, Field( @@ -2694,6 +2704,7 @@ def set_stream_sync_mode( connection.set_stream_sync_mode( stream_name=stream_name, sync_mode=sync_mode, + stream_namespace=stream_namespace, destination_sync_mode=destination_sync_mode, cursor_field=cursor_field, ) From f5ba63cb26451e08d47eaf839e1322c08987bd83 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:17:46 +0000 Subject: [PATCH 03/22] feat(mcp): add out-of-band secret intake core Co-Authored-By: AJ Steers --- airbyte/mcp/_secret_intake.py | 260 ++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 airbyte/mcp/_secret_intake.py diff --git a/airbyte/mcp/_secret_intake.py b/airbyte/mcp/_secret_intake.py new file mode 100644 index 000000000..8d782fa92 --- /dev/null +++ b/airbyte/mcp/_secret_intake.py @@ -0,0 +1,260 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Out-of-band intake for connector secret values.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import secrets +import threading +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from starlette.responses import JSONResponse +from starlette.routing import Route + +from airbyte.mcp._tool_utils import _resolve_transport_bearer_token + + +if TYPE_CHECKING: + from collections.abc import Iterable + + from starlette.requests import Request + +DEFAULT_INTAKE_TTL_SECONDS = 600 +SECRET_INTAKE_PREFIX = "secret_intake::" +_SIGNING_KEY_ENV = "AIRBYTE_MCP_SECRET_INTAKE_SIGNING_KEY" +_PROCESS_SIGNING_KEY = secrets.token_bytes(32) + + +class SecretIntakeError(ValueError): + """Raised when a secret intake token or reference is invalid.""" + + +@dataclass +class _IntakeRecord: + allowed_fields: frozenset[str] + expires_at: float + tenant_claim: str + secrets: dict[str, str] + used: bool = False + + +_INTAKES: dict[str, _IntakeRecord] = {} +_INTAKES_LOCK = threading.Lock() + + +def _signing_key() -> bytes: + configured = os.getenv(_SIGNING_KEY_ENV, "") + return configured.encode() if configured else _PROCESS_SIGNING_KEY + + +def _tenant_claim(token: str | None = None) -> str: + if token is None: + try: + token = _resolve_transport_bearer_token() + except Exception: + token = "" + return hashlib.sha256(token.encode()).hexdigest() if token else "" + + +def _encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode() + + +def _decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def mint_intake_token( + allowed_secret_fields: Iterable[str], + *, + tenant_claim: str | None = None, + ttl_seconds: int = DEFAULT_INTAKE_TTL_SECONDS, +) -> str: + """Mint a signed, one-time token for collecting connector secrets. + + The signing key comes from `AIRBYTE_MCP_SECRET_INTAKE_SIGNING_KEY`. When + unset, a process-random key is used, which is suitable only for a + single-process spike. + """ + fields = frozenset(allowed_secret_fields) + if not fields or any(not field for field in fields): + raise SecretIntakeError("At least one non-empty secret field is required.") + if ttl_seconds <= 0: + raise SecretIntakeError("The intake token TTL must be positive.") + + intake_id = secrets.token_urlsafe(18) + expires_at = time.time() + ttl_seconds + claim = tenant_claim if tenant_claim is not None else _tenant_claim() + payload = { + "intake_id": intake_id, + "allowed_fields": sorted(fields), + "tenant_claim": claim, + "expires_at": expires_at, + } + encoded_payload = _encode(json.dumps(payload, separators=(",", ":")).encode()) + signature = hmac.new( + _signing_key(), + encoded_payload.encode(), + hashlib.sha256, + ).digest() + with _INTAKES_LOCK: + _INTAKES[intake_id] = _IntakeRecord( + allowed_fields=fields, + expires_at=expires_at, + tenant_claim=claim, + secrets={}, + ) + _prune_expired_locked(now=expires_at) + return f"{encoded_payload}.{_encode(signature)}" + + +def _decode_and_verify(token: str) -> tuple[str, _IntakeRecord]: + try: + encoded_payload, encoded_signature = token.split(".", 1) + expected_signature = hmac.new( + _signing_key(), + encoded_payload.encode(), + hashlib.sha256, + ).digest() + if not hmac.compare_digest(_decode(encoded_signature), expected_signature): + raise SecretIntakeError("Invalid intake token.") + payload = json.loads(_decode(encoded_payload)) + intake_id = payload["intake_id"] + allowed_fields = frozenset(payload["allowed_fields"]) + tenant_claim = payload["tenant_claim"] + expires_at = float(payload["expires_at"]) + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + raise SecretIntakeError("Invalid intake token.") from error + + if not isinstance(intake_id, str) or not isinstance(tenant_claim, str): + raise SecretIntakeError("Invalid intake token.") + if time.time() >= expires_at: + raise SecretIntakeError("Intake token expired.") + with _INTAKES_LOCK: + record = _INTAKES.get(intake_id) + if ( + record is None + or record.used + or record.expires_at != expires_at + or record.allowed_fields != allowed_fields + or record.tenant_claim != tenant_claim + ): + raise SecretIntakeError("Invalid intake token.") + return intake_id, record + + +def _prune_expired_locked(*, now: float | None = None) -> None: + current_time = time.time() if now is None else now + for intake_id, record in list(_INTAKES.items()): + if record.expires_at <= current_time: + del _INTAKES[intake_id] + + +def store_intake_secrets(token: str, values: dict[str, str]) -> dict[str, str]: + """Validate and consume a token after storing its submitted values.""" + intake_id, _ = _decode_and_verify(token) + if not isinstance(values, dict) or not values: + raise SecretIntakeError("Secret values are required.") + if any( + not isinstance(field, str) or not isinstance(value, str) for field, value in values.items() + ): + raise SecretIntakeError("Secret field names and values must be strings.") + + with _INTAKES_LOCK: + record = _INTAKES.get(intake_id) + if record is None or record.used or time.time() >= record.expires_at: + raise SecretIntakeError("Invalid intake token.") + if not set(values).issubset(record.allowed_fields): + raise SecretIntakeError("One or more secret fields are not allowed.") + record.secrets = dict(values) + record.used = True + return {field: f"{SECRET_INTAKE_PREFIX}{intake_id}/{field}" for field in values} + + +def resolve_intake_secrets(config: dict[str, Any]) -> dict[str, Any]: + """Resolve `secret_intake::` references for the current transport tenant.""" + caller_claim = _tenant_claim() + + def resolve(value: object) -> object: + if isinstance(value, dict): + return {key: resolve(item) for key, item in value.items()} + if isinstance(value, list): + return [resolve(item) for item in value] + if not isinstance(value, str) or not value.startswith(SECRET_INTAKE_PREFIX): + return value + + reference = value[len(SECRET_INTAKE_PREFIX) :] + try: + intake_id, field = reference.split("/", 1) + except ValueError as error: + raise SecretIntakeError("Invalid secret intake reference.") from error + with _INTAKES_LOCK: + record = _INTAKES.get(intake_id) + if ( + record is None + or not record.used + or time.time() >= record.expires_at + or record.tenant_claim != caller_claim + or field not in record.secrets + ): + raise SecretIntakeError("Invalid secret intake reference.") + return record.secrets[field] + + return resolve(config) + + +async def _secret_intake_endpoint(request: Request) -> JSONResponse: + """Accept secret values without returning them to the caller.""" + if request.method == "OPTIONS": + return _cors_response({}, status_code=204) + authorization = request.headers.get("authorization", "") + if not authorization.lower().startswith("bearer "): + return _cors_response({"error": "Unauthorized."}, status_code=401) + token = authorization[7:].strip() + try: + body = await request.json() + secret_values = body.get("secrets") if isinstance(body, dict) else None + secret_refs = store_intake_secrets(token, secret_values) + except (SecretIntakeError, ValueError, TypeError): + return _cors_response({"error": "Invalid secret intake request."}, status_code=403) + return _cors_response({"secret_refs": secret_refs}) + + +def _cors_response(body: dict[str, Any], *, status_code: int = 200) -> JSONResponse: + return JSONResponse( + body, + status_code=status_code, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + }, + ) + + +def secret_intake_routes() -> list[Route]: + """Return Starlette routes for the out-of-band secret intake endpoint.""" + return [ + Route( + "/secret-intake", + _secret_intake_endpoint, + methods=["POST", "OPTIONS"], + ), + ] + + +__all__ = [ + "DEFAULT_INTAKE_TTL_SECONDS", + "SECRET_INTAKE_PREFIX", + "SecretIntakeError", + "mint_intake_token", + "resolve_intake_secrets", + "secret_intake_routes", + "store_intake_secrets", +] From daca141934098bc55848831a7938172092cf676f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:25:07 +0000 Subject: [PATCH 04/22] feat(mcp): add connector config form app Co-Authored-By: AJ Steers --- airbyte/mcp/_arg_resolvers.py | 14 + airbyte/mcp/_secret_intake.py | 15 +- airbyte/mcp/interactive/__init__.py | 5 + .../interactive/_connector_config_form_ui.py | 258 ++++++++++++++++++ airbyte/mcp/server.py | 7 + .../test_mcp_connector_config_form.py | 61 +++++ tests/unit_tests/test_mcp_secret_intake.py | 133 +++++++++ 7 files changed, 486 insertions(+), 7 deletions(-) create mode 100644 airbyte/mcp/interactive/_connector_config_form_ui.py create mode 100644 tests/unit_tests/test_mcp_connector_config_form.py create mode 100644 tests/unit_tests/test_mcp_secret_intake.py diff --git a/airbyte/mcp/_arg_resolvers.py b/airbyte/mcp/_arg_resolvers.py index d0ff39d49..a0df2bc78 100644 --- a/airbyte/mcp/_arg_resolvers.py +++ b/airbyte/mcp/_arg_resolvers.py @@ -15,6 +15,7 @@ from airbyte.constants import SECRETS_HYDRATION_PREFIX from airbyte.mcp._guards import raise_if_untrusted_execution_context +from airbyte.mcp._secret_intake import SECRET_INTAKE_PREFIX, resolve_intake_secrets from airbyte.secrets.hydration import deep_update, detect_hardcoded_secrets from airbyte.secrets.util import get_secret @@ -30,6 +31,17 @@ def _contains_secret_reference(value: object) -> bool: return False +def _contains_secret_intake_reference(value: object) -> bool: + """Return whether `value` contains an out-of-band secret reference.""" + if isinstance(value, str): + return value.startswith(SECRET_INTAKE_PREFIX) + if isinstance(value, dict): + return any(_contains_secret_intake_reference(item) for item in value.values()) + if isinstance(value, list): + return any(_contains_secret_intake_reference(item) for item in value) + return False + + # Hint: Null result if input is Null @overload def resolve_list_of_strings(value: None) -> None: ... @@ -165,6 +177,8 @@ def _raise_invalid_type(file_config: object) -> None: raise_if_untrusted_execution_context( "Resolving inline secret references (`secret_reference::`) in connector config" ) + if _contains_secret_intake_reference(config_dict): + config_dict = resolve_intake_secrets(config_dict) if config_dict and config_spec_jsonschema is not None: hardcoded_secrets: list[list[str]] = detect_hardcoded_secrets( diff --git a/airbyte/mcp/_secret_intake.py b/airbyte/mcp/_secret_intake.py index 8d782fa92..0623ff124 100644 --- a/airbyte/mcp/_secret_intake.py +++ b/airbyte/mcp/_secret_intake.py @@ -12,7 +12,7 @@ import threading import time from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from starlette.responses import JSONResponse from starlette.routing import Route @@ -83,8 +83,8 @@ def mint_intake_token( single-process spike. """ fields = frozenset(allowed_secret_fields) - if not fields or any(not field for field in fields): - raise SecretIntakeError("At least one non-empty secret field is required.") + if any(not field for field in fields): + raise SecretIntakeError("Secret field names must be non-empty.") if ttl_seconds <= 0: raise SecretIntakeError("The intake token TTL must be positive.") @@ -104,13 +104,13 @@ def mint_intake_token( hashlib.sha256, ).digest() with _INTAKES_LOCK: + _prune_expired_locked() _INTAKES[intake_id] = _IntakeRecord( allowed_fields=fields, expires_at=expires_at, tenant_claim=claim, secrets={}, ) - _prune_expired_locked(now=expires_at) return f"{encoded_payload}.{_encode(signature)}" @@ -206,10 +206,10 @@ def resolve(value: object) -> object: raise SecretIntakeError("Invalid secret intake reference.") return record.secrets[field] - return resolve(config) + return cast(dict[str, Any], resolve(config)) -async def _secret_intake_endpoint(request: Request) -> JSONResponse: +async def secret_intake_endpoint(request: Request) -> JSONResponse: """Accept secret values without returning them to the caller.""" if request.method == "OPTIONS": return _cors_response({}, status_code=204) @@ -243,7 +243,7 @@ def secret_intake_routes() -> list[Route]: return [ Route( "/secret-intake", - _secret_intake_endpoint, + secret_intake_endpoint, methods=["POST", "OPTIONS"], ), ] @@ -255,6 +255,7 @@ def secret_intake_routes() -> list[Route]: "SecretIntakeError", "mint_intake_token", "resolve_intake_secrets", + "secret_intake_endpoint", "secret_intake_routes", "store_intake_secrets", ] diff --git a/airbyte/mcp/interactive/__init__.py b/airbyte/mcp/interactive/__init__.py index 07df22b6d..29a45bf7a 100644 --- a/airbyte/mcp/interactive/__init__.py +++ b/airbyte/mcp/interactive/__init__.py @@ -5,7 +5,10 @@ from typing import TYPE_CHECKING +from fastmcp_extensions import register_mcp_resources + from airbyte.mcp._tool_utils import register_mcp_tools +from airbyte.mcp.interactive._connector_config_form_ui import show_connector_config_form from airbyte.mcp.interactive._registry_ui import show_connectors_list from airbyte.mcp.interactive._sync_history_ui import show_connection_sync_history from airbyte.mcp.interactive._workspace_sync_status_ui import show_workspace_sync_status @@ -18,10 +21,12 @@ def register_interactive_tools(app: FastMCP) -> None: """Register UI-presenting tools.""" register_mcp_tools(app, mcp_module="interactive") + register_mcp_resources(app, mcp_module="_connector_config_form_ui") __all__ = [ "register_interactive_tools", + "show_connector_config_form", "show_connectors_list", "show_connection_sync_history", "show_workspace_sync_status", diff --git a/airbyte/mcp/interactive/_connector_config_form_ui.py b/airbyte/mcp/interactive/_connector_config_form_ui.py new file mode 100644 index 000000000..4d9f61691 --- /dev/null +++ b/airbyte/mcp/interactive/_connector_config_form_ui.py @@ -0,0 +1,258 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Interactive connector configuration form MCP App.""" + +from __future__ import annotations + +import json +import os +from collections.abc import Mapping +from typing import Annotated, Any +from urllib.parse import urlparse + +from fastmcp.apps import UI_MIME_TYPE, AppConfig, ResourceCSP +from fastmcp.tools.base import ToolResult +from fastmcp_extensions import mcp_resource +from pydantic import Field + +from airbyte import exceptions as exc +from airbyte._util.registry_spec import get_connector_spec_from_registry +from airbyte.mcp._secret_intake import mint_intake_token +from airbyte.mcp._tool_utils import INTERACTIVE_UI_ANNOTATION, mcp_tool + + +CONNECTOR_FORM_RESOURCE_URI = "ui://airbyte/connector-config-form" +MCP_SERVER_URL_ENV = "MCP_SERVER_URL" +DEFAULT_HTTP_PORT = 8080 +DEFAULT_MCP_SERVER_URL = f"http://localhost:{DEFAULT_HTTP_PORT}" + +_HTML_RESOURCE = """ +Connector configuration + +

Connector configuration

+""" + + +def _server_origin() -> str: + server_url = os.getenv(MCP_SERVER_URL_ENV, "").strip() or DEFAULT_MCP_SERVER_URL + parsed = urlparse(server_url) + return f"{parsed.scheme}://{parsed.netloc}" + + +def _intake_endpoint() -> str: + return f"{_server_origin()}/secret-intake" + + +def _schema_secret_paths(schema: Mapping[str, Any], prefix: str = "") -> set[str]: + paths: set[str] = set() + properties = schema.get("properties", {}) + if not isinstance(properties, Mapping): + return paths + for name, child in properties.items(): + if not isinstance(name, str) or not isinstance(child, Mapping): + continue + path = f"{prefix}.{name}" if prefix else name + if child.get("airbyte_secret") is True: + paths.add(path) + paths.update(_schema_secret_paths(child, path)) + return paths + + +def _paths_present(value: object, prefix: str = "") -> set[str]: + if not isinstance(value, Mapping): + return set() + paths: set[str] = set() + for name, child in value.items(): + if not isinstance(name, str): + continue + path = f"{prefix}.{name}" if prefix else name + paths.add(path) + paths.update(_paths_present(child, path)) + return paths + + +@mcp_resource( + CONNECTOR_FORM_RESOURCE_URI, + "Self-contained Airbyte connector configuration form.", + UI_MIME_TYPE, +) +def connector_config_form_resource() -> str: + """Return the raw HTML connector configuration form.""" + return _HTML_RESOURCE + + +@mcp_tool( + read_only=True, + idempotent=True, + open_world=True, + annotations={INTERACTIVE_UI_ANNOTATION: True}, + app=AppConfig( + resource_uri=CONNECTOR_FORM_RESOURCE_URI, + csp=ResourceCSP(connect_domains=[_server_origin()]), + ), +) +def show_connector_config_form( + connector_name: Annotated[ + str, + Field(description="Connector name, such as `source-pokeapi`."), + ], + config_defaults: Annotated[ + dict[str, Any] | str | None, + Field( + description=( + "Non-secret configuration defaults to prefill. Secret values must " + "be entered by the user in the form." + ), + ), + ] = None, +) -> ToolResult: + """Show a hosted-safe connector configuration form.""" + if config_defaults is None: + config_defaults = {} + if isinstance(config_defaults, str): + try: + config_defaults = json.loads(config_defaults) + except json.JSONDecodeError as error: + raise exc.PyAirbyteInputError( + message="config_defaults must be a JSON object.", + context={"connector_name": connector_name}, + ) from error + if not isinstance(config_defaults, dict): + raise exc.PyAirbyteInputError( + message="config_defaults must be an object.", + context={"connector_name": connector_name}, + ) + + spec_schema = get_connector_spec_from_registry(connector_name, platform="cloud") + if spec_schema is None: + spec_schema = get_connector_spec_from_registry(connector_name, platform="oss") + if not spec_schema: + raise exc.PyAirbyteInputError( + message=f"Could not fetch a configuration schema for '{connector_name}'.", + context={"connector_name": connector_name}, + ) + secret_fields = sorted(_schema_secret_paths(spec_schema)) + supplied_fields = _paths_present(config_defaults) + supplied_secrets = sorted(supplied_fields.intersection(secret_fields)) + if supplied_secrets: + raise exc.PyAirbyteInputError( + message=( + "Secret values cannot be provided in config_defaults. " + "The user must enter them in the form." + ), + context={"secret_fields": supplied_secrets}, + ) + + intake_token = mint_intake_token(secret_fields) + content = json.dumps( + { + "connector_name": connector_name, + "non_secret_defaults": config_defaults, + "secret_fields": secret_fields, + "note": ( + "The user will enter secrets in the form. The model will only " + "receive opaque secret_intake:: references." + ), + }, + separators=(",", ":"), + ) + structured_content = { + "connector_name": connector_name, + "schema": spec_schema, + "spec_schema": spec_schema, + "non_secret_defaults": config_defaults, + "secret_fields": secret_fields, + "intake_token": intake_token, + "intake_endpoint": _intake_endpoint(), + } + return ToolResult(content=content, structured_content=structured_content) + + +__all__ = ["show_connector_config_form"] diff --git a/airbyte/mcp/server.py b/airbyte/mcp/server.py index dc053bdc5..53230adf9 100644 --- a/airbyte/mcp/server.py +++ b/airbyte/mcp/server.py @@ -73,6 +73,7 @@ from airbyte._util.telemetry import DO_NOT_TRACK, PYAIRBYTE_APP_TRACKING_KEY from airbyte.constants import AIRBYTE_OFFLINE_MODE, _str_to_bool, is_hosted_mcp_mode from airbyte.mcp._config import load_secrets_to_env_vars +from airbyte.mcp._secret_intake import secret_intake_endpoint from airbyte.mcp._tool_utils import ( AIRBYTE_EXCLUDE_MODULES_CONFIG_ARG, AIRBYTE_INCLUDE_MODULES_CONFIG_ARG, @@ -383,6 +384,12 @@ async def health_check(request: Request) -> JSONResponse: # noqa: ARG001, RUF02 return JSONResponse({"status": "ok"}) +@app.custom_route("/secret-intake", methods=["POST", "OPTIONS"]) +async def secret_intake(request: Request) -> JSONResponse: + """Accept connector secrets out of band from the MCP tool call.""" + return await secret_intake_endpoint(request) + + def main() -> None: """@private Main entry point for the MCP server. diff --git a/tests/unit_tests/test_mcp_connector_config_form.py b/tests/unit_tests/test_mcp_connector_config_form.py new file mode 100644 index 000000000..b2b14619c --- /dev/null +++ b/tests/unit_tests/test_mcp_connector_config_form.py @@ -0,0 +1,61 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Tests for the connector configuration form MCP App.""" + +from __future__ import annotations + +import json + +import pytest + +from airbyte.exceptions import PyAirbyteInputError +from airbyte.mcp.interactive import _connector_config_form_ui as form + + +SCHEMA = { + "type": "object", + "properties": { + "credentials": { + "type": "object", + "properties": { + "api_key": {"type": "string", "airbyte_secret": True}, + "username": {"type": "string"}, + }, + }, + "region": {"type": "string"}, + }, +} + + +def test_form_result_contains_schema_and_opaque_intake_data( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + form, "get_connector_spec_from_registry", lambda *args, **kwargs: SCHEMA + ) + + result = form.show_connector_config_form( + "source-example", + {"credentials": {"username": "user"}, "region": "us-east-1"}, + ) + + content = result.content[0].text + structured = result.structured_content + assert "source-example" in content + assert "api_key" in content + assert structured["spec_schema"] == SCHEMA + assert structured["intake_endpoint"].endswith("/secret-intake") + assert structured["intake_token"] + assert "secret-value" not in content + assert "secret-value" not in json.dumps(structured) + + +def test_form_rejects_secret_defaults(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + form, "get_connector_spec_from_registry", lambda *args, **kwargs: SCHEMA + ) + + with pytest.raises(PyAirbyteInputError, match="Secret values cannot"): + form.show_connector_config_form( + "source-example", + {"credentials": {"api_key": "secret-value"}}, + ) diff --git a/tests/unit_tests/test_mcp_secret_intake.py b/tests/unit_tests/test_mcp_secret_intake.py new file mode 100644 index 000000000..87a507185 --- /dev/null +++ b/tests/unit_tests/test_mcp_secret_intake.py @@ -0,0 +1,133 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Tests for out-of-band MCP secret intake.""" + +from __future__ import annotations + +import json +import time + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from airbyte.mcp import _secret_intake +from airbyte.mcp._secret_intake import ( + SecretIntakeError, + mint_intake_token, + resolve_intake_secrets, + secret_intake_routes, + store_intake_secrets, +) +from airbyte.mcp._arg_resolvers import resolve_connector_config + + +def _intake_id(token: str) -> str: + payload = json.loads(_secret_intake._decode(token.split(".", 1)[0])) + return payload["intake_id"] + + +@pytest.fixture +def local_tenant(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_secret_intake, "_resolve_transport_bearer_token", lambda: "") + + +def test_token_roundtrip_and_replay(local_tenant: None) -> None: + token = mint_intake_token(["password"]) + + assert store_intake_secrets(token, {"password": "secret-value"}) == { + "password": f"secret_intake::{_intake_id(token)}/password" + } + with pytest.raises(SecretIntakeError, match="Invalid intake token"): + store_intake_secrets(token, {"password": "another-value"}) + + +def test_token_rejects_tampering_and_disallowed_fields(local_tenant: None) -> None: + token = mint_intake_token(["password"]) + encoded_payload, signature = token.split(".") + + with pytest.raises(SecretIntakeError, match="Invalid intake token"): + store_intake_secrets(f"{encoded_payload}x.{signature}", {"password": "value"}) + token = mint_intake_token(["password"]) + with pytest.raises(SecretIntakeError, match="not allowed"): + store_intake_secrets(token, {"other": "value"}) + + +def test_expired_token_fails( + local_tenant: None, monkeypatch: pytest.MonkeyPatch +) -> None: + token = mint_intake_token(["password"], ttl_seconds=1) + now = time.time() + monkeypatch.setattr(_secret_intake.time, "time", lambda: now + 2) + + with pytest.raises(SecretIntakeError, match="expired"): + store_intake_secrets(token, {"password": "value"}) + + +def test_endpoint_returns_only_opaque_references(local_tenant: None) -> None: + app = Starlette(routes=secret_intake_routes()) + token = mint_intake_token(["password"]) + + with TestClient(app) as client: + response = client.post( + "/secret-intake", + headers={"Authorization": f"Bearer {token}"}, + json={"secrets": {"password": "secret-value"}}, + ) + + assert response.status_code == 200 + assert "secret-value" not in response.text + assert response.json()["secret_refs"]["password"].startswith("secret_intake::") + + +def test_endpoint_rejects_missing_and_reused_tokens(local_tenant: None) -> None: + app = Starlette(routes=secret_intake_routes()) + + with TestClient(app) as client: + missing = client.post("/secret-intake", json={"secrets": {"password": "value"}}) + token = mint_intake_token(["password"]) + first = client.post( + "/secret-intake", + headers={"Authorization": f"Bearer {token}"}, + json={"secrets": {"password": "value"}}, + ) + reused = client.post( + "/secret-intake", + headers={"Authorization": f"Bearer {token}"}, + json={"secrets": {"password": "value"}}, + ) + + assert missing.status_code == 401 + assert first.status_code == 200 + assert reused.status_code == 403 + + +def test_resolve_intake_secrets_enforces_tenant( + local_tenant: None, monkeypatch: pytest.MonkeyPatch +) -> None: + token = mint_intake_token(["password"], tenant_claim="") + refs = store_intake_secrets(token, {"password": "secret-value"}) + config = {"credentials": {"password": refs["password"]}} + + assert resolve_intake_secrets(config)["credentials"]["password"] == "secret-value" + + monkeypatch.setattr( + _secret_intake, "_resolve_transport_bearer_token", lambda: "tenant-b" + ) + with pytest.raises(SecretIntakeError, match="Invalid secret intake reference"): + resolve_intake_secrets(config) + + +def test_unknown_reference_fails(local_tenant: None) -> None: + with pytest.raises(SecretIntakeError, match="Invalid secret intake reference"): + resolve_intake_secrets({"password": "secret_intake::unknown/password"}) + + +def test_connector_config_resolves_intake_references( + local_tenant: None, +) -> None: + token = mint_intake_token(["password"]) + refs = store_intake_secrets(token, {"password": "secret-value"}) + + assert resolve_connector_config({"password": refs["password"]}) == { + "password": "secret-value" + } From 2d0a3d3c2382ec2d76bdb60169a1aa41236bef22 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:36:35 +0000 Subject: [PATCH 05/22] fix(mcp): validate secret intake payload shape Co-Authored-By: AJ Steers --- airbyte/mcp/_secret_intake.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/airbyte/mcp/_secret_intake.py b/airbyte/mcp/_secret_intake.py index 0623ff124..aaebb573f 100644 --- a/airbyte/mcp/_secret_intake.py +++ b/airbyte/mcp/_secret_intake.py @@ -220,6 +220,8 @@ async def secret_intake_endpoint(request: Request) -> JSONResponse: try: body = await request.json() secret_values = body.get("secrets") if isinstance(body, dict) else None + if not isinstance(secret_values, dict): + secret_values = {} secret_refs = store_intake_secrets(token, secret_values) except (SecretIntakeError, ValueError, TypeError): return _cors_response({"error": "Invalid secret intake request."}, status_code=403) From 398e2fa2cc9b47b9034ac1f58c4028edbb6365f9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:41:51 +0000 Subject: [PATCH 06/22] fix(mcp): cap connector form agent preview Co-Authored-By: AJ Steers --- .../interactive/_connector_config_form_ui.py | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/airbyte/mcp/interactive/_connector_config_form_ui.py b/airbyte/mcp/interactive/_connector_config_form_ui.py index 4d9f61691..0c0c89364 100644 --- a/airbyte/mcp/interactive/_connector_config_form_ui.py +++ b/airbyte/mcp/interactive/_connector_config_form_ui.py @@ -24,6 +24,7 @@ MCP_SERVER_URL_ENV = "MCP_SERVER_URL" DEFAULT_HTTP_PORT = 8080 DEFAULT_MCP_SERVER_URL = f"http://localhost:{DEFAULT_HTTP_PORT}" +_MAX_AGENT_CONTENT_LENGTH = 12_000 _HTML_RESOURCE = """ Connector configuration @@ -158,6 +159,38 @@ def _paths_present(value: object, prefix: str = "") -> set[str]: return paths +def _agent_content( + connector_name: str, + config_defaults: dict[str, Any], + secret_fields: list[str], +) -> str: + """Build bounded text for agents that cannot render the form.""" + note = ( + "The user will enter secrets in the form. The model will only receive " + "opaque secret_intake:: references." + ) + payload: dict[str, Any] = { + "connector_name": connector_name, + "non_secret_defaults": config_defaults, + "secret_fields": secret_fields, + "note": note, + "agent_preview_max_chars": _MAX_AGENT_CONTENT_LENGTH, + } + content = json.dumps(payload, separators=(",", ":")) + if len(content) <= _MAX_AGENT_CONTENT_LENGTH: + return content + + payload["non_secret_defaults"] = {} + payload["agent_preview_truncated"] = True + content = json.dumps(payload, separators=(",", ":")) + if len(content) <= _MAX_AGENT_CONTENT_LENGTH: + return content + + payload["secret_fields"] = [] + payload["connector_name"] = connector_name[:256] + return json.dumps(payload, separators=(",", ":")) + + @mcp_resource( CONNECTOR_FORM_RESOURCE_URI, "Self-contained Airbyte connector configuration form.", @@ -231,18 +264,7 @@ def show_connector_config_form( ) intake_token = mint_intake_token(secret_fields) - content = json.dumps( - { - "connector_name": connector_name, - "non_secret_defaults": config_defaults, - "secret_fields": secret_fields, - "note": ( - "The user will enter secrets in the form. The model will only " - "receive opaque secret_intake:: references." - ), - }, - separators=(",", ":"), - ) + content = _agent_content(connector_name, config_defaults, secret_fields) structured_content = { "connector_name": connector_name, "schema": spec_schema, From e65391b3e90d1623b64af8784f9bbd3dadddc8d3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:55:40 +0000 Subject: [PATCH 07/22] fix(mcp): harden connector config form intake Co-Authored-By: AJ Steers --- airbyte/mcp/_arg_resolvers.py | 21 +++++++++++--- airbyte/mcp/_secret_intake.py | 5 +--- .../interactive/_connector_config_form_ui.py | 15 ++++++++-- .../test_mcp_connector_config_form.py | 29 +++++++++++++++++++ tests/unit_tests/test_mcp_secret_intake.py | 18 ++++++++++++ 5 files changed, 78 insertions(+), 10 deletions(-) diff --git a/airbyte/mcp/_arg_resolvers.py b/airbyte/mcp/_arg_resolvers.py index a0df2bc78..9fa9db666 100644 --- a/airbyte/mcp/_arg_resolvers.py +++ b/airbyte/mcp/_arg_resolvers.py @@ -42,6 +42,17 @@ def _contains_secret_intake_reference(value: object) -> bool: return False +def _mask_secret_intake_references(value: object) -> object: + """Mask intake references as hydration references during secret detection.""" + if isinstance(value, str) and value.startswith(SECRET_INTAKE_PREFIX): + return SECRETS_HYDRATION_PREFIX + value[len(SECRET_INTAKE_PREFIX) :] + if isinstance(value, dict): + return {key: _mask_secret_intake_references(item) for key, item in value.items()} + if isinstance(value, list): + return [_mask_secret_intake_references(item) for item in value] + return value + + # Hint: Null result if input is Null @overload def resolve_list_of_strings(value: None) -> None: ... @@ -98,7 +109,7 @@ def resolve_list_of_strings(value: str | list[str] | set[str] | None) -> list[st return [item.strip() for item in value.split(",") if item.strip()] -def resolve_connector_config( # noqa: PLR0912 +def resolve_connector_config( # noqa: PLR0912, PLR0915 config: dict | str | None = None, config_file: str | Path | None = None, config_secret_name: str | None = None, @@ -177,12 +188,11 @@ def _raise_invalid_type(file_config: object) -> None: raise_if_untrusted_execution_context( "Resolving inline secret references (`secret_reference::`) in connector config" ) - if _contains_secret_intake_reference(config_dict): - config_dict = resolve_intake_secrets(config_dict) if config_dict and config_spec_jsonschema is not None: + detection_config = _mask_secret_intake_references(config_dict) hardcoded_secrets: list[list[str]] = detect_hardcoded_secrets( - config=config_dict, + config=detection_config, spec_json_schema=config_spec_jsonschema, ) if hardcoded_secrets: @@ -198,6 +208,9 @@ def _raise_invalid_type(file_config: object) -> None: ) raise ValueError(error_msg) + if _contains_secret_intake_reference(config_dict): + config_dict = resolve_intake_secrets(config_dict) + if config_secret_name is not None: raise_if_untrusted_execution_context( "Resolving connector config from a server-side secret (`config_secret_name`)" diff --git a/airbyte/mcp/_secret_intake.py b/airbyte/mcp/_secret_intake.py index aaebb573f..3478a3080 100644 --- a/airbyte/mcp/_secret_intake.py +++ b/airbyte/mcp/_secret_intake.py @@ -55,10 +55,7 @@ def _signing_key() -> bytes: def _tenant_claim(token: str | None = None) -> str: if token is None: - try: - token = _resolve_transport_bearer_token() - except Exception: - token = "" + token = _resolve_transport_bearer_token() return hashlib.sha256(token.encode()).hexdigest() if token else "" diff --git a/airbyte/mcp/interactive/_connector_config_form_ui.py b/airbyte/mcp/interactive/_connector_config_form_ui.py index 0c0c89364..7e968f1d8 100644 --- a/airbyte/mcp/interactive/_connector_config_form_ui.py +++ b/airbyte/mcp/interactive/_connector_config_form_ui.py @@ -48,6 +48,7 @@ value && typeof value === "object" ? value[key] : undefined, object); const setPath = (object, path, value) => { const keys = path.split("."); + if (keys.some((key) => ["__proto__", "constructor", "prototype"].includes(key))) return; let target = object; keys.slice(0, -1).forEach((key) => target = target[key] ||= {}); target[keys[keys.length - 1]] = value; @@ -108,6 +109,7 @@ } catch (error) { status.className = "error"; status.textContent = error.message; } }); window.addEventListener("message", (event) => { + if (event.source !== window.parent) return; const message = event.data || {}; const result = message.params && (message.params.tool_result || message.params.result); if (result) render(result.structuredContent || result.structured_content || result); @@ -121,14 +123,23 @@ """ -def _server_origin() -> str: +def _server_url() -> str: server_url = os.getenv(MCP_SERVER_URL_ENV, "").strip() or DEFAULT_MCP_SERVER_URL parsed = urlparse(server_url) + if parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1"}: + raise exc.PyAirbyteInputError( + message="MCP_SERVER_URL must use HTTPS outside localhost.", + ) + return server_url.rstrip("/") + + +def _server_origin() -> str: + parsed = urlparse(_server_url()) return f"{parsed.scheme}://{parsed.netloc}" def _intake_endpoint() -> str: - return f"{_server_origin()}/secret-intake" + return f"{_server_url()}/secret-intake" def _schema_secret_paths(schema: Mapping[str, Any], prefix: str = "") -> set[str]: diff --git a/tests/unit_tests/test_mcp_connector_config_form.py b/tests/unit_tests/test_mcp_connector_config_form.py index b2b14619c..e172d1c5a 100644 --- a/tests/unit_tests/test_mcp_connector_config_form.py +++ b/tests/unit_tests/test_mcp_connector_config_form.py @@ -59,3 +59,32 @@ def test_form_rejects_secret_defaults(monkeypatch: pytest.MonkeyPatch) -> None: "source-example", {"credentials": {"api_key": "secret-value"}}, ) + + +def test_intake_endpoint_preserves_server_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(form.MCP_SERVER_URL_ENV, "https://example.com/cloud-mcp/") + + assert form._intake_endpoint() == "https://example.com/cloud-mcp/secret-intake" + assert form._server_origin() == "https://example.com" + + +def test_server_origin_rejects_insecure_nonlocal_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(form.MCP_SERVER_URL_ENV, "http://example.com/cloud-mcp") + + with pytest.raises(PyAirbyteInputError, match="HTTPS"): + form._server_origin() + + +def test_server_origin_allows_local_http_url(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(form.MCP_SERVER_URL_ENV, "http://localhost:8080/cloud-mcp") + + assert form._server_origin() == "http://localhost:8080" + assert form._intake_endpoint() == "http://localhost:8080/cloud-mcp/secret-intake" + + +def test_form_blocks_prototype_pollution_paths() -> None: + html = form.connector_config_form_resource() + + assert '["__proto__", "constructor", "prototype"]' in html diff --git a/tests/unit_tests/test_mcp_secret_intake.py b/tests/unit_tests/test_mcp_secret_intake.py index 87a507185..4e7699d6e 100644 --- a/tests/unit_tests/test_mcp_secret_intake.py +++ b/tests/unit_tests/test_mcp_secret_intake.py @@ -131,3 +131,21 @@ def test_connector_config_resolves_intake_references( assert resolve_connector_config({"password": refs["password"]}) == { "password": "secret-value" } + + +def test_connector_config_resolves_intake_references_before_secret_check( + local_tenant: None, +) -> None: + token = mint_intake_token(["password"]) + refs = store_intake_secrets(token, {"password": "secret-value"}) + schema = { + "type": "object", + "properties": { + "password": {"type": "string", "airbyte_secret": True}, + }, + } + + assert resolve_connector_config( + {"password": refs["password"]}, + config_spec_jsonschema=schema, + ) == {"password": "secret-value"} From 20c3dd9a7137d4687d8a15ed956c20157067a875 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:58:01 +0000 Subject: [PATCH 08/22] fix(mcp): type intake detection config Co-Authored-By: AJ Steers --- airbyte/mcp/_arg_resolvers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/airbyte/mcp/_arg_resolvers.py b/airbyte/mcp/_arg_resolvers.py index 9fa9db666..8363d0074 100644 --- a/airbyte/mcp/_arg_resolvers.py +++ b/airbyte/mcp/_arg_resolvers.py @@ -9,7 +9,7 @@ import json from pathlib import Path -from typing import Any, overload +from typing import Any, cast, overload import yaml @@ -190,7 +190,10 @@ def _raise_invalid_type(file_config: object) -> None: ) if config_dict and config_spec_jsonschema is not None: - detection_config = _mask_secret_intake_references(config_dict) + detection_config = cast( + "dict[str, Any]", + _mask_secret_intake_references(config_dict), + ) hardcoded_secrets: list[list[str]] = detect_hardcoded_secrets( config=detection_config, spec_json_schema=config_spec_jsonschema, From 8f94559c11103e2a9b9c1d27f029e4918093d46a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:09:55 +0000 Subject: [PATCH 09/22] fix(mcp): restrict intake refs and server URLs Co-Authored-By: AJ Steers --- airbyte/mcp/_arg_resolvers.py | 16 ++++++- airbyte/mcp/_secret_intake.py | 35 +++++++++++++-- .../interactive/_connector_config_form_ui.py | 21 +++------ .../test_mcp_connector_config_form.py | 14 ++++++ tests/unit_tests/test_mcp_secret_intake.py | 44 +++++++++++++++++-- 5 files changed, 105 insertions(+), 25 deletions(-) diff --git a/airbyte/mcp/_arg_resolvers.py b/airbyte/mcp/_arg_resolvers.py index 8363d0074..5d25ac793 100644 --- a/airbyte/mcp/_arg_resolvers.py +++ b/airbyte/mcp/_arg_resolvers.py @@ -15,7 +15,11 @@ from airbyte.constants import SECRETS_HYDRATION_PREFIX from airbyte.mcp._guards import raise_if_untrusted_execution_context -from airbyte.mcp._secret_intake import SECRET_INTAKE_PREFIX, resolve_intake_secrets +from airbyte.mcp._secret_intake import ( + SECRET_INTAKE_PREFIX, + _schema_secret_paths, + resolve_intake_secrets, +) from airbyte.secrets.hydration import deep_update, detect_hardcoded_secrets from airbyte.secrets.util import get_secret @@ -212,7 +216,15 @@ def _raise_invalid_type(file_config: object) -> None: raise ValueError(error_msg) if _contains_secret_intake_reference(config_dict): - config_dict = resolve_intake_secrets(config_dict) + allowed_paths = ( + _schema_secret_paths(config_spec_jsonschema) + if config_spec_jsonschema is not None + else None + ) + config_dict = resolve_intake_secrets( + config_dict, + allowed_paths=allowed_paths, + ) if config_secret_name is not None: raise_if_untrusted_execution_context( diff --git a/airbyte/mcp/_secret_intake.py b/airbyte/mcp/_secret_intake.py index 3478a3080..c0f292ebd 100644 --- a/airbyte/mcp/_secret_intake.py +++ b/airbyte/mcp/_secret_intake.py @@ -11,6 +11,7 @@ import secrets import threading import time +from collections.abc import Mapping from dataclasses import dataclass from typing import TYPE_CHECKING, Any, cast @@ -174,23 +175,49 @@ def store_intake_secrets(token: str, values: dict[str, str]) -> dict[str, str]: return {field: f"{SECRET_INTAKE_PREFIX}{intake_id}/{field}" for field in values} -def resolve_intake_secrets(config: dict[str, Any]) -> dict[str, Any]: +def _schema_secret_paths(schema: Mapping[str, Any], prefix: str = "") -> set[str]: + paths: set[str] = set() + properties = schema.get("properties", {}) + if not isinstance(properties, Mapping): + return paths + for name, child in properties.items(): + if not isinstance(name, str) or not isinstance(child, Mapping): + continue + path = f"{prefix}.{name}" if prefix else name + if child.get("airbyte_secret") is True: + paths.add(path) + paths.update(_schema_secret_paths(child, path)) + return paths + + +def resolve_intake_secrets( + config: dict[str, Any], + *, + allowed_paths: set[str] | None = None, +) -> dict[str, Any]: """Resolve `secret_intake::` references for the current transport tenant.""" caller_claim = _tenant_claim() - def resolve(value: object) -> object: + def resolve(value: object, path: tuple[str, ...] = ()) -> object: if isinstance(value, dict): - return {key: resolve(item) for key, item in value.items()} + return {key: resolve(item, (*path, key)) for key, item in value.items()} if isinstance(value, list): - return [resolve(item) for item in value] + return [resolve(item, path) for item in value] if not isinstance(value, str) or not value.startswith(SECRET_INTAKE_PREFIX): return value + field_path = ".".join(path) reference = value[len(SECRET_INTAKE_PREFIX) :] try: intake_id, field = reference.split("/", 1) except ValueError as error: raise SecretIntakeError("Invalid secret intake reference.") from error + if ( + not path + or field != path[-1] + or (allowed_paths is not None and field_path not in allowed_paths) + ): + raise SecretIntakeError("Invalid secret intake reference.") with _INTAKES_LOCK: record = _INTAKES.get(intake_id) if ( diff --git a/airbyte/mcp/interactive/_connector_config_form_ui.py b/airbyte/mcp/interactive/_connector_config_form_ui.py index 7e968f1d8..cff9eb75e 100644 --- a/airbyte/mcp/interactive/_connector_config_form_ui.py +++ b/airbyte/mcp/interactive/_connector_config_form_ui.py @@ -16,7 +16,7 @@ from airbyte import exceptions as exc from airbyte._util.registry_spec import get_connector_spec_from_registry -from airbyte.mcp._secret_intake import mint_intake_token +from airbyte.mcp._secret_intake import _schema_secret_paths, mint_intake_token from airbyte.mcp._tool_utils import INTERACTIVE_UI_ANNOTATION, mcp_tool @@ -126,6 +126,10 @@ def _server_url() -> str: server_url = os.getenv(MCP_SERVER_URL_ENV, "").strip() or DEFAULT_MCP_SERVER_URL parsed = urlparse(server_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise exc.PyAirbyteInputError( + message="MCP_SERVER_URL must be a valid HTTP(S) URL.", + ) if parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1"}: raise exc.PyAirbyteInputError( message="MCP_SERVER_URL must use HTTPS outside localhost.", @@ -142,21 +146,6 @@ def _intake_endpoint() -> str: return f"{_server_url()}/secret-intake" -def _schema_secret_paths(schema: Mapping[str, Any], prefix: str = "") -> set[str]: - paths: set[str] = set() - properties = schema.get("properties", {}) - if not isinstance(properties, Mapping): - return paths - for name, child in properties.items(): - if not isinstance(name, str) or not isinstance(child, Mapping): - continue - path = f"{prefix}.{name}" if prefix else name - if child.get("airbyte_secret") is True: - paths.add(path) - paths.update(_schema_secret_paths(child, path)) - return paths - - def _paths_present(value: object, prefix: str = "") -> set[str]: if not isinstance(value, Mapping): return set() diff --git a/tests/unit_tests/test_mcp_connector_config_form.py b/tests/unit_tests/test_mcp_connector_config_form.py index e172d1c5a..9a24560ea 100644 --- a/tests/unit_tests/test_mcp_connector_config_form.py +++ b/tests/unit_tests/test_mcp_connector_config_form.py @@ -77,6 +77,20 @@ def test_server_origin_rejects_insecure_nonlocal_url( form._server_origin() +@pytest.mark.parametrize( + "server_url", + ["//attacker.example", "ftp://example.com", "example.com"], +) +def test_server_origin_rejects_invalid_url( + monkeypatch: pytest.MonkeyPatch, + server_url: str, +) -> None: + monkeypatch.setenv(form.MCP_SERVER_URL_ENV, server_url) + + with pytest.raises(PyAirbyteInputError, match="valid HTTP"): + form._server_origin() + + def test_server_origin_allows_local_http_url(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(form.MCP_SERVER_URL_ENV, "http://localhost:8080/cloud-mcp") diff --git a/tests/unit_tests/test_mcp_secret_intake.py b/tests/unit_tests/test_mcp_secret_intake.py index 4e7699d6e..a47421a49 100644 --- a/tests/unit_tests/test_mcp_secret_intake.py +++ b/tests/unit_tests/test_mcp_secret_intake.py @@ -122,6 +122,14 @@ def test_unknown_reference_fails(local_tenant: None) -> None: resolve_intake_secrets({"password": "secret_intake::unknown/password"}) +def test_resolve_intake_secrets_rejects_wrong_field_path(local_tenant: None) -> None: + token = mint_intake_token(["password"]) + refs = store_intake_secrets(token, {"password": "secret-value"}) + + with pytest.raises(SecretIntakeError, match="Invalid secret intake reference"): + resolve_intake_secrets({"username": refs["password"]}) + + def test_connector_config_resolves_intake_references( local_tenant: None, ) -> None: @@ -141,11 +149,41 @@ def test_connector_config_resolves_intake_references_before_secret_check( schema = { "type": "object", "properties": { - "password": {"type": "string", "airbyte_secret": True}, + "credentials": { + "type": "object", + "properties": { + "password": {"type": "string", "airbyte_secret": True}, + }, + }, }, } assert resolve_connector_config( - {"password": refs["password"]}, + {"credentials": {"password": refs["password"]}}, config_spec_jsonschema=schema, - ) == {"password": "secret-value"} + ) == {"credentials": {"password": "secret-value"}} + + +def test_connector_config_rejects_intake_reference_at_nonsecret_path( + local_tenant: None, +) -> None: + token = mint_intake_token(["password"]) + refs = store_intake_secrets(token, {"password": "secret-value"}) + schema = { + "type": "object", + "properties": { + "credentials": { + "type": "object", + "properties": { + "password": {"type": "string", "airbyte_secret": True}, + "username": {"type": "string"}, + }, + }, + }, + } + + with pytest.raises(SecretIntakeError, match="Invalid secret intake reference"): + resolve_connector_config( + {"credentials": {"username": refs["password"]}}, + config_spec_jsonschema=schema, + ) From b3c73b7df93715bdbe35d0d50512fb38a11aa851 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:24:05 +0000 Subject: [PATCH 10/22] fix(mcp): handle complex connector auth schemas Co-Authored-By: AJ Steers --- airbyte/mcp/_secret_intake.py | 22 +++++++----- .../interactive/_connector_config_form_ui.py | 18 +++++++++- .../test_mcp_connector_config_form.py | 30 ++++++++++++++++ tests/unit_tests/test_mcp_secret_intake.py | 35 +++++++++++++++++++ 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/airbyte/mcp/_secret_intake.py b/airbyte/mcp/_secret_intake.py index c0f292ebd..c040c975e 100644 --- a/airbyte/mcp/_secret_intake.py +++ b/airbyte/mcp/_secret_intake.py @@ -178,15 +178,21 @@ def store_intake_secrets(token: str, values: dict[str, str]) -> dict[str, str]: def _schema_secret_paths(schema: Mapping[str, Any], prefix: str = "") -> set[str]: paths: set[str] = set() properties = schema.get("properties", {}) - if not isinstance(properties, Mapping): - return paths - for name, child in properties.items(): - if not isinstance(name, str) or not isinstance(child, Mapping): + if isinstance(properties, Mapping): + for name, child in properties.items(): + if not isinstance(name, str) or not isinstance(child, Mapping): + continue + path = f"{prefix}.{name}" if prefix else name + if child.get("airbyte_secret") is True: + paths.add(path) + paths.update(_schema_secret_paths(child, path)) + for branch_key in ("oneOf", "anyOf", "allOf"): + branches = schema.get(branch_key, []) + if not isinstance(branches, list): continue - path = f"{prefix}.{name}" if prefix else name - if child.get("airbyte_secret") is True: - paths.add(path) - paths.update(_schema_secret_paths(child, path)) + for branch in branches: + if isinstance(branch, Mapping): + paths.update(_schema_secret_paths(branch, prefix)) return paths diff --git a/airbyte/mcp/interactive/_connector_config_form_ui.py b/airbyte/mcp/interactive/_connector_config_form_ui.py index cff9eb75e..f4e24b82f 100644 --- a/airbyte/mcp/interactive/_connector_config_form_ui.py +++ b/airbyte/mcp/interactive/_connector_config_form_ui.py @@ -57,6 +57,8 @@ const properties = schema && schema.properties || {}; return Object.entries(properties).flatMap(([name, child]) => { const path = prefix ? `${prefix}.${name}` : name; + if (child && ["oneOf", "anyOf", "allOf"].some((key) => Array.isArray(child[key]))) + return [{ path, schema: child }]; if (child && child.type === "object" && child.properties) return fields(child, path); return [{ path, schema: child || {} }]; @@ -74,6 +76,13 @@ ).forEach(({path, schema}) => { const label = document.createElement("label"); label.textContent = `${schema.title || path}${required.has(path) ? " *" : ""}`; + if (["oneOf", "anyOf", "allOf"].some((key) => Array.isArray(schema[key]))) { + const notice = document.createElement("span"); + notice.textContent = "Complex authentication objects are not supported by this form."; + notice.setAttribute("aria-disabled", "true"); + label.appendChild(notice); form.appendChild(label); + return; + } const input = document.createElement("input"); input.name = path; input.type = result.secret_fields.includes(path) ? "password" : "text"; @@ -111,9 +120,16 @@ window.addEventListener("message", (event) => { if (event.source !== window.parent) return; const message = event.data || {}; + if (message.method === "ui/notifications/tool-result" && message.params) { + render( + message.params.structuredContent || + message.params.structured_content || + message.params + ); + return; + } const result = message.params && (message.params.tool_result || message.params.result); if (result) render(result.structuredContent || result.structured_content || result); - if (message.method === "ui/notifications/tool-result" && message.params) render(message.params); }); post({jsonrpc: "2.0", id: 1, method: "ui/initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, diff --git a/tests/unit_tests/test_mcp_connector_config_form.py b/tests/unit_tests/test_mcp_connector_config_form.py index 9a24560ea..ef5073117 100644 --- a/tests/unit_tests/test_mcp_connector_config_form.py +++ b/tests/unit_tests/test_mcp_connector_config_form.py @@ -102,3 +102,33 @@ def test_form_blocks_prototype_pollution_paths() -> None: html = form.connector_config_form_resource() assert '["__proto__", "constructor", "prototype"]' in html + + +def test_form_handles_one_of_schema_without_plaintext_input( + monkeypatch: pytest.MonkeyPatch, +) -> None: + schema = { + "type": "object", + "properties": { + "credentials": { + "type": "object", + "oneOf": [ + { + "properties": { + "api_key": {"type": "string", "airbyte_secret": True}, + } + } + ], + } + }, + } + monkeypatch.setattr( + form, "get_connector_spec_from_registry", lambda *args, **kwargs: schema + ) + + result = form.show_connector_config_form("source-example") + + assert result.structured_content["secret_fields"] == ["credentials.api_key"] + html = form.connector_config_form_resource() + assert "Complex authentication objects are not supported by this form." in html + assert "Array.isArray(child[key])" in html diff --git a/tests/unit_tests/test_mcp_secret_intake.py b/tests/unit_tests/test_mcp_secret_intake.py index a47421a49..ff2889f94 100644 --- a/tests/unit_tests/test_mcp_secret_intake.py +++ b/tests/unit_tests/test_mcp_secret_intake.py @@ -13,6 +13,7 @@ from airbyte.mcp import _secret_intake from airbyte.mcp._secret_intake import ( SecretIntakeError, + _schema_secret_paths, mint_intake_token, resolve_intake_secrets, secret_intake_routes, @@ -122,6 +123,40 @@ def test_unknown_reference_fails(local_tenant: None) -> None: resolve_intake_secrets({"password": "secret_intake::unknown/password"}) +def test_schema_secret_paths_include_one_of_branches() -> None: + schema = { + "oneOf": [ + { + "type": "object", + "properties": { + "credentials": { + "type": "object", + "properties": { + "api_key": {"type": "string", "airbyte_secret": True}, + }, + }, + }, + }, + { + "type": "object", + "properties": { + "credentials": { + "type": "object", + "properties": { + "password": {"type": "string", "airbyte_secret": True}, + }, + }, + }, + }, + ] + } + + assert _schema_secret_paths(schema) == { + "credentials.api_key", + "credentials.password", + } + + def test_resolve_intake_secrets_rejects_wrong_field_path(local_tenant: None) -> None: token = mint_intake_token(["password"]) refs = store_intake_secrets(token, {"password": "secret-value"}) From 3cfab2f9f5472900ef1bc43c1b2aaada92a64330 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:07:03 +0000 Subject: [PATCH 11/22] feat(mcp): rework config form to stateless one-shot submit Co-Authored-By: AJ Steers --- airbyte/mcp/_arg_resolvers.py | 48 +-- airbyte/mcp/_config_submit.py | 338 ++++++++++++++++++ airbyte/mcp/_secret_intake.py | 293 --------------- .../interactive/_connector_config_form_ui.py | 143 ++++++-- airbyte/mcp/server.py | 10 +- tests/unit_tests/test_mcp_config_submit.py | 185 ++++++++++ .../test_mcp_connector_config_form.py | 65 +++- tests/unit_tests/test_mcp_secret_intake.py | 224 ------------ 8 files changed, 709 insertions(+), 597 deletions(-) create mode 100644 airbyte/mcp/_config_submit.py delete mode 100644 airbyte/mcp/_secret_intake.py create mode 100644 tests/unit_tests/test_mcp_config_submit.py delete mode 100644 tests/unit_tests/test_mcp_secret_intake.py diff --git a/airbyte/mcp/_arg_resolvers.py b/airbyte/mcp/_arg_resolvers.py index 5d25ac793..d0ff39d49 100644 --- a/airbyte/mcp/_arg_resolvers.py +++ b/airbyte/mcp/_arg_resolvers.py @@ -9,17 +9,12 @@ import json from pathlib import Path -from typing import Any, cast, overload +from typing import Any, overload import yaml from airbyte.constants import SECRETS_HYDRATION_PREFIX from airbyte.mcp._guards import raise_if_untrusted_execution_context -from airbyte.mcp._secret_intake import ( - SECRET_INTAKE_PREFIX, - _schema_secret_paths, - resolve_intake_secrets, -) from airbyte.secrets.hydration import deep_update, detect_hardcoded_secrets from airbyte.secrets.util import get_secret @@ -35,28 +30,6 @@ def _contains_secret_reference(value: object) -> bool: return False -def _contains_secret_intake_reference(value: object) -> bool: - """Return whether `value` contains an out-of-band secret reference.""" - if isinstance(value, str): - return value.startswith(SECRET_INTAKE_PREFIX) - if isinstance(value, dict): - return any(_contains_secret_intake_reference(item) for item in value.values()) - if isinstance(value, list): - return any(_contains_secret_intake_reference(item) for item in value) - return False - - -def _mask_secret_intake_references(value: object) -> object: - """Mask intake references as hydration references during secret detection.""" - if isinstance(value, str) and value.startswith(SECRET_INTAKE_PREFIX): - return SECRETS_HYDRATION_PREFIX + value[len(SECRET_INTAKE_PREFIX) :] - if isinstance(value, dict): - return {key: _mask_secret_intake_references(item) for key, item in value.items()} - if isinstance(value, list): - return [_mask_secret_intake_references(item) for item in value] - return value - - # Hint: Null result if input is Null @overload def resolve_list_of_strings(value: None) -> None: ... @@ -113,7 +86,7 @@ def resolve_list_of_strings(value: str | list[str] | set[str] | None) -> list[st return [item.strip() for item in value.split(",") if item.strip()] -def resolve_connector_config( # noqa: PLR0912, PLR0915 +def resolve_connector_config( # noqa: PLR0912 config: dict | str | None = None, config_file: str | Path | None = None, config_secret_name: str | None = None, @@ -194,12 +167,8 @@ def _raise_invalid_type(file_config: object) -> None: ) if config_dict and config_spec_jsonschema is not None: - detection_config = cast( - "dict[str, Any]", - _mask_secret_intake_references(config_dict), - ) hardcoded_secrets: list[list[str]] = detect_hardcoded_secrets( - config=detection_config, + config=config_dict, spec_json_schema=config_spec_jsonschema, ) if hardcoded_secrets: @@ -215,17 +184,6 @@ def _raise_invalid_type(file_config: object) -> None: ) raise ValueError(error_msg) - if _contains_secret_intake_reference(config_dict): - allowed_paths = ( - _schema_secret_paths(config_spec_jsonschema) - if config_spec_jsonschema is not None - else None - ) - config_dict = resolve_intake_secrets( - config_dict, - allowed_paths=allowed_paths, - ) - if config_secret_name is not None: raise_if_untrusted_execution_context( "Resolving connector config from a server-side secret (`config_secret_name`)" diff --git a/airbyte/mcp/_config_submit.py b/airbyte/mcp/_config_submit.py new file mode 100644 index 000000000..f44434ec9 --- /dev/null +++ b/airbyte/mcp/_config_submit.py @@ -0,0 +1,338 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Stateless encrypted action capabilities for connector configuration forms. + +The action token is encrypted with a process-shared key so its claims, including +Cloud credentials, are not visible to the MCP client. The process-random fallback +is suitable only for a single-process spike. Multi-replica deployments must +configure the same `AIRBYTE_MCP_FORM_SIGNING_KEY` on every replica; replay +tracking is still process-local best effort, while token expiration is the actual +lifetime bound. +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import json +import os +import secrets +import threading +import time +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Literal, cast + +import jsonschema +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from starlette.responses import JSONResponse +from starlette.routing import Route + +from airbyte import get_source +from airbyte.cloud.client import CloudClient +from airbyte.exceptions import AirbyteError, PyAirbyteInputError +from airbyte.mcp._tool_utils import ( + SafeModeError, + _resolve_transport_bearer_token, + check_guid_created_in_session, +) + + +if TYPE_CHECKING: + from starlette.requests import Request + + +DEFAULT_FORM_TOKEN_TTL_SECONDS = 600 +_FORM_SIGNING_KEY_ENV = "AIRBYTE_MCP_FORM_SIGNING_KEY" +_PROCESS_SIGNING_KEY = secrets.token_bytes(32) +_ACTION_NAMES = frozenset({"create", "update", "validate"}) +_AES_GCM_NONCE_SIZE = 12 +_SEEN_JTIS: dict[str, int] = {} +_SEEN_JTIS_LOCK = threading.Lock() + + +class ConfigSubmitError(ValueError): + """Raised when an encrypted configuration-submit capability is invalid.""" + + +def _signing_key() -> bytes: + configured = os.getenv(_FORM_SIGNING_KEY_ENV, "") + return configured.encode() if configured else _PROCESS_SIGNING_KEY + + +def _encryption_key() -> bytes: + return hashlib.sha256(_signing_key()).digest() + + +def _tenant_claim(token: str | None = None) -> str: + if token is None: + token = _resolve_transport_bearer_token() + return hashlib.sha256(token.encode()).hexdigest() if token else "" + + +def _encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode() + + +def _decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def mint_action_token( # noqa: PLR0913 + action: Literal["create", "update", "validate"], + connector_name: str, + *, + workspace_id: str | None = None, + source_id: str | None = None, + source_name: str | None = None, + bearer_token: str | None = None, + client_id: str | None = None, + client_secret: str | None = None, + api_url: str | None = None, + config_api_url: str | None = None, + tenant_claim: str | None = None, + ttl_seconds: int = DEFAULT_FORM_TOKEN_TTL_SECONDS, +) -> str: + """Mint an encrypted, one-shot action capability for the connector form.""" + if action not in _ACTION_NAMES: + raise ConfigSubmitError("Invalid configuration action.") + if not connector_name: + raise ConfigSubmitError("Connector name is required.") + if ttl_seconds <= 0: + raise ConfigSubmitError("The form token TTL must be positive.") + if (client_id is None) != (client_secret is None): + raise ConfigSubmitError("Client credentials must be provided together.") + + claims: dict[str, Any] = { + "action": action, + "connector_name": connector_name, + "tenant_claim": tenant_claim if tenant_claim is not None else _tenant_claim(), + "exp": int(time.time()) + ttl_seconds, + "jti": secrets.token_urlsafe(18), + } + optional_claims = { + "workspace_id": workspace_id, + "source_id": source_id, + "source_name": source_name, + "bearer_token": bearer_token, + "client_id": client_id, + "client_secret": client_secret, + "api_url": api_url, + "config_api_url": config_api_url, + } + claims.update({key: value for key, value in optional_claims.items() if value is not None}) + plaintext = json.dumps(claims, separators=(",", ":")).encode() + nonce = secrets.token_bytes(12) + ciphertext = AESGCM(_encryption_key()).encrypt(nonce, plaintext, None) + return _encode(nonce + ciphertext) + + +def _validate_claims(payload: object) -> dict[str, Any]: + if not isinstance(payload, dict): + raise ConfigSubmitError("Invalid configuration submit token.") + required = ("action", "connector_name", "tenant_claim", "exp", "jti") + if any(key not in payload for key in required): + raise ConfigSubmitError("Invalid configuration submit token.") + valid_types = ( + isinstance(payload["action"], str) + and payload["action"] in _ACTION_NAMES + and isinstance(payload["connector_name"], str) + and isinstance(payload["tenant_claim"], str) + and isinstance(payload["exp"], int) + and not isinstance(payload["exp"], bool) + and isinstance(payload["jti"], str) + ) + if not valid_types: + raise ConfigSubmitError("Invalid configuration submit token.") + for key in ( + "workspace_id", + "source_id", + "source_name", + "bearer_token", + "client_id", + "client_secret", + "api_url", + "config_api_url", + ): + if key in payload and not isinstance(payload[key], str): + raise ConfigSubmitError("Invalid configuration submit token.") + if ("client_id" in payload) != ("client_secret" in payload): + raise ConfigSubmitError("Invalid configuration submit token.") + return cast(dict[str, Any], payload) + + +def _prune_seen_jtis_locked(now: int) -> None: + for jti, expires_at in list(_SEEN_JTIS.items()): + if expires_at <= now: + del _SEEN_JTIS[jti] + + +def decrypt_action_token(token: str) -> dict[str, Any]: + """Decrypt, validate, and consume a one-shot action capability.""" + try: + encoded = _decode(token) + if len(encoded) <= _AES_GCM_NONCE_SIZE: + raise ConfigSubmitError("Invalid configuration submit token.") + plaintext = AESGCM(_encryption_key()).decrypt( + encoded[:_AES_GCM_NONCE_SIZE], + encoded[_AES_GCM_NONCE_SIZE:], + None, + ) + claims = _validate_claims(json.loads(plaintext)) + except (binascii.Error, InvalidTag, json.JSONDecodeError, TypeError, ValueError) as error: + if isinstance(error, ConfigSubmitError): + raise + raise ConfigSubmitError("Invalid configuration submit token.") from error + + now = int(time.time()) + if claims["exp"] <= now: + raise ConfigSubmitError("Configuration submit token expired.") + with _SEEN_JTIS_LOCK: + _prune_seen_jtis_locked(now) + if claims["jti"] in _SEEN_JTIS: + raise ConfigSubmitError("Configuration submit token already used.") + _SEEN_JTIS[claims["jti"]] = claims["exp"] + return claims + + +def _schema_secret_paths(schema: Mapping[str, Any], prefix: str = "") -> set[str]: + paths: set[str] = set() + properties = schema.get("properties", {}) + if isinstance(properties, Mapping): + for name, child in properties.items(): + if not isinstance(name, str) or not isinstance(child, Mapping): + continue + path = f"{prefix}.{name}" if prefix else name + if child.get("airbyte_secret") is True: + paths.add(path) + paths.update(_schema_secret_paths(child, path)) + for branch_key in ("oneOf", "anyOf", "allOf"): + branches = schema.get(branch_key, []) + if not isinstance(branches, list): + continue + for branch in branches: + if isinstance(branch, Mapping): + paths.update(_schema_secret_paths(branch, prefix)) + return paths + + +def _cloud_client_from_claims(claims: Mapping[str, Any]) -> CloudClient: + return CloudClient( + bearer_token=claims.get("bearer_token"), + client_id=claims.get("client_id"), + client_secret=claims.get("client_secret"), + public_api_root=claims.get("api_url"), + config_api_root=claims.get("config_api_url"), + ) + + +def _execute_action(claims: Mapping[str, Any], config: dict[str, Any]) -> dict[str, str]: + action = claims["action"] + connector_name = claims["connector_name"] + if action == "validate": + source = get_source(connector_name, no_executor=True) + source.set_config(config, validate=True) + return {"status": "success", "action": "validated"} + + workspace_id = claims.get("workspace_id") + if not workspace_id: + raise ConfigSubmitError("Cloud workspace is unavailable.") + workspace = _cloud_client_from_claims(claims).get_workspace(workspace_id) + if action == "create": + source = get_source(connector_name, no_executor=True) + source.set_config(config, validate=True) + deployed_source = workspace.deploy_source( + name=claims.get("source_name") or connector_name, + source=source, + unique=True, + ) + return { + "status": "success", + "action": "created", + "connector_id": deployed_source.connector_id, + "connector_url": deployed_source.connector_url, + } + + source_id = claims.get("source_id") + if not source_id: + raise ConfigSubmitError("Cloud source is unavailable.") + check_guid_created_in_session(source_id) + source = workspace.get_source(source_id=source_id) + source.update_config(config=config) + return { + "status": "success", + "action": "updated", + "connector_id": source.connector_id, + "connector_url": source.connector_url, + } + + +async def connector_config_submit_endpoint(request: Request) -> JSONResponse: # noqa: PLR0911 + """Execute a one-shot connector configuration action without echoing config.""" + if request.method == "OPTIONS": + return _cors_response({}, status_code=204) + authorization = request.headers.get("authorization", "") + if not authorization.lower().startswith("bearer "): + return _cors_response({"error": "Unauthorized."}, status_code=401) + token = authorization[7:].strip() + try: + claims = decrypt_action_token(token) + except ConfigSubmitError: + return _cors_response({"error": "Invalid configuration submit request."}, status_code=403) + + try: + body = await request.json() + except (TypeError, ValueError): + return _cors_response({"error": "Invalid configuration submit request."}, status_code=400) + config = body.get("config") if isinstance(body, dict) else None + if not isinstance(config, dict): + return _cors_response({"error": "Invalid configuration submit request."}, status_code=400) + try: + return _cors_response(_execute_action(claims, config)) + except ( + AirbyteError, + ConfigSubmitError, + KeyError, + PyAirbyteInputError, + SafeModeError, + TypeError, + ValueError, + jsonschema.ValidationError, + ): + return _cors_response( + {"error": "Connector configuration could not be submitted."}, status_code=422 + ) + + +def _cors_response(body: dict[str, Any], *, status_code: int = 200) -> JSONResponse: + return JSONResponse( + body, + status_code=status_code, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + }, + ) + + +def connector_config_submit_routes() -> list[Route]: + """Return the Starlette routes for connector configuration submission.""" + return [ + Route( + "/connector-config-submit", + connector_config_submit_endpoint, + methods=["POST", "OPTIONS"], + ), + ] + + +__all__ = [ + "ConfigSubmitError", + "DEFAULT_FORM_TOKEN_TTL_SECONDS", + "_schema_secret_paths", + "connector_config_submit_endpoint", + "connector_config_submit_routes", + "decrypt_action_token", + "mint_action_token", +] diff --git a/airbyte/mcp/_secret_intake.py b/airbyte/mcp/_secret_intake.py deleted file mode 100644 index c040c975e..000000000 --- a/airbyte/mcp/_secret_intake.py +++ /dev/null @@ -1,293 +0,0 @@ -# Copyright (c) 2026 Airbyte, Inc., all rights reserved. -"""Out-of-band intake for connector secret values.""" - -from __future__ import annotations - -import base64 -import hashlib -import hmac -import json -import os -import secrets -import threading -import time -from collections.abc import Mapping -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, cast - -from starlette.responses import JSONResponse -from starlette.routing import Route - -from airbyte.mcp._tool_utils import _resolve_transport_bearer_token - - -if TYPE_CHECKING: - from collections.abc import Iterable - - from starlette.requests import Request - -DEFAULT_INTAKE_TTL_SECONDS = 600 -SECRET_INTAKE_PREFIX = "secret_intake::" -_SIGNING_KEY_ENV = "AIRBYTE_MCP_SECRET_INTAKE_SIGNING_KEY" -_PROCESS_SIGNING_KEY = secrets.token_bytes(32) - - -class SecretIntakeError(ValueError): - """Raised when a secret intake token or reference is invalid.""" - - -@dataclass -class _IntakeRecord: - allowed_fields: frozenset[str] - expires_at: float - tenant_claim: str - secrets: dict[str, str] - used: bool = False - - -_INTAKES: dict[str, _IntakeRecord] = {} -_INTAKES_LOCK = threading.Lock() - - -def _signing_key() -> bytes: - configured = os.getenv(_SIGNING_KEY_ENV, "") - return configured.encode() if configured else _PROCESS_SIGNING_KEY - - -def _tenant_claim(token: str | None = None) -> str: - if token is None: - token = _resolve_transport_bearer_token() - return hashlib.sha256(token.encode()).hexdigest() if token else "" - - -def _encode(value: bytes) -> str: - return base64.urlsafe_b64encode(value).rstrip(b"=").decode() - - -def _decode(value: str) -> bytes: - return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) - - -def mint_intake_token( - allowed_secret_fields: Iterable[str], - *, - tenant_claim: str | None = None, - ttl_seconds: int = DEFAULT_INTAKE_TTL_SECONDS, -) -> str: - """Mint a signed, one-time token for collecting connector secrets. - - The signing key comes from `AIRBYTE_MCP_SECRET_INTAKE_SIGNING_KEY`. When - unset, a process-random key is used, which is suitable only for a - single-process spike. - """ - fields = frozenset(allowed_secret_fields) - if any(not field for field in fields): - raise SecretIntakeError("Secret field names must be non-empty.") - if ttl_seconds <= 0: - raise SecretIntakeError("The intake token TTL must be positive.") - - intake_id = secrets.token_urlsafe(18) - expires_at = time.time() + ttl_seconds - claim = tenant_claim if tenant_claim is not None else _tenant_claim() - payload = { - "intake_id": intake_id, - "allowed_fields": sorted(fields), - "tenant_claim": claim, - "expires_at": expires_at, - } - encoded_payload = _encode(json.dumps(payload, separators=(",", ":")).encode()) - signature = hmac.new( - _signing_key(), - encoded_payload.encode(), - hashlib.sha256, - ).digest() - with _INTAKES_LOCK: - _prune_expired_locked() - _INTAKES[intake_id] = _IntakeRecord( - allowed_fields=fields, - expires_at=expires_at, - tenant_claim=claim, - secrets={}, - ) - return f"{encoded_payload}.{_encode(signature)}" - - -def _decode_and_verify(token: str) -> tuple[str, _IntakeRecord]: - try: - encoded_payload, encoded_signature = token.split(".", 1) - expected_signature = hmac.new( - _signing_key(), - encoded_payload.encode(), - hashlib.sha256, - ).digest() - if not hmac.compare_digest(_decode(encoded_signature), expected_signature): - raise SecretIntakeError("Invalid intake token.") - payload = json.loads(_decode(encoded_payload)) - intake_id = payload["intake_id"] - allowed_fields = frozenset(payload["allowed_fields"]) - tenant_claim = payload["tenant_claim"] - expires_at = float(payload["expires_at"]) - except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error: - raise SecretIntakeError("Invalid intake token.") from error - - if not isinstance(intake_id, str) or not isinstance(tenant_claim, str): - raise SecretIntakeError("Invalid intake token.") - if time.time() >= expires_at: - raise SecretIntakeError("Intake token expired.") - with _INTAKES_LOCK: - record = _INTAKES.get(intake_id) - if ( - record is None - or record.used - or record.expires_at != expires_at - or record.allowed_fields != allowed_fields - or record.tenant_claim != tenant_claim - ): - raise SecretIntakeError("Invalid intake token.") - return intake_id, record - - -def _prune_expired_locked(*, now: float | None = None) -> None: - current_time = time.time() if now is None else now - for intake_id, record in list(_INTAKES.items()): - if record.expires_at <= current_time: - del _INTAKES[intake_id] - - -def store_intake_secrets(token: str, values: dict[str, str]) -> dict[str, str]: - """Validate and consume a token after storing its submitted values.""" - intake_id, _ = _decode_and_verify(token) - if not isinstance(values, dict) or not values: - raise SecretIntakeError("Secret values are required.") - if any( - not isinstance(field, str) or not isinstance(value, str) for field, value in values.items() - ): - raise SecretIntakeError("Secret field names and values must be strings.") - - with _INTAKES_LOCK: - record = _INTAKES.get(intake_id) - if record is None or record.used or time.time() >= record.expires_at: - raise SecretIntakeError("Invalid intake token.") - if not set(values).issubset(record.allowed_fields): - raise SecretIntakeError("One or more secret fields are not allowed.") - record.secrets = dict(values) - record.used = True - return {field: f"{SECRET_INTAKE_PREFIX}{intake_id}/{field}" for field in values} - - -def _schema_secret_paths(schema: Mapping[str, Any], prefix: str = "") -> set[str]: - paths: set[str] = set() - properties = schema.get("properties", {}) - if isinstance(properties, Mapping): - for name, child in properties.items(): - if not isinstance(name, str) or not isinstance(child, Mapping): - continue - path = f"{prefix}.{name}" if prefix else name - if child.get("airbyte_secret") is True: - paths.add(path) - paths.update(_schema_secret_paths(child, path)) - for branch_key in ("oneOf", "anyOf", "allOf"): - branches = schema.get(branch_key, []) - if not isinstance(branches, list): - continue - for branch in branches: - if isinstance(branch, Mapping): - paths.update(_schema_secret_paths(branch, prefix)) - return paths - - -def resolve_intake_secrets( - config: dict[str, Any], - *, - allowed_paths: set[str] | None = None, -) -> dict[str, Any]: - """Resolve `secret_intake::` references for the current transport tenant.""" - caller_claim = _tenant_claim() - - def resolve(value: object, path: tuple[str, ...] = ()) -> object: - if isinstance(value, dict): - return {key: resolve(item, (*path, key)) for key, item in value.items()} - if isinstance(value, list): - return [resolve(item, path) for item in value] - if not isinstance(value, str) or not value.startswith(SECRET_INTAKE_PREFIX): - return value - - field_path = ".".join(path) - reference = value[len(SECRET_INTAKE_PREFIX) :] - try: - intake_id, field = reference.split("/", 1) - except ValueError as error: - raise SecretIntakeError("Invalid secret intake reference.") from error - if ( - not path - or field != path[-1] - or (allowed_paths is not None and field_path not in allowed_paths) - ): - raise SecretIntakeError("Invalid secret intake reference.") - with _INTAKES_LOCK: - record = _INTAKES.get(intake_id) - if ( - record is None - or not record.used - or time.time() >= record.expires_at - or record.tenant_claim != caller_claim - or field not in record.secrets - ): - raise SecretIntakeError("Invalid secret intake reference.") - return record.secrets[field] - - return cast(dict[str, Any], resolve(config)) - - -async def secret_intake_endpoint(request: Request) -> JSONResponse: - """Accept secret values without returning them to the caller.""" - if request.method == "OPTIONS": - return _cors_response({}, status_code=204) - authorization = request.headers.get("authorization", "") - if not authorization.lower().startswith("bearer "): - return _cors_response({"error": "Unauthorized."}, status_code=401) - token = authorization[7:].strip() - try: - body = await request.json() - secret_values = body.get("secrets") if isinstance(body, dict) else None - if not isinstance(secret_values, dict): - secret_values = {} - secret_refs = store_intake_secrets(token, secret_values) - except (SecretIntakeError, ValueError, TypeError): - return _cors_response({"error": "Invalid secret intake request."}, status_code=403) - return _cors_response({"secret_refs": secret_refs}) - - -def _cors_response(body: dict[str, Any], *, status_code: int = 200) -> JSONResponse: - return JSONResponse( - body, - status_code=status_code, - headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "Authorization, Content-Type", - "Access-Control-Allow-Methods": "POST, OPTIONS", - }, - ) - - -def secret_intake_routes() -> list[Route]: - """Return Starlette routes for the out-of-band secret intake endpoint.""" - return [ - Route( - "/secret-intake", - secret_intake_endpoint, - methods=["POST", "OPTIONS"], - ), - ] - - -__all__ = [ - "DEFAULT_INTAKE_TTL_SECONDS", - "SECRET_INTAKE_PREFIX", - "SecretIntakeError", - "mint_intake_token", - "resolve_intake_secrets", - "secret_intake_endpoint", - "secret_intake_routes", - "store_intake_secrets", -] diff --git a/airbyte/mcp/interactive/_connector_config_form_ui.py b/airbyte/mcp/interactive/_connector_config_form_ui.py index f4e24b82f..5f9cfdb93 100644 --- a/airbyte/mcp/interactive/_connector_config_form_ui.py +++ b/airbyte/mcp/interactive/_connector_config_form_ui.py @@ -6,18 +6,31 @@ import json import os from collections.abc import Mapping -from typing import Annotated, Any +from typing import Annotated, Any, Literal from urllib.parse import urlparse +from fastmcp import Context # noqa: TC002 from fastmcp.apps import UI_MIME_TYPE, AppConfig, ResourceCSP from fastmcp.tools.base import ToolResult -from fastmcp_extensions import mcp_resource +from fastmcp_extensions import get_mcp_config, mcp_resource from pydantic import Field from airbyte import exceptions as exc from airbyte._util.registry_spec import get_connector_spec_from_registry -from airbyte.mcp._secret_intake import _schema_secret_paths, mint_intake_token -from airbyte.mcp._tool_utils import INTERACTIVE_UI_ANNOTATION, mcp_tool +from airbyte.constants import ( + CLOUD_API_ROOT_ENV_VAR, + CLOUD_BEARER_TOKEN_ENV_VAR, + CLOUD_CLIENT_ID_ENV_VAR, + CLOUD_CLIENT_SECRET_ENV_VAR, + CLOUD_CONFIG_API_ROOT_ENV_VAR, + CLOUD_WORKSPACE_ID_ENV_VAR, +) +from airbyte.mcp._config_submit import _schema_secret_paths, mint_action_token +from airbyte.mcp._tool_utils import ( + INTERACTIVE_UI_ANNOTATION, + _resolve_transport_bearer_token, + mcp_tool, +) CONNECTOR_FORM_RESOURCE_URI = "ui://airbyte/connector-config-form" @@ -96,26 +109,29 @@ }; form.addEventListener("submit", async (event) => { event.preventDefault(); status.className = ""; status.textContent = "Saving…"; - const visible = {}, secrets = {}; + const config = JSON.parse(JSON.stringify(state.result.non_secret_defaults || {})); + const visible = JSON.parse(JSON.stringify(state.result.non_secret_defaults || {})); form.querySelectorAll("input").forEach((input) => { if (!input.value) return; - if (state.result.secret_fields.includes(input.name)) secrets[input.name] = input.value; - else setPath(visible, input.name, input.value); + setPath(config, input.name, input.value); + if (!state.result.secret_fields.includes(input.name)) + setPath(visible, input.name, input.value); }); try { - let body = {secret_refs: {}}; - if (Object.keys(secrets).length) { - const response = await fetch(state.result.intake_endpoint, { - method: "POST", headers: {"Authorization": `Bearer ${state.result.intake_token}`, - "Content-Type": "application/json"}, body: JSON.stringify({secrets}) - }); - if (!response.ok) throw new Error("The server rejected the secret submission."); - body = await response.json(); - } - const payload = {status: "submitted", visible_config: visible, secret_refs: body.secret_refs}; + const response = await fetch(state.result.submit_endpoint, { + method: "POST", headers: {"Authorization": `Bearer ${state.result.submit_token}`, + "Content-Type": "application/json"}, body: JSON.stringify({config}) + }); + if (!response.ok) throw new Error("The server rejected the configuration."); + const body = await response.json(); + const payload = {status: body.status, action: body.action, visible_config: visible}; + if (typeof body.connector_id === "string") payload.connector_id = body.connector_id; + if (typeof body.connector_url === "string") payload.connector_url = body.connector_url; post({jsonrpc: "2.0", method: "ui/updateModelContext", params: {content: payload}}); status.className = "success"; status.textContent = "Configuration submitted."; - } catch (error) { status.className = "error"; status.textContent = error.message; } + } catch (error) { + status.className = "error"; status.textContent = "The configuration could not be submitted."; + } }); window.addEventListener("message", (event) => { if (event.source !== window.parent) return; @@ -158,8 +174,66 @@ def _server_origin() -> str: return f"{parsed.scheme}://{parsed.netloc}" -def _intake_endpoint() -> str: - return f"{_server_url()}/secret-intake" +def _submit_endpoint() -> str: + return f"{_server_url()}/connector-config-submit" + + +def _cloud_config_value(env_name: str) -> str | None: + value = os.getenv(env_name, "").strip() + return value or None + + +def _resolved_cloud_config_value( + ctx: Context | None, + config_name: str, + env_name: str, +) -> str | None: + if ctx is not None: + configured = get_mcp_config(ctx, config_name) + if configured: + return configured + return _cloud_config_value(env_name) + + +def _mint_form_token( + connector_name: str, + *, + ctx: Context | None, + source_id: str | None, + workspace_id: str | None, + source_name: str | None, +) -> tuple[str, str]: + resolved_workspace_id = workspace_id or _resolved_cloud_config_value( + ctx, "workspace_id", CLOUD_WORKSPACE_ID_ENV_VAR + ) + bearer_token = _resolve_transport_bearer_token() or _resolved_cloud_config_value( + ctx, "bearer_token", CLOUD_BEARER_TOKEN_ENV_VAR + ) + client_id = _resolved_cloud_config_value(ctx, "client_id", CLOUD_CLIENT_ID_ENV_VAR) + client_secret = _resolved_cloud_config_value(ctx, "client_secret", CLOUD_CLIENT_SECRET_ENV_VAR) + has_credentials = bool(bearer_token or (client_id and client_secret)) + action: Literal["create", "update", "validate"] + if source_id: + action = "update" + elif has_credentials and resolved_workspace_id: + action = "create" + else: + action = "validate" + token = mint_action_token( + action, + connector_name, + workspace_id=resolved_workspace_id, + source_id=source_id, + source_name=source_name, + bearer_token=bearer_token, + client_id=client_id if not bearer_token else None, + client_secret=client_secret if not bearer_token else None, + api_url=_resolved_cloud_config_value(ctx, "api_url", CLOUD_API_ROOT_ENV_VAR), + config_api_url=_resolved_cloud_config_value( + ctx, "config_api_url", CLOUD_CONFIG_API_ROOT_ENV_VAR + ), + ) + return action, token def _paths_present(value: object, prefix: str = "") -> set[str]: @@ -183,7 +257,7 @@ def _agent_content( """Build bounded text for agents that cannot render the form.""" note = ( "The user will enter secrets in the form. The model will only receive " - "opaque secret_intake:: references." + "a confirmation and non-secret configuration." ) payload: dict[str, Any] = { "connector_name": connector_name, @@ -241,6 +315,20 @@ def show_connector_config_form( ), ), ] = None, + *, + ctx: Context | None = None, + source_id: Annotated[ + str | None, + Field(description="Existing Cloud source ID to update.", default=None), + ] = None, + workspace_id: Annotated[ + str | None, + Field(description="Cloud workspace ID for creating the source.", default=None), + ] = None, + source_name: Annotated[ + str | None, + Field(description="Name to use when creating the source.", default=None), + ] = None, ) -> ToolResult: """Show a hosted-safe connector configuration form.""" if config_defaults is None: @@ -279,7 +367,13 @@ def show_connector_config_form( context={"secret_fields": supplied_secrets}, ) - intake_token = mint_intake_token(secret_fields) + action, submit_token = _mint_form_token( + connector_name, + ctx=ctx, + source_id=source_id, + workspace_id=workspace_id, + source_name=source_name, + ) content = _agent_content(connector_name, config_defaults, secret_fields) structured_content = { "connector_name": connector_name, @@ -287,8 +381,9 @@ def show_connector_config_form( "spec_schema": spec_schema, "non_secret_defaults": config_defaults, "secret_fields": secret_fields, - "intake_token": intake_token, - "intake_endpoint": _intake_endpoint(), + "action": action, + "submit_token": submit_token, + "submit_endpoint": _submit_endpoint(), } return ToolResult(content=content, structured_content=structured_content) diff --git a/airbyte/mcp/server.py b/airbyte/mcp/server.py index 53230adf9..f8b9fe708 100644 --- a/airbyte/mcp/server.py +++ b/airbyte/mcp/server.py @@ -73,7 +73,7 @@ from airbyte._util.telemetry import DO_NOT_TRACK, PYAIRBYTE_APP_TRACKING_KEY from airbyte.constants import AIRBYTE_OFFLINE_MODE, _str_to_bool, is_hosted_mcp_mode from airbyte.mcp._config import load_secrets_to_env_vars -from airbyte.mcp._secret_intake import secret_intake_endpoint +from airbyte.mcp._config_submit import connector_config_submit_endpoint from airbyte.mcp._tool_utils import ( AIRBYTE_EXCLUDE_MODULES_CONFIG_ARG, AIRBYTE_INCLUDE_MODULES_CONFIG_ARG, @@ -384,10 +384,10 @@ async def health_check(request: Request) -> JSONResponse: # noqa: ARG001, RUF02 return JSONResponse({"status": "ok"}) -@app.custom_route("/secret-intake", methods=["POST", "OPTIONS"]) -async def secret_intake(request: Request) -> JSONResponse: - """Accept connector secrets out of band from the MCP tool call.""" - return await secret_intake_endpoint(request) +@app.custom_route("/connector-config-submit", methods=["POST", "OPTIONS"]) +async def connector_config_submit(request: Request) -> JSONResponse: + """Execute a connector configuration action from the MCP App form.""" + return await connector_config_submit_endpoint(request) def main() -> None: diff --git a/tests/unit_tests/test_mcp_config_submit.py b/tests/unit_tests/test_mcp_config_submit.py new file mode 100644 index 000000000..a1cf1747c --- /dev/null +++ b/tests/unit_tests/test_mcp_config_submit.py @@ -0,0 +1,185 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Tests for stateless MCP connector configuration submission.""" + +from __future__ import annotations + +import base64 +import time + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from airbyte.mcp import _config_submit +from airbyte.mcp._config_submit import ( + ConfigSubmitError, + _schema_secret_paths, + connector_config_submit_routes, + decrypt_action_token, + mint_action_token, +) + + +def test_token_encrypts_and_decrypts_claims(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AIRBYTE_MCP_FORM_SIGNING_KEY", "test-signing-key") + token = mint_action_token( + "create", + "source-example", + workspace_id="workspace-id", + source_name="Example", + bearer_token="bearer-secret", + api_url="https://api.example.com", + ) + + assert "bearer-secret" not in token + claims = decrypt_action_token(token) + assert claims["action"] == "create" + assert claims["connector_name"] == "source-example" + assert claims["workspace_id"] == "workspace-id" + assert claims["bearer_token"] == "bearer-secret" + + +def test_token_rejects_expiry_and_replay(monkeypatch: pytest.MonkeyPatch) -> None: + token = mint_action_token("validate", "source-example", ttl_seconds=1) + now = time.time() + monkeypatch.setattr(_config_submit.time, "time", lambda: now + 2) + + with pytest.raises(ConfigSubmitError, match="expired"): + decrypt_action_token(token) + + monkeypatch.setattr(_config_submit.time, "time", lambda: now) + token = mint_action_token("validate", "source-example") + decrypt_action_token(token) + with pytest.raises(ConfigSubmitError, match="already used"): + decrypt_action_token(token) + + +def test_token_rejects_ciphertext_tampering() -> None: + token = mint_action_token("validate", "source-example") + encoded = bytearray(base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))) + encoded[-1] ^= 1 + tampered = base64.urlsafe_b64encode(encoded).rstrip(b"=").decode() + + with pytest.raises(ConfigSubmitError, match="Invalid"): + decrypt_action_token(tampered) + + +def test_token_rejects_invalid_claims() -> None: + with pytest.raises(ConfigSubmitError, match="Invalid configuration action"): + mint_action_token("invalid", "source-example") # type: ignore[arg-type] + + +def test_schema_secret_paths_include_nested_branches() -> None: + schema = { + "oneOf": [ + { + "type": "object", + "properties": { + "credentials": { + "type": "object", + "properties": { + "api_key": {"type": "string", "airbyte_secret": True}, + }, + }, + }, + }, + { + "type": "object", + "properties": { + "credentials": { + "type": "object", + "properties": { + "password": {"type": "string", "airbyte_secret": True}, + }, + }, + }, + }, + ] + } + + assert _schema_secret_paths(schema) == { + "credentials.api_key", + "credentials.password", + } + + +class _SourceStub: + def __init__(self) -> None: + self.config: dict[str, object] | None = None + + def set_config(self, config: dict[str, object], *, validate: bool = True) -> None: + self.config = config + assert validate + + +def test_validate_endpoint_executes_full_config_without_echo( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = _SourceStub() + monkeypatch.setattr(_config_submit, "get_source", lambda *args, **kwargs: source) + token = mint_action_token("validate", "source-example") + app = Starlette(routes=connector_config_submit_routes()) + config = {"credentials": {"api_key": "secret-value"}, "region": "us-east-1"} + + with TestClient(app) as client: + response = client.post( + "/connector-config-submit", + headers={"Authorization": f"Bearer {token}"}, + json={"config": config}, + ) + + assert response.status_code == 200 + assert response.json() == {"status": "success", "action": "validated"} + assert source.config == config + assert "secret-value" not in response.text + + +def test_endpoint_rejects_missing_malformed_and_reused_tokens( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + _config_submit, "get_source", lambda *args, **kwargs: _SourceStub() + ) + app = Starlette(routes=connector_config_submit_routes()) + + with TestClient(app) as client: + missing = client.post( + "/connector-config-submit", + json={"config": {"password": "value"}}, + ) + malformed = client.post( + "/connector-config-submit", + headers={"Authorization": "Bearer invalid"}, + json={"config": {}}, + ) + token = mint_action_token("validate", "source-example") + first = client.post( + "/connector-config-submit", + headers={"Authorization": f"Bearer {token}"}, + json={"config": {}}, + ) + reused = client.post( + "/connector-config-submit", + headers={"Authorization": f"Bearer {token}"}, + json={"config": {}}, + ) + + assert missing.status_code == 401 + assert malformed.status_code == 403 + assert first.status_code == 200 + assert reused.status_code == 403 + + +def test_endpoint_rejects_missing_config_without_echoing_secrets() -> None: + app = Starlette(routes=connector_config_submit_routes()) + token = mint_action_token("validate", "source-example") + + with TestClient(app) as client: + response = client.post( + "/connector-config-submit", + headers={"Authorization": f"Bearer {token}"}, + json={"not_config": "secret-value"}, + ) + + assert response.status_code == 400 + assert "secret-value" not in response.text diff --git a/tests/unit_tests/test_mcp_connector_config_form.py b/tests/unit_tests/test_mcp_connector_config_form.py index ef5073117..33a173f20 100644 --- a/tests/unit_tests/test_mcp_connector_config_form.py +++ b/tests/unit_tests/test_mcp_connector_config_form.py @@ -26,7 +26,7 @@ } -def test_form_result_contains_schema_and_opaque_intake_data( +def test_form_result_contains_schema_and_submit_data( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( @@ -43,8 +43,10 @@ def test_form_result_contains_schema_and_opaque_intake_data( assert "source-example" in content assert "api_key" in content assert structured["spec_schema"] == SCHEMA - assert structured["intake_endpoint"].endswith("/secret-intake") - assert structured["intake_token"] + assert structured["submit_endpoint"].endswith("/connector-config-submit") + assert structured["submit_token"] + assert "intake_token" not in structured + assert "intake_endpoint" not in structured assert "secret-value" not in content assert "secret-value" not in json.dumps(structured) @@ -61,10 +63,13 @@ def test_form_rejects_secret_defaults(monkeypatch: pytest.MonkeyPatch) -> None: ) -def test_intake_endpoint_preserves_server_path(monkeypatch: pytest.MonkeyPatch) -> None: +def test_submit_endpoint_preserves_server_path(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(form.MCP_SERVER_URL_ENV, "https://example.com/cloud-mcp/") - assert form._intake_endpoint() == "https://example.com/cloud-mcp/secret-intake" + assert ( + form._submit_endpoint() + == "https://example.com/cloud-mcp/connector-config-submit" + ) assert form._server_origin() == "https://example.com" @@ -95,7 +100,53 @@ def test_server_origin_allows_local_http_url(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setenv(form.MCP_SERVER_URL_ENV, "http://localhost:8080/cloud-mcp") assert form._server_origin() == "http://localhost:8080" - assert form._intake_endpoint() == "http://localhost:8080/cloud-mcp/secret-intake" + assert ( + form._submit_endpoint() + == "http://localhost:8080/cloud-mcp/connector-config-submit" + ) + + +def test_form_selects_create_action_with_cloud_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(form.CLOUD_BEARER_TOKEN_ENV_VAR, "cloud-secret") + monkeypatch.setenv(form.CLOUD_WORKSPACE_ID_ENV_VAR, "workspace-id") + monkeypatch.setattr( + form, "get_connector_spec_from_registry", lambda *args, **kwargs: SCHEMA + ) + + result = form.show_connector_config_form("source-example") + + assert result.structured_content["action"] == "create" + assert "cloud-secret" not in json.dumps(result.structured_content) + + +def test_form_selects_update_action_with_source_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + form, "get_connector_spec_from_registry", lambda *args, **kwargs: SCHEMA + ) + + result = form.show_connector_config_form("source-example", source_id="source-id") + + assert result.structured_content["action"] == "update" + + +def test_form_selects_validate_without_cloud_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(form.CLOUD_BEARER_TOKEN_ENV_VAR, raising=False) + monkeypatch.delenv(form.CLOUD_CLIENT_ID_ENV_VAR, raising=False) + monkeypatch.delenv(form.CLOUD_CLIENT_SECRET_ENV_VAR, raising=False) + monkeypatch.delenv(form.CLOUD_WORKSPACE_ID_ENV_VAR, raising=False) + monkeypatch.setattr( + form, "get_connector_spec_from_registry", lambda *args, **kwargs: SCHEMA + ) + + result = form.show_connector_config_form("source-example") + + assert result.structured_content["action"] == "validate" def test_form_blocks_prototype_pollution_paths() -> None: @@ -132,3 +183,5 @@ def test_form_handles_one_of_schema_without_plaintext_input( html = form.connector_config_form_resource() assert "Complex authentication objects are not supported by this form." in html assert "Array.isArray(child[key])" in html + assert "state.result.submit_endpoint" in html + assert "JSON.stringify({config})" in html diff --git a/tests/unit_tests/test_mcp_secret_intake.py b/tests/unit_tests/test_mcp_secret_intake.py deleted file mode 100644 index ff2889f94..000000000 --- a/tests/unit_tests/test_mcp_secret_intake.py +++ /dev/null @@ -1,224 +0,0 @@ -# Copyright (c) 2026 Airbyte, Inc., all rights reserved. -"""Tests for out-of-band MCP secret intake.""" - -from __future__ import annotations - -import json -import time - -import pytest -from starlette.applications import Starlette -from starlette.testclient import TestClient - -from airbyte.mcp import _secret_intake -from airbyte.mcp._secret_intake import ( - SecretIntakeError, - _schema_secret_paths, - mint_intake_token, - resolve_intake_secrets, - secret_intake_routes, - store_intake_secrets, -) -from airbyte.mcp._arg_resolvers import resolve_connector_config - - -def _intake_id(token: str) -> str: - payload = json.loads(_secret_intake._decode(token.split(".", 1)[0])) - return payload["intake_id"] - - -@pytest.fixture -def local_tenant(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(_secret_intake, "_resolve_transport_bearer_token", lambda: "") - - -def test_token_roundtrip_and_replay(local_tenant: None) -> None: - token = mint_intake_token(["password"]) - - assert store_intake_secrets(token, {"password": "secret-value"}) == { - "password": f"secret_intake::{_intake_id(token)}/password" - } - with pytest.raises(SecretIntakeError, match="Invalid intake token"): - store_intake_secrets(token, {"password": "another-value"}) - - -def test_token_rejects_tampering_and_disallowed_fields(local_tenant: None) -> None: - token = mint_intake_token(["password"]) - encoded_payload, signature = token.split(".") - - with pytest.raises(SecretIntakeError, match="Invalid intake token"): - store_intake_secrets(f"{encoded_payload}x.{signature}", {"password": "value"}) - token = mint_intake_token(["password"]) - with pytest.raises(SecretIntakeError, match="not allowed"): - store_intake_secrets(token, {"other": "value"}) - - -def test_expired_token_fails( - local_tenant: None, monkeypatch: pytest.MonkeyPatch -) -> None: - token = mint_intake_token(["password"], ttl_seconds=1) - now = time.time() - monkeypatch.setattr(_secret_intake.time, "time", lambda: now + 2) - - with pytest.raises(SecretIntakeError, match="expired"): - store_intake_secrets(token, {"password": "value"}) - - -def test_endpoint_returns_only_opaque_references(local_tenant: None) -> None: - app = Starlette(routes=secret_intake_routes()) - token = mint_intake_token(["password"]) - - with TestClient(app) as client: - response = client.post( - "/secret-intake", - headers={"Authorization": f"Bearer {token}"}, - json={"secrets": {"password": "secret-value"}}, - ) - - assert response.status_code == 200 - assert "secret-value" not in response.text - assert response.json()["secret_refs"]["password"].startswith("secret_intake::") - - -def test_endpoint_rejects_missing_and_reused_tokens(local_tenant: None) -> None: - app = Starlette(routes=secret_intake_routes()) - - with TestClient(app) as client: - missing = client.post("/secret-intake", json={"secrets": {"password": "value"}}) - token = mint_intake_token(["password"]) - first = client.post( - "/secret-intake", - headers={"Authorization": f"Bearer {token}"}, - json={"secrets": {"password": "value"}}, - ) - reused = client.post( - "/secret-intake", - headers={"Authorization": f"Bearer {token}"}, - json={"secrets": {"password": "value"}}, - ) - - assert missing.status_code == 401 - assert first.status_code == 200 - assert reused.status_code == 403 - - -def test_resolve_intake_secrets_enforces_tenant( - local_tenant: None, monkeypatch: pytest.MonkeyPatch -) -> None: - token = mint_intake_token(["password"], tenant_claim="") - refs = store_intake_secrets(token, {"password": "secret-value"}) - config = {"credentials": {"password": refs["password"]}} - - assert resolve_intake_secrets(config)["credentials"]["password"] == "secret-value" - - monkeypatch.setattr( - _secret_intake, "_resolve_transport_bearer_token", lambda: "tenant-b" - ) - with pytest.raises(SecretIntakeError, match="Invalid secret intake reference"): - resolve_intake_secrets(config) - - -def test_unknown_reference_fails(local_tenant: None) -> None: - with pytest.raises(SecretIntakeError, match="Invalid secret intake reference"): - resolve_intake_secrets({"password": "secret_intake::unknown/password"}) - - -def test_schema_secret_paths_include_one_of_branches() -> None: - schema = { - "oneOf": [ - { - "type": "object", - "properties": { - "credentials": { - "type": "object", - "properties": { - "api_key": {"type": "string", "airbyte_secret": True}, - }, - }, - }, - }, - { - "type": "object", - "properties": { - "credentials": { - "type": "object", - "properties": { - "password": {"type": "string", "airbyte_secret": True}, - }, - }, - }, - }, - ] - } - - assert _schema_secret_paths(schema) == { - "credentials.api_key", - "credentials.password", - } - - -def test_resolve_intake_secrets_rejects_wrong_field_path(local_tenant: None) -> None: - token = mint_intake_token(["password"]) - refs = store_intake_secrets(token, {"password": "secret-value"}) - - with pytest.raises(SecretIntakeError, match="Invalid secret intake reference"): - resolve_intake_secrets({"username": refs["password"]}) - - -def test_connector_config_resolves_intake_references( - local_tenant: None, -) -> None: - token = mint_intake_token(["password"]) - refs = store_intake_secrets(token, {"password": "secret-value"}) - - assert resolve_connector_config({"password": refs["password"]}) == { - "password": "secret-value" - } - - -def test_connector_config_resolves_intake_references_before_secret_check( - local_tenant: None, -) -> None: - token = mint_intake_token(["password"]) - refs = store_intake_secrets(token, {"password": "secret-value"}) - schema = { - "type": "object", - "properties": { - "credentials": { - "type": "object", - "properties": { - "password": {"type": "string", "airbyte_secret": True}, - }, - }, - }, - } - - assert resolve_connector_config( - {"credentials": {"password": refs["password"]}}, - config_spec_jsonschema=schema, - ) == {"credentials": {"password": "secret-value"}} - - -def test_connector_config_rejects_intake_reference_at_nonsecret_path( - local_tenant: None, -) -> None: - token = mint_intake_token(["password"]) - refs = store_intake_secrets(token, {"password": "secret-value"}) - schema = { - "type": "object", - "properties": { - "credentials": { - "type": "object", - "properties": { - "password": {"type": "string", "airbyte_secret": True}, - "username": {"type": "string"}, - }, - }, - }, - } - - with pytest.raises(SecretIntakeError, match="Invalid secret intake reference"): - resolve_connector_config( - {"credentials": {"username": refs["password"]}}, - config_spec_jsonschema=schema, - ) From c2ab8ea64b961bf6c44599c1c84a721e74888efd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:16:14 +0000 Subject: [PATCH 12/22] fix(mcp): reject unavailable source updates Co-Authored-By: AJ Steers --- .../interactive/_connector_config_form_ui.py | 4 ++++ .../test_mcp_connector_config_form.py | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/airbyte/mcp/interactive/_connector_config_form_ui.py b/airbyte/mcp/interactive/_connector_config_form_ui.py index 5f9cfdb93..1cee2a7d1 100644 --- a/airbyte/mcp/interactive/_connector_config_form_ui.py +++ b/airbyte/mcp/interactive/_connector_config_form_ui.py @@ -214,6 +214,10 @@ def _mint_form_token( has_credentials = bool(bearer_token or (client_id and client_secret)) action: Literal["create", "update", "validate"] if source_id: + if not (has_credentials and resolved_workspace_id): + raise exc.PyAirbyteInputError( + message="Cloud credentials and a workspace ID are required to update a source." + ) action = "update" elif has_credentials and resolved_workspace_id: action = "create" diff --git a/tests/unit_tests/test_mcp_connector_config_form.py b/tests/unit_tests/test_mcp_connector_config_form.py index 33a173f20..018912373 100644 --- a/tests/unit_tests/test_mcp_connector_config_form.py +++ b/tests/unit_tests/test_mcp_connector_config_form.py @@ -124,6 +124,8 @@ def test_form_selects_create_action_with_cloud_credentials( def test_form_selects_update_action_with_source_id( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setenv(form.CLOUD_BEARER_TOKEN_ENV_VAR, "cloud-token") + monkeypatch.setenv(form.CLOUD_WORKSPACE_ID_ENV_VAR, "workspace-id") monkeypatch.setattr( form, "get_connector_spec_from_registry", lambda *args, **kwargs: SCHEMA ) @@ -133,6 +135,25 @@ def test_form_selects_update_action_with_source_id( assert result.structured_content["action"] == "update" +def test_form_rejects_update_without_cloud_credentials_or_workspace( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(form.CLOUD_BEARER_TOKEN_ENV_VAR, raising=False) + monkeypatch.delenv(form.CLOUD_CLIENT_ID_ENV_VAR, raising=False) + monkeypatch.delenv(form.CLOUD_CLIENT_SECRET_ENV_VAR, raising=False) + monkeypatch.delenv(form.CLOUD_WORKSPACE_ID_ENV_VAR, raising=False) + monkeypatch.setattr(form, "_resolve_transport_bearer_token", lambda: "") + monkeypatch.setattr( + form, "get_connector_spec_from_registry", lambda *args, **kwargs: SCHEMA + ) + + with pytest.raises( + PyAirbyteInputError, + match="Cloud credentials and a workspace ID are required", + ): + form.show_connector_config_form("source-example", source_id="source-id") + + def test_form_selects_validate_without_cloud_credentials( monkeypatch: pytest.MonkeyPatch, ) -> None: From 5c36901b060df065bbd1121944eb430043e42606 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 05:14:23 +0000 Subject: [PATCH 13/22] fix(mcp): send spec-compliant ui initialize handshake Co-Authored-By: AJ Steers --- airbyte/mcp/interactive/_connector_config_form_ui.py | 8 ++++++-- tests/unit_tests/test_mcp_connector_config_form.py | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/airbyte/mcp/interactive/_connector_config_form_ui.py b/airbyte/mcp/interactive/_connector_config_form_ui.py index 1cee2a7d1..bda935abc 100644 --- a/airbyte/mcp/interactive/_connector_config_form_ui.py +++ b/airbyte/mcp/interactive/_connector_config_form_ui.py @@ -136,6 +136,10 @@ window.addEventListener("message", (event) => { if (event.source !== window.parent) return; const message = event.data || {}; + if (message.id === 1 && message.result) { + post({jsonrpc: "2.0", method: "ui/notifications/initialized"}); + return; + } if (message.method === "ui/notifications/tool-result" && message.params) { render( message.params.structuredContent || @@ -148,8 +152,8 @@ if (result) render(result.structuredContent || result.structured_content || result); }); post({jsonrpc: "2.0", id: 1, method: "ui/initialize", params: { - protocolVersion: "2025-06-18", capabilities: {}, - clientInfo: {name: "airbyte-form", version: "0.1"} + protocolVersion: "2025-06-18", appCapabilities: {}, + appInfo: {name: "airbyte-form", version: "0.1"} }}); })(); """ diff --git a/tests/unit_tests/test_mcp_connector_config_form.py b/tests/unit_tests/test_mcp_connector_config_form.py index 018912373..09c4ec1eb 100644 --- a/tests/unit_tests/test_mcp_connector_config_form.py +++ b/tests/unit_tests/test_mcp_connector_config_form.py @@ -206,3 +206,7 @@ def test_form_handles_one_of_schema_without_plaintext_input( assert "Array.isArray(child[key])" in html assert "state.result.submit_endpoint" in html assert "JSON.stringify({config})" in html + assert "appCapabilities: {}" in html + assert 'appInfo: {name: "airbyte-form", version: "0.1"}' in html + assert "message.id === 1 && message.result" in html + assert 'method: "ui/notifications/initialized"' in html From 62ebdb8ffafd9ea12cfed9f1e33153c70d2897a8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 05:54:57 +0000 Subject: [PATCH 14/22] fix(mcp): fix config form preflight and resource CSP Co-Authored-By: AJ Steers --- airbyte/mcp/_config_submit.py | 20 +++++++++------- airbyte/mcp/interactive/__init__.py | 9 ++++---- .../interactive/_connector_config_form_ui.py | 23 +++++++++++++------ tests/unit_tests/test_mcp_config_submit.py | 22 ++++++++++++++++++ .../test_mcp_connector_config_form.py | 22 ++++++++++++++++++ 5 files changed, 77 insertions(+), 19 deletions(-) diff --git a/airbyte/mcp/_config_submit.py b/airbyte/mcp/_config_submit.py index f44434ec9..b1159071d 100644 --- a/airbyte/mcp/_config_submit.py +++ b/airbyte/mcp/_config_submit.py @@ -25,7 +25,7 @@ import jsonschema from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from starlette.responses import JSONResponse +from starlette.responses import JSONResponse, Response from starlette.routing import Route from airbyte import get_source @@ -267,10 +267,10 @@ def _execute_action(claims: Mapping[str, Any], config: dict[str, Any]) -> dict[s } -async def connector_config_submit_endpoint(request: Request) -> JSONResponse: # noqa: PLR0911 +async def connector_config_submit_endpoint(request: Request) -> Response: # noqa: PLR0911 """Execute a one-shot connector configuration action without echoing config.""" if request.method == "OPTIONS": - return _cors_response({}, status_code=204) + return Response(status_code=204, headers=_cors_headers()) authorization = request.headers.get("authorization", "") if not authorization.lower().startswith("bearer "): return _cors_response({"error": "Unauthorized."}, status_code=401) @@ -308,14 +308,18 @@ def _cors_response(body: dict[str, Any], *, status_code: int = 200) -> JSONRespo return JSONResponse( body, status_code=status_code, - headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "Authorization, Content-Type", - "Access-Control-Allow-Methods": "POST, OPTIONS", - }, + headers=_cors_headers(), ) +def _cors_headers() -> dict[str, str]: + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + } + + def connector_config_submit_routes() -> list[Route]: """Return the Starlette routes for connector configuration submission.""" return [ diff --git a/airbyte/mcp/interactive/__init__.py b/airbyte/mcp/interactive/__init__.py index 29a45bf7a..07ece2928 100644 --- a/airbyte/mcp/interactive/__init__.py +++ b/airbyte/mcp/interactive/__init__.py @@ -5,10 +5,11 @@ from typing import TYPE_CHECKING -from fastmcp_extensions import register_mcp_resources - from airbyte.mcp._tool_utils import register_mcp_tools -from airbyte.mcp.interactive._connector_config_form_ui import show_connector_config_form +from airbyte.mcp.interactive._connector_config_form_ui import ( + register_connector_config_form_resource, + show_connector_config_form, +) from airbyte.mcp.interactive._registry_ui import show_connectors_list from airbyte.mcp.interactive._sync_history_ui import show_connection_sync_history from airbyte.mcp.interactive._workspace_sync_status_ui import show_workspace_sync_status @@ -21,7 +22,7 @@ def register_interactive_tools(app: FastMCP) -> None: """Register UI-presenting tools.""" register_mcp_tools(app, mcp_module="interactive") - register_mcp_resources(app, mcp_module="_connector_config_form_ui") + register_connector_config_form_resource(app) __all__ = [ diff --git a/airbyte/mcp/interactive/_connector_config_form_ui.py b/airbyte/mcp/interactive/_connector_config_form_ui.py index bda935abc..dd8730ef5 100644 --- a/airbyte/mcp/interactive/_connector_config_form_ui.py +++ b/airbyte/mcp/interactive/_connector_config_form_ui.py @@ -6,13 +6,13 @@ import json import os from collections.abc import Mapping -from typing import Annotated, Any, Literal +from typing import TYPE_CHECKING, Annotated, Any, Literal from urllib.parse import urlparse from fastmcp import Context # noqa: TC002 from fastmcp.apps import UI_MIME_TYPE, AppConfig, ResourceCSP from fastmcp.tools.base import ToolResult -from fastmcp_extensions import get_mcp_config, mcp_resource +from fastmcp_extensions import get_mcp_config from pydantic import Field from airbyte import exceptions as exc @@ -33,6 +33,10 @@ ) +if TYPE_CHECKING: + from fastmcp import FastMCP + + CONNECTOR_FORM_RESOURCE_URI = "ui://airbyte/connector-config-form" MCP_SERVER_URL_ENV = "MCP_SERVER_URL" DEFAULT_HTTP_PORT = 8080 @@ -289,16 +293,21 @@ def _agent_content( return json.dumps(payload, separators=(",", ":")) -@mcp_resource( - CONNECTOR_FORM_RESOURCE_URI, - "Self-contained Airbyte connector configuration form.", - UI_MIME_TYPE, -) def connector_config_form_resource() -> str: """Return the raw HTML connector configuration form.""" return _HTML_RESOURCE +def register_connector_config_form_resource(app: FastMCP) -> None: + """Register the connector form resource with its CSP metadata.""" + app.resource( + CONNECTOR_FORM_RESOURCE_URI, + description="Self-contained Airbyte connector configuration form.", + mime_type=UI_MIME_TYPE, + app=AppConfig(csp=ResourceCSP(connect_domains=[_server_origin()])), + )(connector_config_form_resource) + + @mcp_tool( read_only=True, idempotent=True, diff --git a/tests/unit_tests/test_mcp_config_submit.py b/tests/unit_tests/test_mcp_config_submit.py index a1cf1747c..2b607bc88 100644 --- a/tests/unit_tests/test_mcp_config_submit.py +++ b/tests/unit_tests/test_mcp_config_submit.py @@ -183,3 +183,25 @@ def test_endpoint_rejects_missing_config_without_echoing_secrets() -> None: assert response.status_code == 400 assert "secret-value" not in response.text + + +def test_options_response_is_bodyless_and_keeps_cors_headers() -> None: + app = Starlette(routes=connector_config_submit_routes()) + + with TestClient(app) as client: + response = client.options( + "/connector-config-submit", + headers={ + "Origin": "https://example.com", + "Access-Control-Request-Method": "POST", + }, + ) + + assert response.status_code == 204 + assert response.content == b"" + assert response.headers["access-control-allow-origin"] == "*" + assert ( + response.headers["access-control-allow-headers"] + == "Authorization, Content-Type" + ) + assert response.headers["access-control-allow-methods"] == "POST, OPTIONS" diff --git a/tests/unit_tests/test_mcp_connector_config_form.py b/tests/unit_tests/test_mcp_connector_config_form.py index 09c4ec1eb..9e7c3ae1b 100644 --- a/tests/unit_tests/test_mcp_connector_config_form.py +++ b/tests/unit_tests/test_mcp_connector_config_form.py @@ -3,6 +3,7 @@ from __future__ import annotations +import asyncio import json import pytest @@ -210,3 +211,24 @@ def test_form_handles_one_of_schema_without_plaintext_input( assert 'appInfo: {name: "airbyte-form", version: "0.1"}' in html assert "message.id === 1 && message.result" in html assert 'method: "ui/notifications/initialized"' in html + + +def test_connector_form_resource_includes_csp_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from fastmcp_extensions import mcp_server + + from airbyte.mcp import interactive + + monkeypatch.setenv(form.MCP_SERVER_URL_ENV, "http://localhost:8080") + app = mcp_server(name="test") + interactive.register_interactive_tools(app) + + resource = next( + item + for item in asyncio.run(app.list_resources()) + if str(item.uri) == form.CONNECTOR_FORM_RESOURCE_URI + ) + + assert resource.meta is not None + assert resource.meta["ui"]["csp"]["connectDomains"] == ["http://localhost:8080"] From 9755438c496c3606489acfd07ab6c0ff1a467dee Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 05:56:08 +0000 Subject: [PATCH 15/22] fix(mcp): widen config submit route return type to Response Co-Authored-By: AJ Steers --- airbyte/mcp/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/airbyte/mcp/server.py b/airbyte/mcp/server.py index f8b9fe708..fd6ab11ff 100644 --- a/airbyte/mcp/server.py +++ b/airbyte/mcp/server.py @@ -61,7 +61,7 @@ build_mcp_auth, mcp_server, ) -from starlette.responses import JSONResponse +from starlette.responses import JSONResponse, Response if TYPE_CHECKING: @@ -385,7 +385,7 @@ async def health_check(request: Request) -> JSONResponse: # noqa: ARG001, RUF02 @app.custom_route("/connector-config-submit", methods=["POST", "OPTIONS"]) -async def connector_config_submit(request: Request) -> JSONResponse: +async def connector_config_submit(request: Request) -> Response: """Execute a connector configuration action from the MCP App form.""" return await connector_config_submit_endpoint(request) From eada4ef0de9227157349d0fe29ead3103da8872e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 06:33:23 +0000 Subject: [PATCH 16/22] fix(mcp): harden connector form metadata and secret handling Co-Authored-By: AJ Steers --- airbyte/mcp/_config_submit.py | 13 ++++++++-- airbyte/mcp/_tool_utils.py | 9 +++++-- .../interactive/_connector_config_form_ui.py | 18 +++++++++---- tests/unit_tests/test_mcp_config_submit.py | 25 +++++++++++++++++++ .../test_mcp_connector_config_form.py | 10 ++++++-- 5 files changed, 64 insertions(+), 11 deletions(-) diff --git a/airbyte/mcp/_config_submit.py b/airbyte/mcp/_config_submit.py index b1159071d..aa920a0ad 100644 --- a/airbyte/mcp/_config_submit.py +++ b/airbyte/mcp/_config_submit.py @@ -197,15 +197,24 @@ def decrypt_action_token(token: str) -> dict[str, Any]: def _schema_secret_paths(schema: Mapping[str, Any], prefix: str = "") -> set[str]: paths: set[str] = set() + if prefix and any( + ( + schema.get("airbyte_secret") is True, + schema.get("writeOnly") is True, + schema.get("format") == "password", + ) + ): + paths.add(prefix) properties = schema.get("properties", {}) if isinstance(properties, Mapping): for name, child in properties.items(): if not isinstance(name, str) or not isinstance(child, Mapping): continue path = f"{prefix}.{name}" if prefix else name - if child.get("airbyte_secret") is True: - paths.add(path) paths.update(_schema_secret_paths(child, path)) + items = schema.get("items") + if isinstance(items, Mapping): + paths.update(_schema_secret_paths(items, prefix)) for branch_key in ("oneOf", "anyOf", "allOf"): branches = schema.get(branch_key, []) if not isinstance(branches, list): diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index 7c472539e..074066ac7 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -13,7 +13,7 @@ import os from collections.abc import Callable, Mapping from pathlib import Path -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar, cast from fastmcp.server.dependencies import get_access_token, get_http_headers from fastmcp_extensions import ( @@ -62,6 +62,7 @@ if TYPE_CHECKING: from fastmcp import FastMCP + from fastmcp.apps import AppConfig from mcp.types import Tool _MCP_TOOL_FUNC = TypeVar("_MCP_TOOL_FUNC", bound=Callable[..., object]) @@ -388,6 +389,10 @@ def register_mcp_tools( excluded = [name for name in exclude_args if name in params] tool_exclude_args = excluded or None + tool_app = tool_annotations.get(_TOOL_APP_KEY) + if callable(tool_app): + tool_app = tool_app() + tool_app = cast("AppConfig | bool | dict[str, Any] | None", tool_app) app.tool( func, annotations={ @@ -397,7 +402,7 @@ def register_mcp_tools( }, exclude_args=tool_exclude_args, meta=tool_annotations.get(_TOOL_META_KEY), - app=tool_annotations.get(_TOOL_APP_KEY), + app=tool_app, ) matching_providers = [ diff --git a/airbyte/mcp/interactive/_connector_config_form_ui.py b/airbyte/mcp/interactive/_connector_config_form_ui.py index dd8730ef5..48c781389 100644 --- a/airbyte/mcp/interactive/_connector_config_form_ui.py +++ b/airbyte/mcp/interactive/_connector_config_form_ui.py @@ -67,7 +67,11 @@ const keys = path.split("."); if (keys.some((key) => ["__proto__", "constructor", "prototype"].includes(key))) return; let target = object; - keys.slice(0, -1).forEach((key) => target = target[key] ||= {}); + keys.slice(0, -1).forEach((key) => { + if (typeof target[key] !== "object" || target[key] === null || Array.isArray(target[key])) + target[key] = {}; + target = target[key]; + }); target[keys[keys.length - 1]] = value; }; const fields = (schema, prefix = "") => { @@ -298,6 +302,13 @@ def connector_config_form_resource() -> str: return _HTML_RESOURCE +def _connector_form_app_config() -> AppConfig: + return AppConfig( + resource_uri=CONNECTOR_FORM_RESOURCE_URI, + csp=ResourceCSP(connect_domains=[_server_origin()]), + ) + + def register_connector_config_form_resource(app: FastMCP) -> None: """Register the connector form resource with its CSP metadata.""" app.resource( @@ -313,10 +324,7 @@ def register_connector_config_form_resource(app: FastMCP) -> None: idempotent=True, open_world=True, annotations={INTERACTIVE_UI_ANNOTATION: True}, - app=AppConfig( - resource_uri=CONNECTOR_FORM_RESOURCE_URI, - csp=ResourceCSP(connect_domains=[_server_origin()]), - ), + app=_connector_form_app_config, ) def show_connector_config_form( connector_name: Annotated[ diff --git a/tests/unit_tests/test_mcp_config_submit.py b/tests/unit_tests/test_mcp_config_submit.py index 2b607bc88..093b795f5 100644 --- a/tests/unit_tests/test_mcp_config_submit.py +++ b/tests/unit_tests/test_mcp_config_submit.py @@ -103,6 +103,31 @@ def test_schema_secret_paths_include_nested_branches() -> None: } +def test_schema_secret_paths_include_hydration_markers_and_items() -> None: + schema = { + "type": "object", + "properties": { + "write_only": {"type": "string", "writeOnly": True}, + "password": {"type": "string", "format": "password"}, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "token": {"type": "string", "format": "password"}, + }, + }, + }, + }, + } + + assert _schema_secret_paths(schema) == { + "write_only", + "password", + "items.token", + } + + class _SourceStub: def __init__(self) -> None: self.config: dict[str, object] | None = None diff --git a/tests/unit_tests/test_mcp_connector_config_form.py b/tests/unit_tests/test_mcp_connector_config_form.py index 9e7c3ae1b..f86c1a09f 100644 --- a/tests/unit_tests/test_mcp_connector_config_form.py +++ b/tests/unit_tests/test_mcp_connector_config_form.py @@ -175,6 +175,7 @@ def test_form_blocks_prototype_pollution_paths() -> None: html = form.connector_config_form_resource() assert '["__proto__", "constructor", "prototype"]' in html + assert "Array.isArray(target[key])" in html def test_form_handles_one_of_schema_without_plaintext_input( @@ -220,7 +221,7 @@ def test_connector_form_resource_includes_csp_metadata( from airbyte.mcp import interactive - monkeypatch.setenv(form.MCP_SERVER_URL_ENV, "http://localhost:8080") + monkeypatch.setenv(form.MCP_SERVER_URL_ENV, "https://late.example.com/cloud-mcp") app = mcp_server(name="test") interactive.register_interactive_tools(app) @@ -231,4 +232,9 @@ def test_connector_form_resource_includes_csp_metadata( ) assert resource.meta is not None - assert resource.meta["ui"]["csp"]["connectDomains"] == ["http://localhost:8080"] + assert resource.meta["ui"]["csp"]["connectDomains"] == ["https://late.example.com"] + + provider = getattr(app, "_local_provider") + tool = provider._components["tool:show_connector_config_form@"] # noqa: SLF001 + assert tool.meta is not None + assert tool.meta["ui"]["csp"]["connectDomains"] == ["https://late.example.com"] From 964430648265c9fe130672a679ff1f9d78851269 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:08:03 +0000 Subject: [PATCH 17/22] fix(mcp): return safe 422 for Cloud API rejection Co-Authored-By: AJ Steers --- airbyte/mcp/_config_submit.py | 2 ++ tests/unit_tests/test_mcp_config_submit.py | 26 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/airbyte/mcp/_config_submit.py b/airbyte/mcp/_config_submit.py index aa920a0ad..9bb10ef64 100644 --- a/airbyte/mcp/_config_submit.py +++ b/airbyte/mcp/_config_submit.py @@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Any, Literal, cast import jsonschema +from airbyte_api.errors import SDKError from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM from starlette.responses import JSONResponse, Response @@ -304,6 +305,7 @@ async def connector_config_submit_endpoint(request: Request) -> Response: # noq KeyError, PyAirbyteInputError, SafeModeError, + SDKError, TypeError, ValueError, jsonschema.ValidationError, diff --git a/tests/unit_tests/test_mcp_config_submit.py b/tests/unit_tests/test_mcp_config_submit.py index 093b795f5..2fe402d4f 100644 --- a/tests/unit_tests/test_mcp_config_submit.py +++ b/tests/unit_tests/test_mcp_config_submit.py @@ -5,8 +5,10 @@ import base64 import time +from typing import Any, cast import pytest +from airbyte_api.errors import SDKError from starlette.applications import Starlette from starlette.testclient import TestClient @@ -159,6 +161,30 @@ def test_validate_endpoint_executes_full_config_without_echo( assert "secret-value" not in response.text +def test_endpoint_returns_safe_422_for_cloud_sdk_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def raise_sdk_error(*args: Any, **kwargs: Any) -> dict[str, str]: + raise SDKError("Cloud rejected the configuration.", 422, "", cast(Any, None)) + + monkeypatch.setattr(_config_submit, "_execute_action", raise_sdk_error) + app = Starlette(routes=connector_config_submit_routes()) + token = mint_action_token("validate", "source-example") + + with TestClient(app) as client: + response = client.post( + "/connector-config-submit", + headers={"Authorization": f"Bearer {token}"}, + json={"config": {"api_key": "secret-value"}}, + ) + + assert response.status_code == 422 + assert response.json() == { + "error": "Connector configuration could not be submitted.", + } + assert "secret-value" not in response.text + + def test_endpoint_rejects_missing_malformed_and_reused_tokens( monkeypatch: pytest.MonkeyPatch, ) -> None: From c99f438f879fc21f23de4dd04fdf5055e46a14d5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:34:15 +0000 Subject: [PATCH 18/22] fix(mcp): keep SDK errors behind internal API wrapper Co-Authored-By: AJ Steers --- airbyte/mcp/_config_submit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airbyte/mcp/_config_submit.py b/airbyte/mcp/_config_submit.py index 9bb10ef64..543ca5bcd 100644 --- a/airbyte/mcp/_config_submit.py +++ b/airbyte/mcp/_config_submit.py @@ -23,13 +23,13 @@ from typing import TYPE_CHECKING, Any, Literal, cast import jsonschema -from airbyte_api.errors import SDKError from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM from starlette.responses import JSONResponse, Response from starlette.routing import Route from airbyte import get_source +from airbyte._util.api_util import SDKError from airbyte.cloud.client import CloudClient from airbyte.exceptions import AirbyteError, PyAirbyteInputError from airbyte.mcp._tool_utils import ( From fb02186f328dd907336994a5da70b8de5751912c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:51:02 +0000 Subject: [PATCH 19/22] fix(mcp): request iframe resize for connector form Co-Authored-By: AJ Steers --- airbyte/mcp/interactive/_connector_config_form_ui.py | 12 ++++++++++++ tests/unit_tests/test_mcp_connector_config_form.py | 2 ++ 2 files changed, 14 insertions(+) diff --git a/airbyte/mcp/interactive/_connector_config_form_ui.py b/airbyte/mcp/interactive/_connector_config_form_ui.py index 48c781389..88b4d4ca8 100644 --- a/airbyte/mcp/interactive/_connector_config_form_ui.py +++ b/airbyte/mcp/interactive/_connector_config_form_ui.py @@ -61,6 +61,14 @@ const status = document.getElementById("status"); const title = document.getElementById("title"); const post = (message) => window.parent.postMessage(message, "*"); + const requestResize = () => { + const contentHeight = Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight + ); + post({jsonrpc: "2.0", method: "ui/notifications/size-changed", + params: {height: Math.min(contentHeight + 20, 600)}}); + }; const pathValue = (object, path) => path.split(".").reduce((value, key) => value && typeof value === "object" ? value[key] : undefined, object); const setPath = (object, path, value) => { @@ -114,9 +122,11 @@ }); const button = document.createElement("button"); button.type = "submit"; button.textContent = "Save configuration"; form.appendChild(button); + requestResize(); }; form.addEventListener("submit", async (event) => { event.preventDefault(); status.className = ""; status.textContent = "Saving…"; + requestResize(); const config = JSON.parse(JSON.stringify(state.result.non_secret_defaults || {})); const visible = JSON.parse(JSON.stringify(state.result.non_secret_defaults || {})); form.querySelectorAll("input").forEach((input) => { @@ -137,8 +147,10 @@ if (typeof body.connector_url === "string") payload.connector_url = body.connector_url; post({jsonrpc: "2.0", method: "ui/updateModelContext", params: {content: payload}}); status.className = "success"; status.textContent = "Configuration submitted."; + requestResize(); } catch (error) { status.className = "error"; status.textContent = "The configuration could not be submitted."; + requestResize(); } }); window.addEventListener("message", (event) => { diff --git a/tests/unit_tests/test_mcp_connector_config_form.py b/tests/unit_tests/test_mcp_connector_config_form.py index f86c1a09f..6d523b1ed 100644 --- a/tests/unit_tests/test_mcp_connector_config_form.py +++ b/tests/unit_tests/test_mcp_connector_config_form.py @@ -212,6 +212,8 @@ def test_form_handles_one_of_schema_without_plaintext_input( assert 'appInfo: {name: "airbyte-form", version: "0.1"}' in html assert "message.id === 1 && message.result" in html assert 'method: "ui/notifications/initialized"' in html + assert 'method: "ui/notifications/size-changed"' in html + assert "Math.min(contentHeight + 20, 600)" in html def test_connector_form_resource_includes_csp_metadata( From 7550f57dd668e5e782f731d39dade91dfa420576 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:58:56 +0000 Subject: [PATCH 20/22] feat(mcp): add standalone connector form delivery Co-Authored-By: AJ Steers --- airbyte/mcp/_config_submit.py | 24 +- .../interactive/_connector_config_form_ui.py | 211 ++++++++++++++---- airbyte/mcp/server.py | 9 + .../test_mcp_connector_config_form.py | 111 +++++++++ 4 files changed, 305 insertions(+), 50 deletions(-) diff --git a/airbyte/mcp/_config_submit.py b/airbyte/mcp/_config_submit.py index 543ca5bcd..90e63b1d8 100644 --- a/airbyte/mcp/_config_submit.py +++ b/airbyte/mcp/_config_submit.py @@ -83,6 +83,7 @@ def mint_action_token( # noqa: PLR0913 action: Literal["create", "update", "validate"], connector_name: str, *, + non_secret_defaults: Mapping[str, Any] | None = None, workspace_id: str | None = None, source_id: str | None = None, source_name: str | None = None, @@ -112,6 +113,9 @@ def mint_action_token( # noqa: PLR0913 "jti": secrets.token_urlsafe(18), } optional_claims = { + "non_secret_defaults": dict(non_secret_defaults) + if non_secret_defaults is not None + else None, "workspace_id": workspace_id, "source_id": source_id, "source_name": source_name, @@ -146,6 +150,7 @@ def _validate_claims(payload: object) -> dict[str, Any]: if not valid_types: raise ConfigSubmitError("Invalid configuration submit token.") for key in ( + "non_secret_defaults", "workspace_id", "source_id", "source_name", @@ -155,6 +160,10 @@ def _validate_claims(payload: object) -> dict[str, Any]: "api_url", "config_api_url", ): + if key == "non_secret_defaults": + if key in payload and not isinstance(payload[key], dict): + raise ConfigSubmitError("Invalid configuration submit token.") + continue if key in payload and not isinstance(payload[key], str): raise ConfigSubmitError("Invalid configuration submit token.") if ("client_id" in payload) != ("client_secret" in payload): @@ -168,8 +177,8 @@ def _prune_seen_jtis_locked(now: int) -> None: del _SEEN_JTIS[jti] -def decrypt_action_token(token: str) -> dict[str, Any]: - """Decrypt, validate, and consume a one-shot action capability.""" +def decrypt_action_token(token: str, *, consume: bool = True) -> dict[str, Any]: + """Decrypt and validate a one-shot action capability, optionally consuming it.""" try: encoded = _decode(token) if len(encoded) <= _AES_GCM_NONCE_SIZE: @@ -188,11 +197,12 @@ def decrypt_action_token(token: str) -> dict[str, Any]: now = int(time.time()) if claims["exp"] <= now: raise ConfigSubmitError("Configuration submit token expired.") - with _SEEN_JTIS_LOCK: - _prune_seen_jtis_locked(now) - if claims["jti"] in _SEEN_JTIS: - raise ConfigSubmitError("Configuration submit token already used.") - _SEEN_JTIS[claims["jti"]] = claims["exp"] + if consume: + with _SEEN_JTIS_LOCK: + _prune_seen_jtis_locked(now) + if claims["jti"] in _SEEN_JTIS: + raise ConfigSubmitError("Configuration submit token already used.") + _SEEN_JTIS[claims["jti"]] = claims["exp"] return claims diff --git a/airbyte/mcp/interactive/_connector_config_form_ui.py b/airbyte/mcp/interactive/_connector_config_form_ui.py index 88b4d4ca8..29d6c5478 100644 --- a/airbyte/mcp/interactive/_connector_config_form_ui.py +++ b/airbyte/mcp/interactive/_connector_config_form_ui.py @@ -7,13 +7,14 @@ import os from collections.abc import Mapping from typing import TYPE_CHECKING, Annotated, Any, Literal -from urllib.parse import urlparse +from urllib.parse import urlencode, urlparse from fastmcp import Context # noqa: TC002 from fastmcp.apps import UI_MIME_TYPE, AppConfig, ResourceCSP from fastmcp.tools.base import ToolResult from fastmcp_extensions import get_mcp_config from pydantic import Field +from starlette.responses import HTMLResponse from airbyte import exceptions as exc from airbyte._util.registry_spec import get_connector_spec_from_registry @@ -25,7 +26,12 @@ CLOUD_CONFIG_API_ROOT_ENV_VAR, CLOUD_WORKSPACE_ID_ENV_VAR, ) -from airbyte.mcp._config_submit import _schema_secret_paths, mint_action_token +from airbyte.mcp._config_submit import ( + ConfigSubmitError, + _schema_secret_paths, + decrypt_action_token, + mint_action_token, +) from airbyte.mcp._tool_utils import ( INTERACTIVE_UI_ANNOTATION, _resolve_transport_bearer_token, @@ -35,6 +41,7 @@ if TYPE_CHECKING: from fastmcp import FastMCP + from starlette.requests import Request CONNECTOR_FORM_RESOURCE_URI = "ui://airbyte/connector-config-form" @@ -55,13 +62,35 @@

Connector configuration

""" @@ -202,6 +236,10 @@ def _submit_endpoint() -> str: return f"{_server_url()}/connector-config-submit" +def _standalone_endpoint(token: str) -> str: + return f"{_server_url()}/connector-config-form?{urlencode({'token': token})}" + + def _cloud_config_value(env_name: str) -> str | None: value = os.getenv(env_name, "").strip() return value or None @@ -222,6 +260,7 @@ def _resolved_cloud_config_value( def _mint_form_token( connector_name: str, *, + config_defaults: dict[str, Any], ctx: Context | None, source_id: str | None, workspace_id: str | None, @@ -250,6 +289,7 @@ def _mint_form_token( token = mint_action_token( action, connector_name, + non_secret_defaults=config_defaults, workspace_id=resolved_workspace_id, source_id=source_id, source_name=source_name, @@ -281,6 +321,7 @@ def _agent_content( connector_name: str, config_defaults: dict[str, Any], secret_fields: list[str], + standalone_url: str, ) -> str: """Build bounded text for agents that cannot render the form.""" note = ( @@ -291,6 +332,7 @@ def _agent_content( "connector_name": connector_name, "non_secret_defaults": config_defaults, "secret_fields": secret_fields, + "standalone_url": standalone_url, "note": note, "agent_preview_max_chars": _MAX_AGENT_CONTENT_LENGTH, } @@ -309,6 +351,82 @@ def _agent_content( return json.dumps(payload, separators=(",", ":")) +def _connector_spec_schema(connector_name: str) -> dict[str, Any] | None: + spec_schema = get_connector_spec_from_registry(connector_name, platform="cloud") + if spec_schema is None: + spec_schema = get_connector_spec_from_registry(connector_name, platform="oss") + return spec_schema + + +def _form_payload( + connector_name: str, + spec_schema: dict[str, Any], + config_defaults: dict[str, Any], + secret_fields: list[str], + action: str, + submit_token: str, +) -> dict[str, Any]: + return { + "connector_name": connector_name, + "schema": spec_schema, + "spec_schema": spec_schema, + "non_secret_defaults": config_defaults, + "secret_fields": secret_fields, + "action": action, + "submit_token": submit_token, + "submit_endpoint": _submit_endpoint(), + } + + +def connector_config_form_page(payload: Mapping[str, Any]) -> str: + """Render the connector configuration form as a standalone browser page.""" + encoded_payload = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).replace( + " +Invalid form link +

This link is invalid or has expired.

""" + + +async def connector_config_form_endpoint(request: Request) -> HTMLResponse: # noqa: RUF029 + """Render a connector configuration form from a one-shot action capability.""" + token = request.query_params.get("token", "") + if not token: + return HTMLResponse(_INVALID_FORM_PAGE, status_code=403) + try: + claims = decrypt_action_token(token, consume=False) + connector_name = claims["connector_name"] + config_defaults = claims.get("non_secret_defaults", {}) + if not isinstance(config_defaults, dict): + return HTMLResponse(_INVALID_FORM_PAGE, status_code=403) + spec_schema = _connector_spec_schema(connector_name) + if not spec_schema: + return HTMLResponse(_INVALID_FORM_PAGE, status_code=403) + secret_fields = sorted(_schema_secret_paths(spec_schema)) + payload = _form_payload( + connector_name, + spec_schema, + config_defaults, + secret_fields, + claims["action"], + token, + ) + except (ConfigSubmitError, exc.PyAirbyteError, KeyError, TypeError, ValueError): + return HTMLResponse(_INVALID_FORM_PAGE, status_code=403) + try: + return HTMLResponse(connector_config_form_page(payload)) + except (TypeError, ValueError): + return HTMLResponse(_INVALID_FORM_PAGE, status_code=403) + + def connector_config_form_resource() -> str: """Return the raw HTML connector configuration form.""" return _HTML_RESOURCE @@ -384,9 +502,7 @@ def show_connector_config_form( context={"connector_name": connector_name}, ) - spec_schema = get_connector_spec_from_registry(connector_name, platform="cloud") - if spec_schema is None: - spec_schema = get_connector_spec_from_registry(connector_name, platform="oss") + spec_schema = _connector_spec_schema(connector_name) if not spec_schema: raise exc.PyAirbyteInputError( message=f"Could not fetch a configuration schema for '{connector_name}'.", @@ -406,23 +522,32 @@ def show_connector_config_form( action, submit_token = _mint_form_token( connector_name, + config_defaults=config_defaults, ctx=ctx, source_id=source_id, workspace_id=workspace_id, source_name=source_name, ) - content = _agent_content(connector_name, config_defaults, secret_fields) - structured_content = { - "connector_name": connector_name, - "schema": spec_schema, - "spec_schema": spec_schema, - "non_secret_defaults": config_defaults, - "secret_fields": secret_fields, - "action": action, - "submit_token": submit_token, - "submit_endpoint": _submit_endpoint(), - } + standalone_url = _standalone_endpoint(submit_token) + content = _agent_content( + connector_name, + config_defaults, + secret_fields, + standalone_url, + ) + structured_content = _form_payload( + connector_name, + spec_schema, + config_defaults, + secret_fields, + action, + submit_token, + ) + structured_content["standalone_url"] = standalone_url return ToolResult(content=content, structured_content=structured_content) -__all__ = ["show_connector_config_form"] +__all__ = [ + "connector_config_form_endpoint", + "show_connector_config_form", +] diff --git a/airbyte/mcp/server.py b/airbyte/mcp/server.py index fd6ab11ff..5308b9871 100644 --- a/airbyte/mcp/server.py +++ b/airbyte/mcp/server.py @@ -92,6 +92,9 @@ from airbyte.mcp.agents import register_agents_tools from airbyte.mcp.cloud import register_cloud_tools from airbyte.mcp.interactive import register_interactive_tools +from airbyte.mcp.interactive._connector_config_form_ui import ( + connector_config_form_endpoint, +) from airbyte.mcp.local import register_local_tools from airbyte.mcp.prompts import register_prompts from airbyte.mcp.registry import register_registry_tools @@ -390,6 +393,12 @@ async def connector_config_submit(request: Request) -> Response: return await connector_config_submit_endpoint(request) +@app.custom_route("/connector-config-form", methods=["GET"]) +async def connector_config_form(request: Request) -> Response: + """Render the connector configuration form as a standalone browser page.""" + return await connector_config_form_endpoint(request) + + def main() -> None: """@private Main entry point for the MCP server. diff --git a/tests/unit_tests/test_mcp_connector_config_form.py b/tests/unit_tests/test_mcp_connector_config_form.py index 6d523b1ed..f7ed5775a 100644 --- a/tests/unit_tests/test_mcp_connector_config_form.py +++ b/tests/unit_tests/test_mcp_connector_config_form.py @@ -7,8 +7,13 @@ import json import pytest +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.testclient import TestClient from airbyte.exceptions import PyAirbyteInputError +from airbyte.mcp import _config_submit +from airbyte.mcp._config_submit import connector_config_submit_routes, mint_action_token from airbyte.mcp.interactive import _connector_config_form_ui as form @@ -46,6 +51,10 @@ def test_form_result_contains_schema_and_submit_data( assert structured["spec_schema"] == SCHEMA assert structured["submit_endpoint"].endswith("/connector-config-submit") assert structured["submit_token"] + assert structured["standalone_url"].endswith( + f"/connector-config-form?token={structured['submit_token']}" + ) + assert structured["standalone_url"] in content assert "intake_token" not in structured assert "intake_endpoint" not in structured assert "secret-value" not in content @@ -71,6 +80,10 @@ def test_submit_endpoint_preserves_server_path(monkeypatch: pytest.MonkeyPatch) form._submit_endpoint() == "https://example.com/cloud-mcp/connector-config-submit" ) + assert ( + form._standalone_endpoint("token") + == "https://example.com/cloud-mcp/connector-config-form?token=token" + ) assert form._server_origin() == "https://example.com" @@ -213,6 +226,8 @@ def test_form_handles_one_of_schema_without_plaintext_input( assert "message.id === 1 && message.result" in html assert 'method: "ui/notifications/initialized"' in html assert 'method: "ui/notifications/size-changed"' in html + assert 'method: "ui/message"' in html + assert "Notify agent" in html assert "Math.min(contentHeight + 20, 600)" in html @@ -240,3 +255,99 @@ def test_connector_form_resource_includes_csp_metadata( tool = provider._components["tool:show_connector_config_form@"] # noqa: SLF001 assert tool.meta is not None assert tool.meta["ui"]["csp"]["connectDomains"] == ["https://late.example.com"] + + +class _StandaloneSource: + def set_config(self, config: dict[str, object], *, validate: bool = True) -> None: + assert validate + + +def _standalone_test_app() -> Starlette: + return Starlette( + routes=[ + Route( + "/connector-config-form", + form.connector_config_form_endpoint, + methods=["GET"], + ), + *connector_config_submit_routes(), + ] + ) + + +def test_standalone_form_renders_and_get_does_not_consume_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + form, "get_connector_spec_from_registry", lambda *args, **kwargs: SCHEMA + ) + monkeypatch.setattr( + _config_submit, "get_source", lambda *args, **kwargs: _StandaloneSource() + ) + token = mint_action_token( + "validate", + "source-example", + non_secret_defaults={"region": "us-east-1"}, + bearer_token="bearer-secret", + client_id="client-id", + client_secret="client-secret", + ) + + with TestClient(_standalone_test_app()) as client: + response = client.get(f"/connector-config-form?token={token}") + again = client.get(f"/connector-config-form?token={token}") + submitted = client.post( + "/connector-config-submit", + headers={"Authorization": f"Bearer {token}"}, + json={"config": {"region": "us-east-1"}}, + ) + + assert response.status_code == 200 + assert again.status_code == 200 + assert "source-example" in response.text + assert '"region":"us-east-1"' in response.text + assert "bearer-secret" not in response.text + assert "client-secret" not in response.text + assert submitted.status_code == 200 + + +@pytest.mark.parametrize("query", ["", "?token=invalid"]) +def test_standalone_form_rejects_invalid_or_missing_token(query: str) -> None: + with TestClient(_standalone_test_app()) as client: + response = client.get(f"/connector-config-form{query}") + + assert response.status_code == 403 + assert "This link is invalid or has expired." in response.text + + +def test_standalone_form_rejects_expired_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + token = mint_action_token("validate", "source-example", ttl_seconds=1) + monkeypatch.setattr(_config_submit.time, "time", lambda: 2_000_000_000) + + with TestClient(_standalone_test_app()) as client: + response = client.get(f"/connector-config-form?token={token}") + + assert response.status_code == 403 + assert "This link is invalid or has expired." in response.text + + +def test_standalone_form_escapes_script_terminators( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + form, "get_connector_spec_from_registry", lambda *args, **kwargs: SCHEMA + ) + token = mint_action_token( + "validate", + "source-example", + non_secret_defaults={"region": ""}, + ) + + with TestClient(_standalone_test_app()) as client: + response = client.get(f"/connector-config-form?token={token}") + + assert response.status_code == 200 + assert "" not in response.text + assert "<\\/script>" in response.text From 2e71c12804f8be162bf6e07ea8ccb74f59244f4c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:09:59 +0000 Subject: [PATCH 21/22] fix(mcp): detect secrets in array defaults Co-Authored-By: AJ Steers --- .../interactive/_connector_config_form_ui.py | 5 ++++ .../test_mcp_connector_config_form.py | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/airbyte/mcp/interactive/_connector_config_form_ui.py b/airbyte/mcp/interactive/_connector_config_form_ui.py index 29d6c5478..f83502895 100644 --- a/airbyte/mcp/interactive/_connector_config_form_ui.py +++ b/airbyte/mcp/interactive/_connector_config_form_ui.py @@ -305,6 +305,11 @@ def _mint_form_token( def _paths_present(value: object, prefix: str = "") -> set[str]: + if isinstance(value, (list, tuple)): + paths: set[str] = set() + for item in value: + paths.update(_paths_present(item, prefix)) + return paths if not isinstance(value, Mapping): return set() paths: set[str] = set() diff --git a/tests/unit_tests/test_mcp_connector_config_form.py b/tests/unit_tests/test_mcp_connector_config_form.py index f7ed5775a..7a999e660 100644 --- a/tests/unit_tests/test_mcp_connector_config_form.py +++ b/tests/unit_tests/test_mcp_connector_config_form.py @@ -73,6 +73,34 @@ def test_form_rejects_secret_defaults(monkeypatch: pytest.MonkeyPatch) -> None: ) +def test_form_rejects_secrets_in_array_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + schema = { + "type": "object", + "properties": { + "streams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "token": {"type": "string", "airbyte_secret": True}, + }, + }, + }, + }, + } + monkeypatch.setattr( + form, "get_connector_spec_from_registry", lambda *args, **kwargs: schema + ) + + with pytest.raises(PyAirbyteInputError, match="Secret values cannot"): + form.show_connector_config_form( + "source-example", + {"streams": [{"token": "secret-value"}]}, + ) + + def test_submit_endpoint_preserves_server_path(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(form.MCP_SERVER_URL_ENV, "https://example.com/cloud-mcp/") From 3af6fad9d6f865c23b3c93dc3577136c7f967884 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:29:30 +0000 Subject: [PATCH 22/22] fix: read raw Config API catalog in set_stream_sync_mode Co-Authored-By: AJ Steers --- airbyte/cloud/connections.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airbyte/cloud/connections.py b/airbyte/cloud/connections.py index bad5dfd93..2909edae5 100644 --- a/airbyte/cloud/connections.py +++ b/airbyte/cloud/connections.py @@ -880,7 +880,7 @@ def set_stream_sync_mode( is ambiguous (multiple matches without a namespace), or incremental mode is requested without a usable cursor field. """ - catalog = self.dump_raw_catalog() + catalog = self.dump_raw_catalog(normalize=False) if not catalog or "streams" not in catalog: raise PyAirbyteInputError( message="Connection catalog is empty or missing.",