From 8dea792b81cffa8987e7965ef2637cbe3d0d64a9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:27:23 +0000 Subject: [PATCH 01/47] feat(cloud): add Connector Builder project drafting, testing values, test reads, and resolve tools Co-Authored-By: AJ Steers --- airbyte/_util/api_util.py | 160 ++++++ airbyte/cloud/__init__.py | 2 + airbyte/cloud/connector_builder.py | 516 ++++++++++++++++++ airbyte/cloud/connectors.py | 32 +- airbyte/cloud/workspaces.py | 133 ++++- airbyte/mcp/cloud.py | 411 +++++++++++++- tests/unit_tests/fixtures/arg_trace_plan.json | 40 ++ .../test_cloud_connector_builder.py | 411 ++++++++++++++ tests/unit_tests/test_mcp_arg_trace_parity.py | 7 +- .../unit_tests/test_mcp_trusted_execution.py | 1 + 10 files changed, 1664 insertions(+), 49 deletions(-) create mode 100644 airbyte/cloud/connector_builder.py create mode 100644 tests/unit_tests/test_cloud_connector_builder.py diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index bb6ebc4da..d013017fc 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -3175,6 +3175,166 @@ def update_connector_builder_project_testing_values( # noqa: PLR0913 ) +def create_connector_builder_project( + *, + workspace_id: str, + name: str, + draft_manifest: dict[str, Any] | None, + api_root: str, + client_id: SecretString | None, + client_secret: SecretString | None, + bearer_token: SecretString | None, + config_api_root: str | None = None, +) -> str: + """Create an unpublished connector builder project and return its ID. + + Calls `POST /v1/connector_builder_projects/create`. + """ + builder_project: dict[str, Any] = {"name": name} + if draft_manifest is not None: + builder_project["draftManifest"] = draft_manifest + + response = _make_config_api_request( + path="/connector_builder_projects/create", + json={"workspaceId": workspace_id, "builderProject": builder_project}, + api_root=api_root, + config_api_root=config_api_root, + client_id=client_id, + client_secret=client_secret, + bearer_token=bearer_token, + ) + builder_project_id = response.get("builderProjectId") + if not isinstance(builder_project_id, str): + raise AirbyteCloudError( + message="Connector builder project API returned an unexpected response.", + context={"workspace_id": workspace_id}, + ) + return builder_project_id + + +def read_connector_builder_project_stream( # noqa: PLR0913 + *, + workspace_id: str, + builder_project_id: str, + manifest: dict[str, Any], + stream_name: str, + record_limit: int, + page_limit: int, + slice_limit: int, + api_root: str, + client_id: SecretString | None, + client_secret: SecretString | None, + bearer_token: SecretString | None, + config_api_root: str | None = None, +) -> dict[str, Any]: + """Run a test read of one stream on Airbyte Cloud, using the project's testing values. + + Calls `POST /v1/connector_builder_projects/read_stream`. + """ + return _make_config_api_request( + path="/connector_builder_projects/read_stream", + json={ + "workspaceId": workspace_id, + "builderProjectId": builder_project_id, + "manifest": manifest, + "streamName": stream_name, + "recordLimit": record_limit, + "pageLimit": page_limit, + "sliceLimit": slice_limit, + }, + api_root=api_root, + config_api_root=config_api_root, + client_id=client_id, + client_secret=client_secret, + bearer_token=bearer_token, + ) + + +def full_resolve_connector_builder_project_manifest( # noqa: PLR0913 + *, + workspace_id: str, + builder_project_id: str, + manifest: dict[str, Any], + stream_limit: int, + api_root: str, + client_id: SecretString | None, + client_secret: SecretString | None, + bearer_token: SecretString | None, + config_api_root: str | None = None, +) -> dict[str, Any]: + """Resolve a manifest, including dynamic streams, using the project's testing values. + + Calls `POST /v1/connector_builder_projects/full_resolve` and returns the resolved manifest. + """ + response = _make_config_api_request( + path="/connector_builder_projects/full_resolve", + json={ + "workspaceId": workspace_id, + "builderProjectId": builder_project_id, + "manifest": manifest, + "streamLimit": stream_limit, + }, + api_root=api_root, + config_api_root=config_api_root, + client_id=client_id, + client_secret=client_secret, + bearer_token=bearer_token, + ) + resolved_manifest = response.get("manifest") + if not isinstance(resolved_manifest, dict): + raise AirbyteCloudError( + message="Connector builder resolve API returned an unexpected response.", + context={"workspace_id": workspace_id, "builder_project_id": builder_project_id}, + ) + return resolved_manifest + + +def publish_connector_builder_project( # noqa: PLR0913 + *, + workspace_id: str, + builder_project_id: str, + name: str, + manifest: dict[str, Any], + spec: dict[str, Any], + api_root: str, + client_id: SecretString | None, + client_secret: SecretString | None, + bearer_token: SecretString | None, + config_api_root: str | None = None, +) -> str: + """Publish a connector builder project as version 1 of a new custom source definition. + + Calls `POST /v1/connector_builder_projects/publish`, which also clears the project's + draft, and returns the new source definition ID. + """ + response = _make_config_api_request( + path="/connector_builder_projects/publish", + json={ + "workspaceId": workspace_id, + "builderProjectId": builder_project_id, + "name": name, + "initialDeclarativeManifest": { + "manifest": manifest, + "spec": spec, + "version": 1, + "description": "", + }, + }, + api_root=api_root, + config_api_root=config_api_root, + client_id=client_id, + client_secret=client_secret, + bearer_token=bearer_token, + ) + source_definition_id = response.get("sourceDefinitionId") + if not isinstance(source_definition_id, str): + raise AirbyteCloudError( + message="Connector builder publish API returned an unexpected response.", + context={"workspace_id": workspace_id, "builder_project_id": builder_project_id}, + ) + return source_definition_id + + # Organization and workspace listing diff --git a/airbyte/cloud/__init__.py b/airbyte/cloud/__init__.py index 2a2043f35..9b39ce135 100644 --- a/airbyte/cloud/__init__.py +++ b/airbyte/cloud/__init__.py @@ -112,6 +112,7 @@ client, client_config, connections, + connector_builder, constants, organizations, sync_results, @@ -125,6 +126,7 @@ "client", "organizations", "connections", + "connector_builder", "constants", "client_config", "sync_results", diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py new file mode 100644 index 000000000..e988a939a --- /dev/null +++ b/airbyte/cloud/connector_builder.py @@ -0,0 +1,516 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Connector Builder projects in Airbyte Cloud. + +A Connector Builder project holds an unpublished draft manifest and the testing values +(including secrets) used to test it. Test reads and manifest resolution run on Airbyte Cloud, +so no connector code runs on the caller's machine. +""" + +from __future__ import annotations + +import difflib +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +import yaml +from pydantic import BaseModel, Field + +from airbyte import exceptions as exc +from airbyte._util import api_util +from airbyte._util.api_util import get_web_url_root + + +if TYPE_CHECKING: + from airbyte.cloud.connectors import CustomCloudSourceDefinition + from airbyte.cloud.workspaces import CloudWorkspace + + +_HTTP_ERROR_STATUS_MIN = 400 +_MANIFEST_KEYS = frozenset({"streams", "definitions", "check"}) +_YAML_LINE_WIDTH = 2**31 - 1 + +ManifestTextEditMode = Literal["replace_full", "replace_text", "replace_all"] +"""How `update_draft_manifest_text()` edits the manifest text. + +- `replace_full`: `new_text` is the whole manifest. +- `replace_text`: replace the single exact match of `old_text`; fails on zero or several matches. +- `replace_all`: replace every exact match of `old_text`; fails on zero matches. +""" + + +def _unwrap_builder_manifest(value: object) -> dict[str, Any] | None: + """Return the connector manifest from a builder manifest payload. + + Some builder projects store their draft as a `DeclarativeSourceManifest` + envelope (`{"manifest": {...}, "spec": ..., "version": ...}`) rather than + the bare manifest; unwrap that envelope so the result matches the shape of + `manifest`. + """ + if not isinstance(value, dict): + return None + nested = value.get("manifest") + if isinstance(nested, dict) and not any(key in value for key in _MANIFEST_KEYS): + return nested + return value + + +def render_manifest_text(manifest: dict[str, Any]) -> str: + """Render a manifest as deterministic YAML text, without line wrapping. + + Airbyte Cloud stores the parsed manifest, not its original text, so comments and + formatting are not preserved. Text edits match against this rendering. + """ + return yaml.safe_dump( + manifest, + sort_keys=False, + allow_unicode=True, + width=_YAML_LINE_WIDTH, + ) + + +def apply_manifest_text_edit( + current_text: str, + *, + edit_mode: ManifestTextEditMode, + new_text: str, + old_text: str | None = None, +) -> str: + """Return `current_text` after applying one text edit. + + Args: + current_text: The text to edit. + edit_mode: See `ManifestTextEditMode`. + new_text: The whole new text for `replace_full`, else the replacement for `old_text`. + old_text: The exact text to replace. Required unless `edit_mode` is `replace_full`. + """ + if edit_mode == "replace_full": + if old_text is not None: + raise exc.AirbyteLibInputError( + message="`old_text` is not allowed when `edit_mode` is `replace_full`.", + ) + return new_text + + if not old_text: + raise exc.AirbyteLibInputError( + message=f"`old_text` is required when `edit_mode` is `{edit_mode}`.", + ) + match_count = current_text.count(old_text) + if match_count == 0: + raise exc.AirbyteLibInputError( + message="`old_text` was not found in the draft manifest text.", + guidance="Copy `old_text` verbatim from the latest draft manifest text.", + ) + if edit_mode == "replace_text" and match_count > 1: + raise exc.AirbyteLibInputError( + message=f"`old_text` matches {match_count} places in the draft manifest text.", + guidance=( + "Include more surrounding text in `old_text` so it matches once, " + "or use `replace_all` to replace every match." + ), + context={"match_count": match_count}, + ) + return current_text.replace(old_text, new_text) + + +def parse_manifest_yaml(manifest_yaml: dict[str, Any] | Path | str) -> dict[str, Any]: + """Return a manifest dict from a dict, a YAML file path, or YAML text.""" + if isinstance(manifest_yaml, Path): + manifest_yaml = manifest_yaml.read_text() + if isinstance(manifest_yaml, str): + manifest_yaml = yaml.safe_load(manifest_yaml) + if not isinstance(manifest_yaml, dict): + raise exc.AirbyteLibInputError(message="The manifest must be a YAML mapping.") + return manifest_yaml + + +def _builder_testing_values_spec(manifest: dict[str, Any]) -> dict[str, Any]: + """Build the `spec` object that the testing-values API expects, as the Builder UI does.""" + manifest_spec = manifest.get("spec") or {} + return { + "documentationUrl": manifest_spec.get("documentation_url"), + "connectionSpecification": manifest_spec.get("connection_specification") or {}, + "advancedAuth": manifest_spec.get("advanced_auth"), + } + + +def _publish_spec(manifest: dict[str, Any]) -> dict[str, Any]: + """Build the connector spec for publishing, with camelCase `connectionSpecification`.""" + spec = dict(manifest.get("spec") or {}) + spec["connectionSpecification"] = spec.get("connection_specification") or {} + return spec + + +class ConnectorBuilderPageSummary(BaseModel): + """One HTTP page fetched during a Connector Builder test read.""" + + url: str | None = None + http_method: str | None = None + status: int | None = None + record_count: int = 0 + response_body: str | None = Field( + default=None, + description="The response body, included only for HTTP error responses.", + ) + + +class ConnectorBuilderStreamReadResult(BaseModel): + """Result of a Connector Builder test read of one stream.""" + + stream_name: str + record_count: int + slice_count: int + page_count: int + test_read_limit_reached: bool + records: list[dict[str, Any]] + pages: list[ConnectorBuilderPageSummary] + logs: list[dict[str, Any]] + inferred_schema: dict[str, Any] | None = None + + @classmethod + def from_api_response( + cls, + stream_name: str, + response: dict[str, Any], + ) -> ConnectorBuilderStreamReadResult: + """Build a result from a `read_stream` API response.""" + records: list[dict[str, Any]] = [] + pages: list[ConnectorBuilderPageSummary] = [] + slices = response.get("slices") or [] + for slice_ in slices: + for page in slice_.get("pages") or []: + page_records = page.get("records") or [] + records.extend(page_records) + request = page.get("request") or {} + http_response = page.get("response") or {} + status = http_response.get("status") + pages.append( + ConnectorBuilderPageSummary( + url=request.get("url"), + http_method=request.get("http_method"), + status=status, + record_count=len(page_records), + response_body=( + http_response.get("body") + if isinstance(status, int) and status >= _HTTP_ERROR_STATUS_MIN + else None + ), + ) + ) + + return cls( + stream_name=stream_name, + record_count=len(records), + slice_count=len(slices), + page_count=len(pages), + test_read_limit_reached=bool(response.get("test_read_limit_reached")), + records=records, + pages=pages, + logs=[ + {"level": log.get("level"), "message": log.get("message")} + for log in response.get("logs") or [] + ], + inferred_schema=response.get("inferred_schema"), + ) + + +class ConnectorBuilderResolvedManifest(BaseModel): + """A manifest resolved by Airbyte Cloud, with dynamic streams expanded.""" + + manifest: dict[str, Any] + stream_names: list[str] + + @classmethod + def from_manifest(cls, manifest: dict[str, Any]) -> ConnectorBuilderResolvedManifest: + """Build a result from a resolved manifest.""" + return cls( + manifest=manifest, + stream_names=[ + stream["name"] + for stream in manifest.get("streams") or [] + if isinstance(stream, dict) and isinstance(stream.get("name"), str) + ], + ) + + +class CloudConnectorBuilderProject: + """A Connector Builder project in an Airbyte Cloud workspace.""" + + def __init__( + self, + workspace: CloudWorkspace, + builder_project_id: str, + ) -> None: + """Initialize a Connector Builder project object. Does not call the API.""" + self.workspace = workspace + self.builder_project_id = builder_project_id + + @property + def url(self) -> str: + """The project's Connector Builder URL, where users enter testing values and secrets.""" + return ( + f"{get_web_url_root(self.workspace.api_root)}/workspaces/" + f"{self.workspace.workspace_id}/connector-builder/edit/{self.builder_project_id}" + ) + + def get_project_data(self) -> dict[str, Any]: + """Fetch the project metadata and draft manifest from the API.""" + return api_util.get_connector_builder_project( + workspace_id=self.workspace.workspace_id, + builder_project_id=self.builder_project_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, + config_api_root=self.workspace.config_api_root, + ) + + @property + def name(self) -> str: + """The project name.""" + return self.get_project_data()["builderProject"]["name"] + + @property + def source_definition_id(self) -> str | None: + """The ID of the custom source definition, or `None` if never published.""" + return self.get_project_data()["builderProject"].get("sourceDefinitionId") + + @property + def published_manifest(self) -> dict[str, Any] | None: + """The active manifest of the published definition, or `None` if never published.""" + source_definition_id = self.source_definition_id + if source_definition_id is None: + return None + return self.workspace.get_custom_source_definition( + source_definition_id, + definition_type="yaml", + ).manifest + + @property + def draft_manifest(self) -> dict[str, Any] | None: + """The unpublished draft manifest, or `None` if the project has no draft.""" + project_data = self.get_project_data() + if not project_data["builderProject"].get("hasDraft"): + return None + declarative_manifest = project_data.get("declarativeManifest") or {} + return _unwrap_builder_manifest(declarative_manifest.get("manifest")) + + def _require_draft_manifest(self) -> dict[str, Any]: + manifest = self.draft_manifest + if manifest is None: + raise exc.AirbyteLibInputError( + message="This Connector Builder project has no draft manifest.", + guidance="Save a draft manifest to the project first.", + context={"builder_project_id": self.builder_project_id}, + ) + return manifest + + def set_draft_manifest( + self, + manifest_yaml: dict[str, Any] | Path | str, + *, + pre_validate: bool = True, + ) -> None: + """Replace the project's draft manifest. Does not publish anything. + + Args: + manifest_yaml: The manifest, as a dict, a YAML file path, or YAML text. + pre_validate: Whether to validate the manifest client-side before saving. + """ + manifest = parse_manifest_yaml(manifest_yaml) + if pre_validate: + api_util.validate_yaml_manifest(manifest, raise_on_error=True) + + api_util.update_connector_builder_project( + workspace_id=self.workspace.workspace_id, + builder_project_id=self.builder_project_id, + name=self.name, + draft_manifest=manifest, + components_file_content=None, + api_root=self.workspace.api_root, + client_id=self.workspace.client_id, + client_secret=self.workspace.client_secret, + bearer_token=self.workspace.bearer_token, + config_api_root=self.workspace.config_api_root, + ) + + @property + def draft_manifest_text(self) -> str | None: + """The draft manifest rendered by `render_manifest_text()`, or `None` if no draft.""" + manifest = self.draft_manifest + return render_manifest_text(manifest) if manifest is not None else None + + def update_draft_manifest_text( + self, + *, + edit_mode: ManifestTextEditMode, + new_text: str, + old_text: str | None = None, + pre_validate: bool = True, + ) -> str: + """Edit the draft manifest as text and save it. Does not publish anything. + + Edits match against `draft_manifest_text`. Returns a unified diff from the previous + rendering to the rendering of the saved draft. + + Args: + edit_mode: See `ManifestTextEditMode`. + new_text: The whole new manifest for `replace_full`, else the replacement text. + old_text: The exact text to replace. Required unless `edit_mode` is `replace_full`. + pre_validate: Whether to validate the edited manifest client-side before saving. + """ + current_text = self.draft_manifest_text + if current_text is None and edit_mode != "replace_full": + raise exc.AirbyteLibInputError( + message="This Connector Builder project has no draft manifest to edit.", + guidance="Use `replace_full` to save a whole manifest first.", + context={"builder_project_id": self.builder_project_id}, + ) + edited_text = apply_manifest_text_edit( + current_text or "", + edit_mode=edit_mode, + new_text=new_text, + old_text=old_text, + ) + self.set_draft_manifest(edited_text, pre_validate=pre_validate) + + saved_text = self.draft_manifest_text or "" + return "".join( + difflib.unified_diff( + (current_text or "").splitlines(keepends=True), + saved_text.splitlines(keepends=True), + fromfile="previous", + tofile="saved", + ) + ) + + def set_testing_values( + self, + testing_values: dict[str, Any], + ) -> None: + """Replace the project's testing values, which Cloud stores in its secret storage. + + The spec comes from the draft manifest, so save a draft first. Fields marked + `airbyte_secret` in the spec are stored as secrets. + """ + manifest = self._require_draft_manifest() + api_util.update_connector_builder_project_testing_values( + workspace_id=self.workspace.workspace_id, + builder_project_id=self.builder_project_id, + testing_values=testing_values, + spec=_builder_testing_values_spec(manifest), + api_root=self.workspace.api_root, + client_id=self.workspace.client_id, + client_secret=self.workspace.client_secret, + bearer_token=self.workspace.bearer_token, + config_api_root=self.workspace.config_api_root, + ) + + def test_read_stream( + self, + stream_name: str, + *, + record_limit: int = 100, + page_limit: int = 5, + slice_limit: int = 5, + ) -> ConnectorBuilderStreamReadResult: + """Test-read one stream of the draft manifest on Airbyte Cloud. + + Uses the project's saved testing values as the connector config. + + Args: + stream_name: The stream to read. + record_limit: Maximum records to return (1 to 10,000). + page_limit: Maximum pages to fetch per slice (1 to 20). + slice_limit: Maximum slices to read (1 to 20). + """ + response = api_util.read_connector_builder_project_stream( + workspace_id=self.workspace.workspace_id, + builder_project_id=self.builder_project_id, + manifest=self._require_draft_manifest(), + stream_name=stream_name, + record_limit=record_limit, + page_limit=page_limit, + slice_limit=slice_limit, + api_root=self.workspace.api_root, + client_id=self.workspace.client_id, + client_secret=self.workspace.client_secret, + bearer_token=self.workspace.bearer_token, + config_api_root=self.workspace.config_api_root, + ) + return ConnectorBuilderStreamReadResult.from_api_response(stream_name, response) + + def resolve_manifest( + self, + *, + stream_limit: int = 5, + ) -> ConnectorBuilderResolvedManifest: + """Resolve the draft manifest on Airbyte Cloud, including dynamic streams. + + Uses the project's saved testing values to generate dynamic streams. + + Args: + stream_limit: Maximum generated streams to return per dynamic stream (1 to 100). + """ + resolved_manifest = api_util.full_resolve_connector_builder_project_manifest( + workspace_id=self.workspace.workspace_id, + builder_project_id=self.builder_project_id, + manifest=self._require_draft_manifest(), + stream_limit=stream_limit, + api_root=self.workspace.api_root, + client_id=self.workspace.client_id, + client_secret=self.workspace.client_secret, + bearer_token=self.workspace.bearer_token, + config_api_root=self.workspace.config_api_root, + ) + return ConnectorBuilderResolvedManifest.from_manifest(resolved_manifest) + + def publish( + self, + name: str, + ) -> CustomCloudSourceDefinition: + """Publish the draft manifest as a custom source definition. + + The first publish creates the definition with `name`. If the project is already + published, the draft becomes a new active version of the existing definition. + """ + manifest = self._require_draft_manifest() + source_definition_id = self.source_definition_id + if source_definition_id is not None: + definition = self.workspace.get_custom_source_definition( + source_definition_id, + definition_type="yaml", + ) + return definition.update_definition(manifest_yaml=manifest) + + source_definition_id = api_util.publish_connector_builder_project( + workspace_id=self.workspace.workspace_id, + builder_project_id=self.builder_project_id, + name=name, + manifest=manifest, + spec=_publish_spec(manifest), + api_root=self.workspace.api_root, + client_id=self.workspace.client_id, + client_secret=self.workspace.client_secret, + bearer_token=self.workspace.bearer_token, + config_api_root=self.workspace.config_api_root, + ) + return self.workspace.get_custom_source_definition( + source_definition_id, + definition_type="yaml", + ) + + def __repr__(self) -> str: + """Return a string representation of the project.""" + return f"CloudConnectorBuilderProject(builder_project_id={self.builder_project_id!r})" + + +__all__ = [ + "CloudConnectorBuilderProject", + "ConnectorBuilderPageSummary", + "ConnectorBuilderResolvedManifest", + "ConnectorBuilderStreamReadResult", + "ManifestTextEditMode", + "apply_manifest_text_edit", + "parse_manifest_yaml", + "render_manifest_text", +] diff --git a/airbyte/cloud/connectors.py b/airbyte/cloud/connectors.py index c8eeee050..1732c86e8 100644 --- a/airbyte/cloud/connectors.py +++ b/airbyte/cloud/connectors.py @@ -88,6 +88,7 @@ ) from airbyte._util import api_util, text_util from airbyte._util.api_util import get_web_url_root +from airbyte.cloud.connector_builder import CloudConnectorBuilderProject, _unwrap_builder_manifest from airbyte.cloud.models import ( CheckResult, CloudCustomSourceDefinitionInfo, @@ -1273,25 +1274,6 @@ def _from_destination_response( return result -_MANIFEST_KEYS = frozenset({"streams", "definitions", "check"}) - - -def _unwrap_builder_manifest(value: object) -> dict[str, Any] | None: - """Return the connector manifest from a builder manifest payload. - - Some builder projects store their draft as a `DeclarativeSourceManifest` - envelope (`{"manifest": {...}, "spec": ..., "version": ...}`) rather than - the bare manifest; unwrap that envelope so the result matches the shape of - `manifest`. - """ - if not isinstance(value, dict): - return None - nested = value.get("manifest") - if isinstance(nested, dict) and not any(key in value for key in _MANIFEST_KEYS): - return nested - return value - - class CustomCloudSourceDefinition: # noqa: PLR0904 """A custom source connector definition in Airbyte Cloud. @@ -1465,6 +1447,18 @@ def connector_builder_project_id(self) -> str | None: return self._connector_builder_project_id + @property + def connector_builder_project(self) -> CloudConnectorBuilderProject | None: + """The Connector Builder project behind this definition. YAML connectors only.""" + if self.definition_type != "yaml": + return None + + project_id = self.connector_builder_project_id + if not project_id: + return None + + return CloudConnectorBuilderProject(self.workspace, project_id) + @property def connector_builder_project_url(self) -> str | None: """Get the connector builder project URL. Only present for YAML connectors.""" diff --git a/airbyte/cloud/workspaces.py b/airbyte/cloud/workspaces.py index 101e7d1eb..26c5bac55 100644 --- a/airbyte/cloud/workspaces.py +++ b/airbyte/cloud/workspaces.py @@ -39,11 +39,8 @@ import uuid from dataclasses import dataclass, field from functools import cached_property -from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, overload -import yaml - from airbyte import exceptions as exc from airbyte._direct_connectors import api_util as agents_api_util from airbyte._direct_connectors import connector_docs @@ -60,6 +57,7 @@ from airbyte.cloud._credentials import _AirbyteCredentials from airbyte.cloud.client_config import CloudClientConfig from airbyte.cloud.connections import CloudConnection +from airbyte.cloud.connector_builder import CloudConnectorBuilderProject, parse_manifest_yaml from airbyte.cloud.models import ( ConnectorFeature, ConnectorType, @@ -75,6 +73,7 @@ if TYPE_CHECKING: from collections.abc import Callable + from pathlib import Path from airbyte.cloud.organizations import CloudOrganization from airbyte.sources.base import Source @@ -1257,11 +1256,14 @@ def publish_custom_source_definition( unique: bool = True, pre_validate: bool = True, testing_values: dict[str, Any] | None = None, + builder_project_id: str | None = None, ) -> cloud_connectors.CustomCloudSourceDefinition: """Publish a custom source connector definition. You must specify EITHER manifest_yaml (for YAML connectors) OR both docker_image - and docker_tag (for Docker connectors), but not both. + and docker_tag (for Docker connectors), but not both. To publish an existing + Connector Builder project, pass `builder_project_id`; `manifest_yaml` is then optional + and replaces the project's draft before publishing. Args: name: Display name for the connector definition @@ -1274,6 +1276,9 @@ def publish_custom_source_definition( Connector Builder UI. If provided, these values are stored as the complete testing values object for the connector builder project (replaces any existing values), allowing immediate test read operations. + builder_project_id: Optional Connector Builder project to publish. If the project + is already published, its draft becomes a new active version of the existing + definition. Returns: CustomCloudSourceDefinition object representing the created definition @@ -1282,6 +1287,20 @@ def publish_custom_source_definition( AirbyteLibInputError: If both or neither of manifest_yaml and docker_image provided AirbyteDuplicateResourcesError: If unique=True and name already exists """ + if builder_project_id is not None: + if docker_image is not None: + raise exc.AirbyteLibInputError( + message="docker_image cannot be combined with builder_project_id.", + ) + return self._publish_connector_builder_project( + builder_project_id, + name=name, + manifest_yaml=manifest_yaml, + unique=unique, + pre_validate=pre_validate, + testing_values=testing_values, + ) + is_yaml = manifest_yaml is not None is_docker = docker_image is not None @@ -1314,18 +1333,12 @@ def publish_custom_source_definition( ) if is_yaml: - manifest_dict: dict[str, Any] - if isinstance(manifest_yaml, Path): - manifest_dict = yaml.safe_load(manifest_yaml.read_text()) - elif isinstance(manifest_yaml, str): - manifest_dict = yaml.safe_load(manifest_yaml) - elif manifest_yaml is not None: - manifest_dict = manifest_yaml - else: + if manifest_yaml is None: raise exc.AirbyteLibInputError( message="manifest_yaml is required for YAML connectors", context={"name": name}, ) + manifest_dict = parse_manifest_yaml(manifest_yaml) if pre_validate: api_util.validate_yaml_manifest(manifest_dict, raise_on_error=True) @@ -1354,6 +1367,102 @@ def publish_custom_source_definition( "Only YAML manifest-based custom sources are currently available." ) + def _publish_connector_builder_project( + self, + builder_project_id: str, + *, + name: str, + manifest_yaml: dict[str, Any] | Path | str | None, + unique: bool, + pre_validate: bool, + testing_values: dict[str, Any] | None, + ) -> cloud_connectors.CustomCloudSourceDefinition: + project = self.get_connector_builder_project(builder_project_id) + if manifest_yaml is not None: + project.set_draft_manifest(manifest_yaml, pre_validate=pre_validate) + + if unique and project.source_definition_id is None: + existing = self.list_custom_source_definitions(definition_type="yaml") + if any(d.name == name for d in existing): + raise exc.AirbyteDuplicateResourcesError( + resource_type="custom_source_definition", + resource_name=name, + ) + + definition = project.publish(name) + if testing_values is not None: + definition.set_testing_values(testing_values) + return definition + + def create_connector_builder_project( + self, + name: str, + *, + manifest_yaml: dict[str, Any] | Path | str | None = None, + pre_validate: bool = True, + ) -> CloudConnectorBuilderProject: + """Create an unpublished Connector Builder project. + + Nothing appears in the workspace's source list until the project is published. + + Args: + name: Display name for the project. + manifest_yaml: Optional initial draft manifest (dict, YAML file path, or YAML text). + pre_validate: Whether to validate the manifest client-side before saving. + """ + manifest_dict: dict[str, Any] | None = None + if manifest_yaml is not None: + manifest_dict = parse_manifest_yaml(manifest_yaml) + if pre_validate: + api_util.validate_yaml_manifest(manifest_dict, raise_on_error=True) + + builder_project_id = api_util.create_connector_builder_project( + workspace_id=self.workspace_id, + name=name, + draft_manifest=manifest_dict, + api_root=self.api_root, + client_id=self.client_id, + client_secret=self.client_secret, + bearer_token=self.bearer_token, + config_api_root=self.config_api_root, + ) + return CloudConnectorBuilderProject(self, builder_project_id) + + def get_connector_builder_project( + self, + builder_project_id: str | None = None, + *, + definition_id: str | None = None, + ) -> CloudConnectorBuilderProject: + """Get a Connector Builder project by its ID or by its published definition's ID. + + Args: + builder_project_id: The Connector Builder project ID. + definition_id: The ID of the custom source definition published from the project. + + Raises: + AirbyteLibInputError: If not exactly one ID is given, or the definition has no + Connector Builder project. + """ + if (builder_project_id is None) == (definition_id is None): + raise exc.AirbyteLibInputError( + message="Provide exactly one of `builder_project_id` or `definition_id`.", + ) + if builder_project_id is not None: + return CloudConnectorBuilderProject(self, builder_project_id) + + assert definition_id is not None + project = self.get_custom_source_definition( + definition_id, + definition_type="yaml", + ).connector_builder_project + if project is None: + raise exc.AirbyteLibInputError( + message="No Connector Builder project was found for this definition.", + context={"definition_id": definition_id}, + ) + return project + def list_custom_source_definitions( self, *, diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 39c1d41b0..c9b77c5f0 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -35,6 +35,10 @@ ) from airbyte._util import api_util from airbyte.cloud.client import _MAX_WORKSPACES_TO_VALIDATE, CloudClient +from airbyte.cloud.connector_builder import ( + CloudConnectorBuilderProject, + ManifestTextEditMode, +) from airbyte.cloud.connectors import ( CheckResult, CloudConnector, @@ -3302,6 +3306,18 @@ def publish_custom_source_definition( # noqa: PLR0913 # Explicit args mirror M ), TraceArg.FINGERPRINT, ], + builder_project_id: Annotated[ + str | None, + Field( + description=( + "Optional. Publish this Connector Builder project's draft instead of creating " + "a new project. The manifest arguments are then optional and replace the draft " + "before publishing. If the project is already published, the draft becomes a " + "new active version of its existing definition." + ), + default=None, + ), + ] = None, ) -> str: """Publish a custom YAML source connector definition to Airbyte Cloud. @@ -3312,7 +3328,7 @@ def publish_custom_source_definition( # noqa: PLR0913 # Explicit args mirror M manifest_yaml_text=manifest_yaml_text, manifest_yaml_file=manifest_yaml_file, ) - if processed_manifest is None: + if processed_manifest is None and builder_project_id is None: error_message = ( "Provide the manifest in `manifest_yaml_text` (full YAML contents) or " "`manifest_yaml_file` (local path)." @@ -3341,6 +3357,7 @@ def publish_custom_source_definition( # noqa: PLR0913 # Explicit args mirror M unique=unique, pre_validate=pre_validate, testing_values=testing_values_dict, + builder_project_id=builder_project_id, ) register_guid_created_in_session(custom_source.definition_id) return ( @@ -3451,6 +3468,25 @@ def get_custom_source_definition( return result +_BUILDER_PROJECT_ID_DESCRIPTION = ( + "The Connector Builder project ID. Provide exactly one of `builder_project_id` or " + "`definition_id`." +) +_BUILDER_DEFINITION_ID_DESCRIPTION = ( + "The custom source definition ID, for a project that has been published. Provide exactly " + "one of `builder_project_id` or `definition_id`." +) +_TEST_READ_RECORDS_PREVIEW_LIMIT = 10 + + +def _builder_project_ids(project: CloudConnectorBuilderProject) -> dict[str, Any]: + return { + "builder_project_id": project.builder_project_id, + "definition_id": project.source_definition_id, + "connector_builder_project_url": project.url, + } + + @mcp_tool( read_only=True, idempotent=True, @@ -3458,8 +3494,56 @@ def get_custom_source_definition( def get_connector_builder_draft_manifest( ctx: Context, definition_id: Annotated[ + str | None, + Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + ] = None, + *, + builder_project_id: Annotated[ + str | None, + Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], +) -> dict[str, Any]: + """Get the Connector Builder draft manifest of a project or custom source definition. + + Returns the working draft manifest that has been saved but not yet published, both as + a dict and as `draft_manifest_text`. Airbyte Cloud stores the parsed manifest, so the text + is a normalized YAML rendering without comments; text edits with + `update_connector_builder_manifest_text` match against it. + + If no draft exists, 'has_draft' will be False and the draft fields will be None. + The published manifest is included for comparison, or None if never published. + """ + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + draft_manifest = project.draft_manifest + return { + **_builder_project_ids(project), + "name": project.name, + "has_draft": draft_manifest is not None, + "draft_manifest": draft_manifest, + "draft_manifest_text": project.draft_manifest_text, + "published_manifest": project.published_manifest, + } + + +@mcp_tool(extra_help_text=_manifest_yaml_source_hint) +def create_connector_builder_project( + ctx: Context, + name: Annotated[ str, - Field(description="The ID of the custom source definition to retrieve the draft for."), + Field(description="The name for the Connector Builder project."), + TraceArg.FINGERPRINT, ], *, workspace_id: Annotated[ @@ -3470,30 +3554,323 @@ def get_connector_builder_draft_manifest( ), TraceArg.OMIT, ], + manifest_yaml_text: Annotated[ + str | None, + Field( + description=( + "Optional initial draft. Full YAML contents of the declarative (low-code) " + "connector manifest: the file's text, not a filename or path." + ), + default=None, + ), + ] = None, + manifest_yaml_file: Annotated[ + str | None, + Field( + description=( + "Optional initial draft. Absolute path to a manifest YAML file on the machine " + "running this MCP server." + ), + default=None, + ), + ] = None, + pre_validate: Annotated[ + bool, + Field( + description="Whether to validate the manifest client-side before saving.", + default=True, + ), + ] = True, ) -> dict[str, Any]: - """Get the Connector Builder draft manifest for a custom source definition. + """Create an unpublished Connector Builder project to develop a custom YAML source. + + The project holds the draft manifest and the testing values used for test reads. + Nothing appears in the workspace's source list until the project is published with + `publish_custom_source_definition`. + """ + processed_manifest = resolve_manifest_yaml( + manifest_yaml_text=manifest_yaml_text, + manifest_yaml_file=manifest_yaml_file, + ) + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.create_connector_builder_project( + name, + manifest_yaml=processed_manifest, + pre_validate=pre_validate, + ) + register_guid_created_in_session(project.builder_project_id) + return { + "builder_project_id": project.builder_project_id, + "definition_id": None, + "connector_builder_project_url": project.url, + "name": name, + } - Returns the working draft manifest that has been saved in the Connector Builder UI - but not yet published. This is useful for inspecting what a user is currently working - on before they publish their changes. - If no draft exists, 'has_draft' will be False and 'draft_manifest' will be None. - The published manifest is always included for comparison. +@mcp_tool(destructive=True) +def update_connector_builder_manifest_text( # Explicit args mirror MCP tool schema. + ctx: Context, + edit_mode: Annotated[ + ManifestTextEditMode, + Field( + description=( + "`replace_full`: `new_text` is the whole manifest. " + "`replace_text`: replace the single exact match of `old_text`; fails if " + "`old_text` matches zero or several places (add surrounding text to make it " + "unique). `replace_all`: replace every exact match of `old_text`." + ), + ), + ], + new_text: Annotated[ + str, + Field( + description=( + "The whole manifest YAML for `replace_full`, else the text that replaces " + "`old_text`." + ), + ), + ], + *, + old_text: Annotated[ + str | None, + Field( + description=( + "The exact text to replace, copied verbatim from `draft_manifest_text`. " + "Required unless `edit_mode` is `replace_full`." + ), + default=None, + ), + ] = None, + builder_project_id: Annotated[ + str | None, + Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + ] = None, + definition_id: Annotated[ + str | None, + Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], + pre_validate: Annotated[ + bool, + Field( + description="Whether to validate the edited manifest client-side before saving.", + default=True, + ), + ] = True, +) -> str: + """Edit and save a Connector Builder project's draft manifest. Does not publish anything. + + Text edits match against `draft_manifest_text` from `get_connector_builder_draft_manifest`. + Returns a unified diff from the previous text to the saved text; base later edits on the + saved text. """ workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) - definition = workspace.get_custom_source_definition( + project = workspace.get_connector_builder_project( + builder_project_id, definition_id=definition_id, - definition_type="yaml", ) + diff = project.update_draft_manifest_text( + edit_mode=edit_mode, + new_text=new_text, + old_text=old_text, + pre_validate=pre_validate, + ) + return ( + f"Saved the draft manifest of Connector Builder project {project.builder_project_id}." + "\n\n" + (diff or "No changes.") + ) + + +@mcp_tool(destructive=True, idempotent=True) +def set_connector_builder_testing_values( # Explicit args mirror MCP tool schema. + ctx: Context, + *, + builder_project_id: Annotated[ + str | None, + Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + ] = None, + definition_id: Annotated[ + str | None, + Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], + testing_values: Annotated[ + dict | str | None, + Field( + description=( + "Configuration values for test reads, as a JSON object or JSON string. " + "Supports inline secret refs via 'secret_reference::ENV_VAR_NAME' syntax. " + "Replaces all existing testing values." + ), + default=None, + ), + ] = None, + testing_values_secret_name: Annotated[ + str | None, + Field( + description=( + "Optional name of a secret containing testing values in JSON or YAML format. " + "The secret is resolved by the MCP server and merged into testing_values, with " + "secret values taking precedence." + ), + default=None, + ), + TraceArg.FINGERPRINT, + ] = None, +) -> str: + """Replace the testing values (connector config) of a Connector Builder project. + + Test reads use these values. Airbyte Cloud stores fields marked `airbyte_secret` in the + draft manifest's spec in its secret storage. Requires a saved draft manifest. + Don't ask users to paste secrets into the chat: send them the returned project URL to + enter secrets in the Connector Builder's testing values form instead. + """ + if testing_values is None and testing_values_secret_name is None: + raise AirbyteLibInputError( + message="Provide `testing_values` or `testing_values_secret_name`.", + ) + testing_values_dict = resolve_connector_config( + config=testing_values, + config_secret_name=testing_values_secret_name, + ) + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + project.set_testing_values(testing_values_dict) + return ( + f"Saved testing values for Connector Builder project {project.builder_project_id}. " + f"Users can review them or enter secrets at: {project.url}" + ) + +@mcp_tool(read_only=True, open_world=True) +def test_connector_builder_stream( # Explicit args mirror MCP tool schema. + ctx: Context, + stream_name: Annotated[ + str, + Field(description="The name of the stream to test-read."), + TraceArg.FINGERPRINT, + ], + *, + builder_project_id: Annotated[ + str | None, + Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + ] = None, + definition_id: Annotated[ + str | None, + Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], + record_limit: Annotated[ + int, + Field(description="Maximum records to read (1 to 10,000).", default=100, ge=1, le=10000), + TraceArg.VALUE, + ] = 100, + page_limit: Annotated[ + int, + Field(description="Maximum pages to fetch per slice (1 to 20).", default=5, ge=1, le=20), + TraceArg.VALUE, + ] = 5, + slice_limit: Annotated[ + int, + Field(description="Maximum slices to read (1 to 20).", default=5, ge=1, le=20), + TraceArg.VALUE, + ] = 5, +) -> dict[str, Any]: + """Test-read one stream of a Connector Builder project's draft manifest on Airbyte Cloud. + + Uses the project's saved testing values as the connector config. Returns counts, a + per-page summary of HTTP requests (with response bodies for HTTP errors), connector logs, + the inferred schema, and a preview of the first records (`records` holds at most + 10 records; `record_count` is the total read). + """ + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + result = project.test_read_stream( + stream_name, + record_limit=record_limit, + page_limit=page_limit, + slice_limit=slice_limit, + ) return { - "definition_id": definition.definition_id, - "name": definition.name, - "connector_builder_project_id": definition.connector_builder_project_id, - "connector_builder_project_url": definition.connector_builder_project_url, - "has_draft": definition.has_draft, - "draft_manifest": definition.draft_manifest, - "published_manifest": definition.manifest, + **result.model_dump(), + "records": result.records[:_TEST_READ_RECORDS_PREVIEW_LIMIT], + "builder_project_id": project.builder_project_id, + } + + +@mcp_tool(read_only=True, idempotent=True, open_world=True) +def resolve_connector_builder_manifest( + ctx: Context, + *, + builder_project_id: Annotated[ + str | None, + Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + ] = None, + definition_id: Annotated[ + str | None, + Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], + stream_limit: Annotated[ + int, + Field( + description="Maximum generated streams per dynamic stream (1 to 100).", + default=5, + ge=1, + le=100, + ), + TraceArg.VALUE, + ] = 5, +) -> dict[str, Any]: + """Resolve a Connector Builder project's draft manifest on Airbyte Cloud. + + Expands `$ref`s, `$parameters`, and dynamic streams (using the project's testing values), + and lists the resulting stream names. + """ + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + resolved = project.resolve_manifest(stream_limit=stream_limit) + return { + "builder_project_id": project.builder_project_id, + "stream_names": resolved.stream_names, + "resolved_manifest": resolved.manifest, } diff --git a/tests/unit_tests/fixtures/arg_trace_plan.json b/tests/unit_tests/fixtures/arg_trace_plan.json index 758a80928..816855f6b 100644 --- a/tests/unit_tests/fixtures/arg_trace_plan.json +++ b/tests/unit_tests/fixtures/arg_trace_plan.json @@ -17,6 +17,13 @@ "table_prefix": "fingerprint", "workspace_id": "omit" }, + "create_connector_builder_project": { + "manifest_yaml_file": "hash", + "manifest_yaml_text": "hash", + "name": "fingerprint", + "pre_validate": "value allowed=[false,true]", + "workspace_id": "omit" + }, "deploy_connector_to_cloud": { "config": "presence", "config_secret_name": "fingerprint", @@ -143,6 +150,7 @@ "workspace_id": "omit" }, "get_connector_builder_draft_manifest": { + "builder_project_id": "hash", "definition_id": "hash", "workspace_id": "omit" }, @@ -252,6 +260,7 @@ "workspace_id": "omit" }, "publish_custom_source_definition": { + "builder_project_id": "hash", "manifest_yaml_file": "hash", "manifest_yaml_text": "hash", "name": "fingerprint", @@ -288,6 +297,12 @@ "streams": "hash list", "workspace_id": "omit" }, + "resolve_connector_builder_manifest": { + "builder_project_id": "hash", + "definition_id": "hash", + "stream_limit": "value int", + "workspace_id": "omit" + }, "run_cloud_sync": { "connection_id": "hash", "wait": "value allowed=[false,true]", @@ -311,6 +326,13 @@ "prefix": "fingerprint", "workspace_id": "omit" }, + "set_connector_builder_testing_values": { + "builder_project_id": "hash", + "definition_id": "hash", + "testing_values": "presence", + "testing_values_secret_name": "fingerprint", + "workspace_id": "omit" + }, "set_default_cloud_workspace": { "user_email": "hash", "workspace_id": "omit" @@ -347,6 +369,15 @@ "source_connector_name": "fingerprint", "streams": "fingerprint list" }, + "test_connector_builder_stream": { + "builder_project_id": "hash", + "definition_id": "hash", + "page_limit": "value int", + "record_limit": "value int", + "slice_limit": "value int", + "stream_name": "fingerprint", + "workspace_id": "omit" + }, "update_cloud_connection": { "connection_id": "hash", "cron_expression": "fingerprint", @@ -362,6 +393,15 @@ "connector_type": "value allowed=[\"destination\",\"source\"]", "workspace_id": "omit" }, + "update_connector_builder_manifest_text": { + "builder_project_id": "hash", + "definition_id": "hash", + "edit_mode": "value allowed=[\"replace_all\",\"replace_full\",\"replace_text\"]", + "new_text": "hash", + "old_text": "hash", + "pre_validate": "value allowed=[false,true]", + "workspace_id": "omit" + }, "update_custom_source_definition": { "definition_id": "hash", "manifest_yaml_file": "hash", diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py new file mode 100644 index 000000000..df115021f --- /dev/null +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -0,0 +1,411 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Unit tests for Connector Builder projects in Airbyte Cloud.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from airbyte._util import api_util +from airbyte.cloud import connector_builder +from airbyte.cloud.connector_builder import ( + CloudConnectorBuilderProject, + ConnectorBuilderResolvedManifest, + ConnectorBuilderStreamReadResult, + apply_manifest_text_edit, + render_manifest_text, +) +from airbyte.cloud.workspaces import CloudWorkspace +from airbyte.exceptions import AirbyteLibInputError +from airbyte.secrets.base import SecretString + +MANIFEST: dict[str, Any] = { + "version": "6.0.0", + "streams": [{"name": "users"}, {"name": "orders"}], + "spec": { + "connection_specification": {"type": "object", "properties": {}}, + "documentation_url": "https://example.com/docs", + }, +} + + +@pytest.mark.parametrize( + ("edit_mode", "new_text", "old_text", "expected"), + [ + pytest.param("replace_full", "b: 2\n", None, "b: 2\n", id="replace-full"), + pytest.param( + "replace_text", "y", "x", "a: y\nb: z\n", id="replace-text-single" + ), + pytest.param("replace_all", "q", "z", "a: x\nb: q\n", id="replace-all-single"), + ], +) +def test_apply_manifest_text_edit( + edit_mode: connector_builder.ManifestTextEditMode, + new_text: str, + old_text: str | None, + expected: str, +) -> None: + result = apply_manifest_text_edit( + "a: x\nb: z\n", + edit_mode=edit_mode, + new_text=new_text, + old_text=old_text, + ) + assert result == expected + + +def test_apply_manifest_text_edit_replace_all_replaces_every_match() -> None: + result = apply_manifest_text_edit( + "a: x\nb: x\n", edit_mode="replace_all", new_text="y", old_text="x" + ) + assert result == "a: y\nb: y\n" + + +@pytest.mark.parametrize( + ("edit_mode", "old_text", "match"), + [ + pytest.param("replace_text", "x", "matches 2 places", id="multiple-matches"), + pytest.param("replace_text", "nope", "was not found", id="no-match"), + pytest.param("replace_all", "nope", "was not found", id="replace-all-no-match"), + pytest.param("replace_text", None, "is required", id="missing-old-text"), + pytest.param("replace_text", "", "is required", id="empty-old-text"), + pytest.param("replace_full", "x", "not allowed", id="old-text-with-full"), + ], +) +def test_apply_manifest_text_edit_errors( + edit_mode: connector_builder.ManifestTextEditMode, + old_text: str | None, + match: str, +) -> None: + with pytest.raises(AirbyteLibInputError, match=match): + apply_manifest_text_edit( + "a: x\nb: x\n", edit_mode=edit_mode, new_text="y", old_text=old_text + ) + + +def test_render_manifest_text_keeps_order_and_does_not_wrap() -> None: + long_value = "word " * 40 + text = render_manifest_text({"z": 1, "a": long_value.strip()}) + assert text.splitlines()[0] == "z: 1" + assert len(text.splitlines()) == 2 + + +def test_stream_read_result_from_api_response() -> None: + response = { + "logs": [{"level": "ERROR", "message": "boom", "stacktrace": "..."}], + "slices": [ + { + "pages": [ + { + "records": [{"id": 1}, {"id": 2}], + "request": {"url": "https://api/x", "http_method": "GET"}, + "response": {"status": 200, "body": "ok"}, + }, + { + "records": [], + "request": { + "url": "https://api/x?page=2", + "http_method": "GET", + }, + "response": {"status": 401, "body": "unauthorized"}, + }, + ] + } + ], + "test_read_limit_reached": True, + "inferred_schema": {"type": "object"}, + } + + result = ConnectorBuilderStreamReadResult.from_api_response("users", response) + + assert result.record_count == 2 + assert result.slice_count == 1 + assert result.page_count == 2 + assert result.test_read_limit_reached is True + assert [page.status for page in result.pages] == [200, 401] + assert result.pages[0].response_body is None + assert result.pages[1].response_body == "unauthorized" + assert result.logs == [{"level": "ERROR", "message": "boom"}] + assert result.inferred_schema == {"type": "object"} + + +def test_resolved_manifest_lists_stream_names() -> None: + resolved = ConnectorBuilderResolvedManifest.from_manifest(MANIFEST) + assert resolved.stream_names == ["users", "orders"] + + +@pytest.mark.parametrize( + ("helper", "kwargs", "expected_path", "expected_json", "response", "expected"), + [ + pytest.param( + api_util.create_connector_builder_project, + {"workspace_id": "ws", "name": "My API", "draft_manifest": MANIFEST}, + "/connector_builder_projects/create", + { + "workspaceId": "ws", + "builderProject": {"name": "My API", "draftManifest": MANIFEST}, + }, + {"builderProjectId": "bp", "workspaceId": "ws"}, + "bp", + id="create", + ), + pytest.param( + api_util.read_connector_builder_project_stream, + { + "workspace_id": "ws", + "builder_project_id": "bp", + "manifest": MANIFEST, + "stream_name": "users", + "record_limit": 10, + "page_limit": 2, + "slice_limit": 3, + }, + "/connector_builder_projects/read_stream", + { + "workspaceId": "ws", + "builderProjectId": "bp", + "manifest": MANIFEST, + "streamName": "users", + "recordLimit": 10, + "pageLimit": 2, + "sliceLimit": 3, + }, + {"logs": [], "slices": []}, + {"logs": [], "slices": []}, + id="read-stream", + ), + pytest.param( + api_util.full_resolve_connector_builder_project_manifest, + { + "workspace_id": "ws", + "builder_project_id": "bp", + "manifest": MANIFEST, + "stream_limit": 7, + }, + "/connector_builder_projects/full_resolve", + { + "workspaceId": "ws", + "builderProjectId": "bp", + "manifest": MANIFEST, + "streamLimit": 7, + }, + {"manifest": {"streams": []}}, + {"streams": []}, + id="full-resolve", + ), + pytest.param( + api_util.publish_connector_builder_project, + { + "workspace_id": "ws", + "builder_project_id": "bp", + "name": "My API", + "manifest": MANIFEST, + "spec": {"connectionSpecification": {}}, + }, + "/connector_builder_projects/publish", + { + "workspaceId": "ws", + "builderProjectId": "bp", + "name": "My API", + "initialDeclarativeManifest": { + "manifest": MANIFEST, + "spec": {"connectionSpecification": {}}, + "version": 1, + "description": "", + }, + }, + {"sourceDefinitionId": "def"}, + "def", + id="publish", + ), + ], +) +def test_connector_builder_api_helpers_forward_requests( + monkeypatch: pytest.MonkeyPatch, + helper: Any, + kwargs: dict[str, Any], + expected_path: str, + expected_json: dict[str, Any], + response: dict[str, Any], + expected: object, +) -> None: + captured: dict[str, Any] = {} + + def fake_config_request(**request_kwargs: Any) -> dict[str, Any]: + captured.update(request_kwargs) + return response + + monkeypatch.setattr(api_util, "_make_config_api_request", fake_config_request) + + result = helper( + **kwargs, + api_root="https://api.example", + config_api_root="https://config.example", + client_id=None, + client_secret=None, + bearer_token=SecretString("token"), + ) + + assert result == expected + assert captured["path"] == expected_path + assert captured["json"] == expected_json + + +def _fake_workspace() -> Any: + return SimpleNamespace( + workspace_id="ws", + api_root="https://api.airbyte.com/v1", + config_api_root=None, + client_id=None, + client_secret=None, + bearer_token=SecretString("token"), + get_custom_source_definition=MagicMock(), + ) + + +def _patch_project_store( + monkeypatch: pytest.MonkeyPatch, + *, + draft: dict[str, Any] | None, + source_definition_id: str | None = None, +) -> dict[str, Any]: + store: dict[str, Any] = {"draft": draft, "saved": []} + + def fake_get(**_: Any) -> dict[str, Any]: + return { + "builderProject": { + "name": "My API", + "builderProjectId": "bp", + "hasDraft": store["draft"] is not None, + "sourceDefinitionId": source_definition_id, + }, + "declarativeManifest": {"manifest": store["draft"], "isDraft": True}, + } + + def fake_update(**kwargs: Any) -> None: + store["draft"] = kwargs["draft_manifest"] + store["saved"].append(kwargs) + + monkeypatch.setattr(api_util, "get_connector_builder_project", fake_get) + monkeypatch.setattr(api_util, "update_connector_builder_project", fake_update) + monkeypatch.setattr(api_util, "validate_yaml_manifest", lambda *_, **__: None) + return store + + +def test_project_url() -> None: + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + assert ( + project.url + == "https://cloud.airbyte.com/workspaces/ws/connector-builder/edit/bp" + ) + + +def test_update_draft_manifest_text_saves_and_returns_diff( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = _patch_project_store(monkeypatch, draft={"version": "6.0.0", "streams": []}) + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + + diff = project.update_draft_manifest_text( + edit_mode="replace_text", + old_text="version: 6.0.0", + new_text="version: 6.1.0", + ) + + assert store["draft"] == {"version": "6.1.0", "streams": []} + assert store["saved"][0]["name"] == "My API" + assert "-version: 6.0.0" in diff + assert "+version: 6.1.0" in diff + + +def test_update_draft_manifest_text_without_draft_requires_replace_full( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = _patch_project_store(monkeypatch, draft=None) + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + + with pytest.raises(AirbyteLibInputError, match="no draft manifest"): + project.update_draft_manifest_text( + edit_mode="replace_all", old_text="a", new_text="b" + ) + + project.update_draft_manifest_text( + edit_mode="replace_full", new_text="version: 6.0.0\n" + ) + assert store["draft"] == {"version": "6.0.0"} + + +def test_set_testing_values_uses_draft_spec(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_project_store(monkeypatch, draft=MANIFEST) + update_testing_values = MagicMock() + monkeypatch.setattr( + api_util, + "update_connector_builder_project_testing_values", + update_testing_values, + ) + + CloudConnectorBuilderProject(_fake_workspace(), "bp").set_testing_values({ + "api_key": "k" + }) + + kwargs = update_testing_values.call_args.kwargs + assert kwargs["testing_values"] == {"api_key": "k"} + assert kwargs["spec"] == { + "documentationUrl": "https://example.com/docs", + "connectionSpecification": {"type": "object", "properties": {}}, + "advancedAuth": None, + } + + +def test_publish_unpublished_project(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_project_store(monkeypatch, draft=MANIFEST) + publish = MagicMock(return_value="def") + monkeypatch.setattr(api_util, "publish_connector_builder_project", publish) + workspace = _fake_workspace() + + CloudConnectorBuilderProject(workspace, "bp").publish("My API") + + spec = publish.call_args.kwargs["spec"] + assert ( + spec["connectionSpecification"] == MANIFEST["spec"]["connection_specification"] + ) + workspace.get_custom_source_definition.assert_called_once_with( + "def", definition_type="yaml" + ) + + +def test_publish_already_published_project_adds_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store(monkeypatch, draft=MANIFEST, source_definition_id="def") + publish = MagicMock() + monkeypatch.setattr(api_util, "publish_connector_builder_project", publish) + workspace = _fake_workspace() + + CloudConnectorBuilderProject(workspace, "bp").publish("My API") + + publish.assert_not_called() + definition = workspace.get_custom_source_definition.return_value + definition.update_definition.assert_called_once_with(manifest_yaml=MANIFEST) + + +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param({}, id="neither"), + pytest.param({"builder_project_id": "bp", "definition_id": "d"}, id="both"), + ], +) +def test_get_connector_builder_project_requires_exactly_one_id( + kwargs: dict[str, str], +) -> None: + workspace = CloudWorkspace( + workspace_id="ws", + client_id=SecretString("id"), + client_secret=SecretString("secret"), + ) + with pytest.raises(AirbyteLibInputError, match="exactly one"): + workspace.get_connector_builder_project(**kwargs) diff --git a/tests/unit_tests/test_mcp_arg_trace_parity.py b/tests/unit_tests/test_mcp_arg_trace_parity.py index a30295b3a..bcc469da4 100644 --- a/tests/unit_tests/test_mcp_arg_trace_parity.py +++ b/tests/unit_tests/test_mcp_arg_trace_parity.py @@ -70,6 +70,7 @@ def test_inventory_matches_fixture() -> None: assert all( "manifest_yaml_file" in plan[name] for name in ( + "create_connector_builder_project", "publish_custom_source_definition", "update_custom_source_definition", ) @@ -77,7 +78,11 @@ def test_inventory_matches_fixture() -> None: FIXTURE.write_text(json.dumps(plan, indent=2, sort_keys=True) + "\n") expected = json.loads(FIXTURE.read_text()) - for name in ("publish_custom_source_definition", "update_custom_source_definition"): + for name in ( + "create_connector_builder_project", + "publish_custom_source_definition", + "update_custom_source_definition", + ): if "manifest_yaml_file" not in plan[name]: expected[name].pop("manifest_yaml_file", None) assert plan == expected diff --git a/tests/unit_tests/test_mcp_trusted_execution.py b/tests/unit_tests/test_mcp_trusted_execution.py index 1dafff09a..9ac046437 100644 --- a/tests/unit_tests/test_mcp_trusted_execution.py +++ b/tests/unit_tests/test_mcp_trusted_execution.py @@ -269,6 +269,7 @@ async def list_tools() -> dict[str, object]: tools = asyncio.run(list_tools()) for tool_name in ( + "create_connector_builder_project", "publish_custom_source_definition", "update_custom_source_definition", ): From d6c5580809fcdce3b9bd614408934c2d802538c7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:27:46 +0000 Subject: [PATCH 02/47] chore: drop stale noqa comment remnants Co-Authored-By: AJ Steers --- airbyte/mcp/cloud.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index c9b77c5f0..97c1e4c12 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -3608,7 +3608,7 @@ def create_connector_builder_project( @mcp_tool(destructive=True) -def update_connector_builder_manifest_text( # Explicit args mirror MCP tool schema. +def update_connector_builder_manifest_text( ctx: Context, edit_mode: Annotated[ ManifestTextEditMode, @@ -3689,7 +3689,7 @@ def update_connector_builder_manifest_text( # Explicit args mirror MCP tool sch @mcp_tool(destructive=True, idempotent=True) -def set_connector_builder_testing_values( # Explicit args mirror MCP tool schema. +def set_connector_builder_testing_values( ctx: Context, *, builder_project_id: Annotated[ @@ -3760,7 +3760,7 @@ def set_connector_builder_testing_values( # Explicit args mirror MCP tool schem @mcp_tool(read_only=True, open_world=True) -def test_connector_builder_stream( # Explicit args mirror MCP tool schema. +def test_connector_builder_stream( ctx: Context, stream_name: Annotated[ str, From e200543a331363da35f8f201db8cd1561e275321 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:31:50 +0000 Subject: [PATCH 03/47] fix(mcp): drop open_world hint from builder test/resolve tools to match repo convention Co-Authored-By: AJ Steers --- airbyte/mcp/cloud.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 97c1e4c12..598666467 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -3759,7 +3759,7 @@ def set_connector_builder_testing_values( ) -@mcp_tool(read_only=True, open_world=True) +@mcp_tool(read_only=True) def test_connector_builder_stream( ctx: Context, stream_name: Annotated[ @@ -3825,7 +3825,7 @@ def test_connector_builder_stream( } -@mcp_tool(read_only=True, idempotent=True, open_world=True) +@mcp_tool(read_only=True, idempotent=True) def resolve_connector_builder_manifest( ctx: Context, *, From 11c25927570e26524f284c6a696536638c49ff38 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:39:22 +0000 Subject: [PATCH 04/47] fix: address review findings (session guards, external access, owner workspace, pre_validate, single-read draft info) Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 88 +++++++++++++++---- airbyte/cloud/connectors.py | 6 +- airbyte/cloud/workspaces.py | 2 +- airbyte/mcp/cloud.py | 34 +++---- .../test_cloud_connector_builder.py | 49 ++++++++++- 5 files changed, 141 insertions(+), 38 deletions(-) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index e988a939a..010fb8ecd 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -232,6 +232,26 @@ def from_manifest(cls, manifest: dict[str, Any]) -> ConnectorBuilderResolvedMani ) +class ConnectorBuilderProjectInfo(BaseModel): + """A snapshot of a Connector Builder project, read with a single project request.""" + + builder_project_id: str + definition_id: str | None + connector_builder_project_url: str + name: str + has_draft: bool + draft_manifest: dict[str, Any] | None + draft_manifest_text: str | None + published_manifest: dict[str, Any] | None + + +def _draft_from_project_data(project_data: dict[str, Any]) -> dict[str, Any] | None: + if not project_data["builderProject"].get("hasDraft"): + return None + declarative_manifest = project_data.get("declarativeManifest") or {} + return _unwrap_builder_manifest(declarative_manifest.get("manifest")) + + class CloudConnectorBuilderProject: """A Connector Builder project in an Airbyte Cloud workspace.""" @@ -239,23 +259,33 @@ def __init__( self, workspace: CloudWorkspace, builder_project_id: str, + *, + owner_workspace_id: str | None = None, ) -> None: - """Initialize a Connector Builder project object. Does not call the API.""" + """Initialize a Connector Builder project object. Does not call the API. + + Args: + workspace: The workspace whose credentials are used. + builder_project_id: The Connector Builder project ID. + owner_workspace_id: The workspace that owns the project, if it differs from + `workspace` (for example, a definition shared from another workspace). + """ self.workspace = workspace self.builder_project_id = builder_project_id + self.owner_workspace_id = owner_workspace_id or workspace.workspace_id @property def url(self) -> str: """The project's Connector Builder URL, where users enter testing values and secrets.""" return ( f"{get_web_url_root(self.workspace.api_root)}/workspaces/" - f"{self.workspace.workspace_id}/connector-builder/edit/{self.builder_project_id}" + f"{self.owner_workspace_id}/connector-builder/edit/{self.builder_project_id}" ) def get_project_data(self) -> dict[str, Any]: """Fetch the project metadata and draft manifest from the API.""" return api_util.get_connector_builder_project( - workspace_id=self.workspace.workspace_id, + workspace_id=self.owner_workspace_id, builder_project_id=self.builder_project_id, api_root=self.workspace.api_root, client_id=self.workspace.client_id, @@ -274,10 +304,7 @@ def source_definition_id(self) -> str | None: """The ID of the custom source definition, or `None` if never published.""" return self.get_project_data()["builderProject"].get("sourceDefinitionId") - @property - def published_manifest(self) -> dict[str, Any] | None: - """The active manifest of the published definition, or `None` if never published.""" - source_definition_id = self.source_definition_id + def _published_manifest(self, source_definition_id: str | None) -> dict[str, Any] | None: if source_definition_id is None: return None return self.workspace.get_custom_source_definition( @@ -285,14 +312,33 @@ def published_manifest(self) -> dict[str, Any] | None: definition_type="yaml", ).manifest + @property + def published_manifest(self) -> dict[str, Any] | None: + """The active manifest of the published definition, or `None` if never published.""" + return self._published_manifest(self.source_definition_id) + + def get_info(self) -> ConnectorBuilderProjectInfo: + """Read the project once and return its IDs, URL, draft, and published manifest.""" + project_data = self.get_project_data() + draft_manifest = _draft_from_project_data(project_data) + source_definition_id = project_data["builderProject"].get("sourceDefinitionId") + return ConnectorBuilderProjectInfo( + builder_project_id=self.builder_project_id, + definition_id=source_definition_id, + connector_builder_project_url=self.url, + name=project_data["builderProject"]["name"], + has_draft=draft_manifest is not None, + draft_manifest=draft_manifest, + draft_manifest_text=( + render_manifest_text(draft_manifest) if draft_manifest is not None else None + ), + published_manifest=self._published_manifest(source_definition_id), + ) + @property def draft_manifest(self) -> dict[str, Any] | None: """The unpublished draft manifest, or `None` if the project has no draft.""" - project_data = self.get_project_data() - if not project_data["builderProject"].get("hasDraft"): - return None - declarative_manifest = project_data.get("declarativeManifest") or {} - return _unwrap_builder_manifest(declarative_manifest.get("manifest")) + return _draft_from_project_data(self.get_project_data()) def _require_draft_manifest(self) -> dict[str, Any]: manifest = self.draft_manifest @@ -321,7 +367,7 @@ def set_draft_manifest( api_util.validate_yaml_manifest(manifest, raise_on_error=True) api_util.update_connector_builder_project( - workspace_id=self.workspace.workspace_id, + workspace_id=self.owner_workspace_id, builder_project_id=self.builder_project_id, name=self.name, draft_manifest=manifest, @@ -394,7 +440,7 @@ def set_testing_values( """ manifest = self._require_draft_manifest() api_util.update_connector_builder_project_testing_values( - workspace_id=self.workspace.workspace_id, + workspace_id=self.owner_workspace_id, builder_project_id=self.builder_project_id, testing_values=testing_values, spec=_builder_testing_values_spec(manifest), @@ -424,7 +470,7 @@ def test_read_stream( slice_limit: Maximum slices to read (1 to 20). """ response = api_util.read_connector_builder_project_stream( - workspace_id=self.workspace.workspace_id, + workspace_id=self.owner_workspace_id, builder_project_id=self.builder_project_id, manifest=self._require_draft_manifest(), stream_name=stream_name, @@ -452,7 +498,7 @@ def resolve_manifest( stream_limit: Maximum generated streams to return per dynamic stream (1 to 100). """ resolved_manifest = api_util.full_resolve_connector_builder_project_manifest( - workspace_id=self.workspace.workspace_id, + workspace_id=self.owner_workspace_id, builder_project_id=self.builder_project_id, manifest=self._require_draft_manifest(), stream_limit=stream_limit, @@ -467,6 +513,8 @@ def resolve_manifest( def publish( self, name: str, + *, + pre_validate: bool = True, ) -> CustomCloudSourceDefinition: """Publish the draft manifest as a custom source definition. @@ -480,10 +528,13 @@ def publish( source_definition_id, definition_type="yaml", ) - return definition.update_definition(manifest_yaml=manifest) + return definition.update_definition(manifest_yaml=manifest, pre_validate=pre_validate) + + if pre_validate: + api_util.validate_yaml_manifest(manifest, raise_on_error=True) source_definition_id = api_util.publish_connector_builder_project( - workspace_id=self.workspace.workspace_id, + workspace_id=self.owner_workspace_id, builder_project_id=self.builder_project_id, name=name, manifest=manifest, @@ -507,6 +558,7 @@ def __repr__(self) -> str: __all__ = [ "CloudConnectorBuilderProject", "ConnectorBuilderPageSummary", + "ConnectorBuilderProjectInfo", "ConnectorBuilderResolvedManifest", "ConnectorBuilderStreamReadResult", "ManifestTextEditMode", diff --git a/airbyte/cloud/connectors.py b/airbyte/cloud/connectors.py index 1732c86e8..2b17af3a1 100644 --- a/airbyte/cloud/connectors.py +++ b/airbyte/cloud/connectors.py @@ -1457,7 +1457,11 @@ def connector_builder_project(self) -> CloudConnectorBuilderProject | None: if not project_id: return None - return CloudConnectorBuilderProject(self.workspace, project_id) + return CloudConnectorBuilderProject( + self.workspace, + project_id, + owner_workspace_id=self._builder_project_workspace_id, + ) @property def connector_builder_project_url(self) -> str | None: diff --git a/airbyte/cloud/workspaces.py b/airbyte/cloud/workspaces.py index 26c5bac55..5ac5d8e36 100644 --- a/airbyte/cloud/workspaces.py +++ b/airbyte/cloud/workspaces.py @@ -1389,7 +1389,7 @@ def _publish_connector_builder_project( resource_name=name, ) - definition = project.publish(name) + definition = project.publish(name, pre_validate=pre_validate) if testing_values is not None: definition.set_testing_values(testing_values) return definition diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 598666467..a853bd164 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -78,6 +78,7 @@ AirbyteExternalAccessNotEnabledError, AirbyteLibError, AirbyteLibInputError, + AirbyteLocalDemoModeError, AirbyteMissingResourceError, AirbyteMissingWorkspaceContextError, ) @@ -3351,6 +3352,10 @@ def publish_custom_source_definition( # noqa: PLR0913 # Explicit args mirror M ) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + if builder_project_id is not None: + _check_builder_project_created_in_session( + workspace.get_connector_builder_project(builder_project_id) + ) custom_source = workspace.publish_custom_source_definition( name=name, manifest_yaml=processed_manifest, @@ -3479,12 +3484,13 @@ def get_custom_source_definition( _TEST_READ_RECORDS_PREVIEW_LIMIT = 10 -def _builder_project_ids(project: CloudConnectorBuilderProject) -> dict[str, Any]: - return { - "builder_project_id": project.builder_project_id, - "definition_id": project.source_definition_id, - "connector_builder_project_url": project.url, - } +def _check_builder_project_created_in_session(project: CloudConnectorBuilderProject) -> None: + try: + check_guid_created_in_session(project.builder_project_id) + except AirbyteLocalDemoModeError: + if project.source_definition_id is None: + raise + check_guid_created_in_session(project.source_definition_id) @mcp_tool( @@ -3526,15 +3532,7 @@ def get_connector_builder_draft_manifest( builder_project_id, definition_id=definition_id, ) - draft_manifest = project.draft_manifest - return { - **_builder_project_ids(project), - "name": project.name, - "has_draft": draft_manifest is not None, - "draft_manifest": draft_manifest, - "draft_manifest_text": project.draft_manifest_text, - "published_manifest": project.published_manifest, - } + return project.get_info().model_dump() @mcp_tool(extra_help_text=_manifest_yaml_source_hint) @@ -3676,6 +3674,7 @@ def update_connector_builder_manifest_text( builder_project_id, definition_id=definition_id, ) + _check_builder_project_created_in_session(project) diff = project.update_draft_manifest_text( edit_mode=edit_mode, new_text=new_text, @@ -3752,6 +3751,7 @@ def set_connector_builder_testing_values( builder_project_id, definition_id=definition_id, ) + _check_builder_project_created_in_session(project) project.set_testing_values(testing_values_dict) return ( f"Saved testing values for Connector Builder project {project.builder_project_id}. " @@ -3759,7 +3759,7 @@ def set_connector_builder_testing_values( ) -@mcp_tool(read_only=True) +@mcp_tool(read_only=True, external_access=True) def test_connector_builder_stream( ctx: Context, stream_name: Annotated[ @@ -3825,7 +3825,7 @@ def test_connector_builder_stream( } -@mcp_tool(read_only=True, idempotent=True) +@mcp_tool(read_only=True, idempotent=True, external_access=True) def resolve_connector_builder_manifest( ctx: Context, *, diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index df115021f..f521f66a5 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -389,7 +389,9 @@ def test_publish_already_published_project_adds_version( publish.assert_not_called() definition = workspace.get_custom_source_definition.return_value - definition.update_definition.assert_called_once_with(manifest_yaml=MANIFEST) + definition.update_definition.assert_called_once_with( + manifest_yaml=MANIFEST, pre_validate=True + ) @pytest.mark.parametrize( @@ -409,3 +411,48 @@ def test_get_connector_builder_project_requires_exactly_one_id( ) with pytest.raises(AirbyteLibInputError, match="exactly one"): workspace.get_connector_builder_project(**kwargs) + + +def test_project_uses_owner_workspace() -> None: + project = CloudConnectorBuilderProject( + _fake_workspace(), "bp", owner_workspace_id="owner" + ) + assert project.url.endswith("/workspaces/owner/connector-builder/edit/bp") + + +def test_get_info_reads_project_once(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_project_store(monkeypatch, draft={"version": "6.0.0"}) + calls: list[dict[str, Any]] = [] + original = api_util.get_connector_builder_project + + def counting_get(**kwargs: Any) -> dict[str, Any]: + calls.append(kwargs) + return original(**kwargs) + + monkeypatch.setattr(api_util, "get_connector_builder_project", counting_get) + + info = CloudConnectorBuilderProject(_fake_workspace(), "bp").get_info() + + assert len(calls) == 1 + assert info.has_draft is True + assert info.draft_manifest_text == "version: 6.0.0\n" + assert info.definition_id is None + assert info.published_manifest is None + + +def test_publish_validates_unpublished_draft_unless_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store(monkeypatch, draft=MANIFEST) + validate = MagicMock() + monkeypatch.setattr(api_util, "validate_yaml_manifest", validate) + monkeypatch.setattr( + api_util, "publish_connector_builder_project", MagicMock(return_value="d") + ) + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + + project.publish("My API", pre_validate=False) + validate.assert_not_called() + + project.publish("My API") + validate.assert_called_once() From 871594a787168f4d066fff8b2c741b4315ee7081 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:51:06 +0000 Subject: [PATCH 05/47] feat(mcp): add Connector Builder readiness report and computed checklist tools Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 165 ++++++++++++++++++ airbyte/mcp/cloud.py | 79 +++++++++ tests/unit_tests/fixtures/arg_trace_plan.json | 10 ++ .../test_cloud_connector_builder.py | 133 ++++++++++++++ 4 files changed, 387 insertions(+) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index 010fb8ecd..0c4beff08 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -26,6 +26,8 @@ _HTTP_ERROR_STATUS_MIN = 400 +_PROBLEM_LOG_LEVELS = frozenset({"WARN", "ERROR", "FATAL"}) +_FAILING_LOG_LEVELS = frozenset({"ERROR", "FATAL"}) _MANIFEST_KEYS = frozenset({"streams", "definitions", "check"}) _YAML_LINE_WIDTH = 2**31 - 1 @@ -252,6 +254,74 @@ def _draft_from_project_data(project_data: dict[str, Any]) -> dict[str, Any] | N return _unwrap_builder_manifest(declarative_manifest.get("manifest")) +class ConnectorBuilderStreamReadiness(BaseModel): + """Readiness of one stream, from a Connector Builder test read.""" + + stream_name: str + passed: bool + record_count: int = 0 + page_count: int = 0 + test_read_limit_reached: bool = False + http_errors: list[ConnectorBuilderPageSummary] = Field(default_factory=list) + problem_logs: list[dict[str, Any]] = Field(default_factory=list) + inferred_schema: dict[str, Any] | None = None + error: str | None = Field( + default=None, + description="The error raised by the test read itself, if it failed to run.", + ) + + @classmethod + def from_stream_read( + cls, result: ConnectorBuilderStreamReadResult + ) -> ConnectorBuilderStreamReadiness: + """Judge a stream from its test read: records returned, no HTTP or error logs.""" + http_errors = [ + page + for page in result.pages + if page.status is not None and page.status >= _HTTP_ERROR_STATUS_MIN + ] + problem_logs = [log for log in result.logs if log.get("level") in _PROBLEM_LOG_LEVELS] + has_errors = bool(http_errors) or any( + log.get("level") in _FAILING_LOG_LEVELS for log in problem_logs + ) + return cls( + stream_name=result.stream_name, + passed=result.record_count > 0 and not has_errors, + record_count=result.record_count, + page_count=result.page_count, + test_read_limit_reached=result.test_read_limit_reached, + http_errors=http_errors, + problem_logs=problem_logs, + inferred_schema=result.inferred_schema, + ) + + +class ConnectorBuilderReadinessReport(BaseModel): + """Result of test-reading every stream of a Connector Builder draft.""" + + passed: bool + stream_count: int + passed_stream_count: int + streams: list[ConnectorBuilderStreamReadiness] + + +class ConnectorBuilderChecklistItem(BaseModel): + """One step toward a finished custom source, with status derived from the project.""" + + step: str + done: bool | None = Field( + description="True or False when known; None when it needs a test read to decide.", + ) + detail: str | None = None + + +class ConnectorBuilderChecklist(BaseModel): + """Progress toward a finished custom source, derived from the project's current state.""" + + items: list[ConnectorBuilderChecklistItem] + next_steps: list[str] + + class CloudConnectorBuilderProject: """A Connector Builder project in an Airbyte Cloud workspace.""" @@ -510,6 +580,97 @@ def resolve_manifest( ) return ConnectorBuilderResolvedManifest.from_manifest(resolved_manifest) + def run_readiness_report( + self, + *, + record_limit: int = 100, + page_limit: int = 5, + slice_limit: int = 5, + stream_limit: int = 5, + ) -> ConnectorBuilderReadinessReport: + """Test-read every stream of the draft on Airbyte Cloud and judge each one. + + Streams come from `resolve_manifest()`, so dynamic streams are included. A stream + passes when its test read returns records with no HTTP errors or error logs. + """ + stream_names = self.resolve_manifest(stream_limit=stream_limit).stream_names + streams: list[ConnectorBuilderStreamReadiness] = [] + for stream_name in stream_names: + try: + result = self.test_read_stream( + stream_name, + record_limit=record_limit, + page_limit=page_limit, + slice_limit=slice_limit, + ) + except exc.AirbyteLibError as ex: + streams.append( + ConnectorBuilderStreamReadiness( + stream_name=stream_name, passed=False, error=str(ex) + ) + ) + continue + streams.append(ConnectorBuilderStreamReadiness.from_stream_read(result)) + + passed_count = sum(stream.passed for stream in streams) + return ConnectorBuilderReadinessReport( + passed=bool(streams) and passed_count == len(streams), + stream_count=len(streams), + passed_stream_count=passed_count, + streams=streams, + ) + + def get_checklist(self) -> ConnectorBuilderChecklist: + """Derive a checklist of the steps to a finished custom source from the project state. + + Nothing is stored: each step's status is read from the project with one request. + Test results aren't stored either, so that step stays unknown (`done=None`) until + `run_readiness_report()` is run. + """ + project_data = self.get_project_data() + draft = _draft_from_project_data(project_data) + spec = ((draft or {}).get("spec") or {}).get("connection_specification") or {} + stream_names = [ + stream.get("name") + for stream in (draft or {}).get("streams") or [] + if isinstance(stream, dict) + ] + has_dynamic_streams = bool((draft or {}).get("dynamic_streams")) + testing_values = project_data.get("testingValues") or {} + source_definition_id = project_data["builderProject"].get("sourceDefinitionId") + + items = [ + ConnectorBuilderChecklistItem(step="Save a draft manifest", done=draft is not None), + ConnectorBuilderChecklistItem( + step="Define the connection spec (user inputs)", + done=bool(spec.get("properties")), + ), + ConnectorBuilderChecklistItem( + step="Define at least one stream", + done=bool(stream_names) or has_dynamic_streams, + detail=", ".join(str(name) for name in stream_names) or None, + ), + ConnectorBuilderChecklistItem( + step="Set testing values (enter secrets in the Connector Builder UI)", + done=bool(testing_values), + detail=self.url, + ), + ConnectorBuilderChecklistItem( + step="Pass a test read on every stream", + done=None, + detail="Unknown until checked: run the readiness report.", + ), + ConnectorBuilderChecklistItem( + step="Publish the custom source definition", + done=source_definition_id is not None, + detail=source_definition_id, + ), + ] + return ConnectorBuilderChecklist( + items=items, + next_steps=[item.step for item in items if item.done is not True], + ) + def publish( self, name: str, @@ -557,7 +718,11 @@ def __repr__(self) -> str: __all__ = [ "CloudConnectorBuilderProject", + "ConnectorBuilderChecklist", + "ConnectorBuilderChecklistItem", "ConnectorBuilderPageSummary", + "ConnectorBuilderReadinessReport", + "ConnectorBuilderStreamReadiness", "ConnectorBuilderProjectInfo", "ConnectorBuilderResolvedManifest", "ConnectorBuilderStreamReadResult", diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index a853bd164..299d7e8cc 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -3874,6 +3874,85 @@ def resolve_connector_builder_manifest( } +@mcp_tool(read_only=True, idempotent=True, external_access=True) +def run_connector_builder_readiness_report( + ctx: Context, + *, + builder_project_id: Annotated[ + str | None, + Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + ] = None, + definition_id: Annotated[ + str | None, + Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], +) -> dict[str, Any]: + """Test-read every stream of a Connector Builder project's draft on Airbyte Cloud. + + Resolves the draft (including dynamic streams), test-reads each stream with the project's + testing values, and returns per-stream pass/fail, record and page counts, HTTP errors, + warning and error logs, and inferred schemas, plus an overall verdict. A stream passes when + it returns records with no HTTP errors or error logs. + """ + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + report = project.run_readiness_report() + return { + "builder_project_id": project.builder_project_id, + **report.model_dump(), + } + + +@mcp_tool(read_only=True, idempotent=True) +def get_connector_builder_checklist( + ctx: Context, + *, + builder_project_id: Annotated[ + str | None, + Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + ] = None, + definition_id: Annotated[ + str | None, + Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], +) -> dict[str, Any]: + """Get the steps to a finished custom source and which are done, with next steps. + + Status is derived from the Connector Builder project's current state, so it can't go stale. + Stream test results aren't stored: use `run_connector_builder_readiness_report` for those. + """ + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + checklist = project.get_checklist() + return { + "builder_project_id": project.builder_project_id, + "connector_builder_project_url": project.url, + **checklist.model_dump(), + } + + @mcp_tool(destructive=True, extra_help_text=_manifest_yaml_source_hint) def update_custom_source_definition( # noqa: PLR0913 # Explicit args mirror MCP tool schema. ctx: Context, diff --git a/tests/unit_tests/fixtures/arg_trace_plan.json b/tests/unit_tests/fixtures/arg_trace_plan.json index 816855f6b..c5477c9a6 100644 --- a/tests/unit_tests/fixtures/arg_trace_plan.json +++ b/tests/unit_tests/fixtures/arg_trace_plan.json @@ -149,6 +149,11 @@ "connection_id": "hash", "workspace_id": "omit" }, + "get_connector_builder_checklist": { + "builder_project_id": "hash", + "definition_id": "hash", + "workspace_id": "omit" + }, "get_connector_builder_draft_manifest": { "builder_project_id": "hash", "definition_id": "hash", @@ -309,6 +314,11 @@ "wait_timeout": "hash", "workspace_id": "omit" }, + "run_connector_builder_readiness_report": { + "builder_project_id": "hash", + "definition_id": "hash", + "workspace_id": "omit" + }, "run_sql_query": { "max_records": "hash", "sql_query": "hash" diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index f521f66a5..2608265c2 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -13,6 +13,7 @@ from airbyte.cloud import connector_builder from airbyte.cloud.connector_builder import ( CloudConnectorBuilderProject, + ConnectorBuilderPageSummary, ConnectorBuilderResolvedManifest, ConnectorBuilderStreamReadResult, apply_manifest_text_edit, @@ -456,3 +457,135 @@ def test_publish_validates_unpublished_draft_unless_disabled( project.publish("My API") validate.assert_called_once() + + +def _stream_read( + stream_name: str, + *, + record_count: int, + status: int = 200, + logs: list[dict[str, Any]] | None = None, +) -> ConnectorBuilderStreamReadResult: + return ConnectorBuilderStreamReadResult( + stream_name=stream_name, + record_count=record_count, + slice_count=1, + page_count=1, + test_read_limit_reached=False, + records=[], + pages=[ConnectorBuilderPageSummary(status=status, record_count=record_count)], + logs=logs or [], + ) + + +def test_run_readiness_report_judges_each_stream( + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + monkeypatch.setattr( + project, + "resolve_manifest", + lambda **_: ConnectorBuilderResolvedManifest( + manifest={}, + stream_names=[ + "ok", + "empty", + "http_error", + "error_log", + "warn_log", + "broken", + ], + ), + ) + reads = { + "ok": _stream_read("ok", record_count=3), + "empty": _stream_read("empty", record_count=0), + "http_error": _stream_read("http_error", record_count=1, status=401), + "error_log": _stream_read( + "error_log", record_count=1, logs=[{"level": "ERROR", "message": "boom"}] + ), + "warn_log": _stream_read( + "warn_log", record_count=1, logs=[{"level": "WARN", "message": "hmm"}] + ), + } + + def fake_read(stream_name: str, **_: Any) -> ConnectorBuilderStreamReadResult: + if stream_name == "broken": + raise AirbyteLibInputError(message="read failed") + return reads[stream_name] + + monkeypatch.setattr(project, "test_read_stream", fake_read) + + report = project.run_readiness_report() + + assert {stream.stream_name: stream.passed for stream in report.streams} == { + "ok": True, + "empty": False, + "http_error": False, + "error_log": False, + "warn_log": True, + "broken": False, + } + assert report.passed is False + assert (report.stream_count, report.passed_stream_count) == (6, 2) + by_name = {stream.stream_name: stream for stream in report.streams} + assert by_name["http_error"].http_errors[0].status == 401 + assert by_name["warn_log"].problem_logs == [{"level": "WARN", "message": "hmm"}] + assert by_name["broken"].error is not None + + +def test_run_readiness_report_without_streams_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + monkeypatch.setattr( + project, + "resolve_manifest", + lambda **_: ConnectorBuilderResolvedManifest(manifest={}, stream_names=[]), + ) + assert project.run_readiness_report().passed is False + + +def test_get_checklist_derives_status_from_project( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store( + monkeypatch, + draft={ + "version": "6.0.0", + "spec": {"connection_specification": {"properties": {"api_key": {}}}}, + "streams": [{"name": "users"}], + }, + ) + + checklist = CloudConnectorBuilderProject(_fake_workspace(), "bp").get_checklist() + + assert [item.done for item in checklist.items] == [ + True, + True, + True, + False, + None, + False, + ] + assert checklist.items[2].detail == "users" + assert checklist.next_steps == [ + "Set testing values (enter secrets in the Connector Builder UI)", + "Pass a test read on every stream", + "Publish the custom source definition", + ] + + +def test_get_checklist_without_draft(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_project_store(monkeypatch, draft=None, source_definition_id="def") + + checklist = CloudConnectorBuilderProject(_fake_workspace(), "bp").get_checklist() + + assert [item.done for item in checklist.items] == [ + False, + False, + False, + False, + None, + True, + ] From da7f84c0b1f257e21bf3d7ec16b747a30291c9d7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:02:59 +0000 Subject: [PATCH 06/47] fix(mcp): redact Builder test-read diagnostics, flag capped dynamic streams, check name before saving draft Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 26 +++++++-- airbyte/cloud/workspaces.py | 6 +-- airbyte/mcp/cloud.py | 22 +++++++- .../test_cloud_connector_builder.py | 54 ++++++++++++++++++- tests/unit_tests/test_mcp_cloud.py | 12 +++++ 5 files changed, 111 insertions(+), 9 deletions(-) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index 0c4beff08..973d90e24 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -9,6 +9,7 @@ from __future__ import annotations import difflib +from collections import Counter from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -302,6 +303,13 @@ class ConnectorBuilderReadinessReport(BaseModel): passed: bool stream_count: int passed_stream_count: int + capped_dynamic_streams: list[str] = Field( + default_factory=list, + description=( + "Dynamic streams that generated `stream_limit` streams or more, so some generated " + "streams may be untested. The report can't pass while this is non-empty." + ), + ) streams: list[ConnectorBuilderStreamReadiness] @@ -591,9 +599,20 @@ def run_readiness_report( """Test-read every stream of the draft on Airbyte Cloud and judge each one. Streams come from `resolve_manifest()`, so dynamic streams are included. A stream - passes when its test read returns records with no HTTP errors or error logs. + passes when its test read returns records with no HTTP errors or error logs. A + dynamic stream that generates `stream_limit` streams or more may have untested + streams, so it's listed in `capped_dynamic_streams` and the report doesn't pass. """ - stream_names = self.resolve_manifest(stream_limit=stream_limit).stream_names + resolved = self.resolve_manifest(stream_limit=stream_limit) + generated_counts = Counter( + stream["dynamic_stream_name"] + for stream in resolved.manifest.get("streams") or [] + if isinstance(stream, dict) and stream.get("dynamic_stream_name") + ) + capped_dynamic_streams = sorted( + str(name) for name, count in generated_counts.items() if count >= stream_limit + ) + stream_names = resolved.stream_names streams: list[ConnectorBuilderStreamReadiness] = [] for stream_name in stream_names: try: @@ -614,9 +633,10 @@ def run_readiness_report( passed_count = sum(stream.passed for stream in streams) return ConnectorBuilderReadinessReport( - passed=bool(streams) and passed_count == len(streams), + passed=(bool(streams) and passed_count == len(streams) and not capped_dynamic_streams), stream_count=len(streams), passed_stream_count=passed_count, + capped_dynamic_streams=capped_dynamic_streams, streams=streams, ) diff --git a/airbyte/cloud/workspaces.py b/airbyte/cloud/workspaces.py index 5ac5d8e36..159900b29 100644 --- a/airbyte/cloud/workspaces.py +++ b/airbyte/cloud/workspaces.py @@ -1378,9 +1378,6 @@ def _publish_connector_builder_project( testing_values: dict[str, Any] | None, ) -> cloud_connectors.CustomCloudSourceDefinition: project = self.get_connector_builder_project(builder_project_id) - if manifest_yaml is not None: - project.set_draft_manifest(manifest_yaml, pre_validate=pre_validate) - if unique and project.source_definition_id is None: existing = self.list_custom_source_definitions(definition_type="yaml") if any(d.name == name for d in existing): @@ -1389,6 +1386,9 @@ def _publish_connector_builder_project( resource_name=name, ) + if manifest_yaml is not None: + project.set_draft_manifest(manifest_yaml, pre_validate=pre_validate) + definition = project.publish(name, pre_validate=pre_validate) if testing_values is not None: definition.set_testing_values(testing_values) diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 299d7e8cc..3c9fbadf0 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -3759,6 +3759,20 @@ def set_connector_builder_testing_values( ) +def _redact_stream_diagnostics(stream_data: dict[str, Any]) -> dict[str, Any]: + """Redact credentials from a test read's page URLs, error bodies, logs, and error text.""" + for page in [*stream_data.get("pages", []), *stream_data.get("http_errors", [])]: + for key in ("url", "response_body"): + if page.get(key): + page[key] = redact_agent_text(page[key]) + for log in [*stream_data.get("logs", []), *stream_data.get("problem_logs", [])]: + if isinstance(log.get("message"), str): + log["message"] = redact_agent_text(log["message"]) + if stream_data.get("error"): + stream_data["error"] = redact_agent_text(stream_data["error"]) + return stream_data + + @mcp_tool(read_only=True, external_access=True) def test_connector_builder_stream( ctx: Context, @@ -3819,7 +3833,7 @@ def test_connector_builder_stream( slice_limit=slice_limit, ) return { - **result.model_dump(), + **_redact_stream_diagnostics(result.model_dump()), "records": result.records[:_TEST_READ_RECORDS_PREVIEW_LIMIT], "builder_project_id": project.builder_project_id, } @@ -3908,9 +3922,13 @@ def run_connector_builder_readiness_report( definition_id=definition_id, ) report = project.run_readiness_report() + report_data = report.model_dump() + report_data["streams"] = [ + _redact_stream_diagnostics(stream) for stream in report_data["streams"] + ] return { "builder_project_id": project.builder_project_id, - **report.model_dump(), + **report_data, } diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 2608265c2..8fc8ad9d9 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -20,7 +20,7 @@ render_manifest_text, ) from airbyte.cloud.workspaces import CloudWorkspace -from airbyte.exceptions import AirbyteLibInputError +from airbyte.exceptions import AirbyteDuplicateResourcesError, AirbyteLibInputError from airbyte.secrets.base import SecretString MANIFEST: dict[str, Any] = { @@ -589,3 +589,55 @@ def test_get_checklist_without_draft(monkeypatch: pytest.MonkeyPatch) -> None: None, True, ] + + +def test_readiness_report_withholds_pass_when_dynamic_streams_are_capped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + generated = [{"name": f"t{i}", "dynamic_stream_name": "tables"} for i in range(2)] + monkeypatch.setattr( + project, + "resolve_manifest", + lambda **_: ConnectorBuilderResolvedManifest.from_manifest({ + "streams": [{"name": "users"}, *generated] + }), + ) + monkeypatch.setattr( + project, + "test_read_stream", + lambda stream_name, **_: _stream_read(stream_name, record_count=1), + ) + + capped = project.run_readiness_report(stream_limit=2) + uncapped = project.run_readiness_report(stream_limit=3) + + assert (capped.passed_stream_count, capped.passed) == (3, False) + assert capped.capped_dynamic_streams == ["tables"] + assert (uncapped.passed, uncapped.capped_dynamic_streams) == (True, []) + + +def test_publish_duplicate_name_leaves_draft_unchanged( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = _patch_project_store(monkeypatch, draft={"version": "6.0.0"}) + workspace = CloudWorkspace( + workspace_id="ws", + client_id=SecretString("id"), + client_secret=SecretString("secret"), + ) + monkeypatch.setattr( + CloudWorkspace, + "list_custom_source_definitions", + lambda *_, **__: [SimpleNamespace(name="My API")], + ) + + with pytest.raises(AirbyteDuplicateResourcesError): + workspace.publish_custom_source_definition( + "My API", + builder_project_id="bp", + manifest_yaml=MANIFEST, + unique=True, + ) + + assert store["saved"] == [] diff --git a/tests/unit_tests/test_mcp_cloud.py b/tests/unit_tests/test_mcp_cloud.py index b84ec280c..caf65edfd 100644 --- a/tests/unit_tests/test_mcp_cloud.py +++ b/tests/unit_tests/test_mcp_cloud.py @@ -3570,3 +3570,15 @@ def test_request_cloud_search_index_backfill_is_a_write_tool() -> None: policy = _TOOL_POLICIES["request_cloud_search_index_backfill"] assert policy.external_access is True assert policy.pipeline_change is False + + +def test_redact_stream_diagnostics() -> None: + leaky = "GET https://api.example.com/data?api_key=s3cret failed: Bearer s3cret" + data = cloud_mcp._redact_stream_diagnostics({ + "pages": [{"url": leaky, "response_body": leaky}], + "http_errors": [{"url": leaky, "response_body": None}], + "logs": [{"level": "WARN", "message": leaky}], + "problem_logs": [{"level": "ERROR", "message": leaky}], + "error": leaky, + }) + assert "s3cret" not in str(data) From 3792f5c05fdb4f54763d60a98c8294108371675c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:17:22 +0000 Subject: [PATCH 07/47] fix(cloud): camelCase publish spec fields and handle input-free connectors in checklist Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 13 ++++---- .../test_cloud_connector_builder.py | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index 973d90e24..06125a0ba 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -137,10 +137,8 @@ def _builder_testing_values_spec(manifest: dict[str, Any]) -> dict[str, Any]: def _publish_spec(manifest: dict[str, Any]) -> dict[str, Any]: - """Build the connector spec for publishing, with camelCase `connectionSpecification`.""" - spec = dict(manifest.get("spec") or {}) - spec["connectionSpecification"] = spec.get("connection_specification") or {} - return spec + """Build the connector spec for publishing, with the API's camelCase field names.""" + return {**(manifest.get("spec") or {}), **_builder_testing_values_spec(manifest)} class ConnectorBuilderPageSummary(BaseModel): @@ -649,7 +647,8 @@ def get_checklist(self) -> ConnectorBuilderChecklist: """ project_data = self.get_project_data() draft = _draft_from_project_data(project_data) - spec = ((draft or {}).get("spec") or {}).get("connection_specification") or {} + spec = ((draft or {}).get("spec") or {}).get("connection_specification") + takes_no_inputs = isinstance(spec, dict) and not spec.get("properties") stream_names = [ stream.get("name") for stream in (draft or {}).get("streams") or [] @@ -663,7 +662,7 @@ def get_checklist(self) -> ConnectorBuilderChecklist: ConnectorBuilderChecklistItem(step="Save a draft manifest", done=draft is not None), ConnectorBuilderChecklistItem( step="Define the connection spec (user inputs)", - done=bool(spec.get("properties")), + done=isinstance(spec, dict), ), ConnectorBuilderChecklistItem( step="Define at least one stream", @@ -672,7 +671,7 @@ def get_checklist(self) -> ConnectorBuilderChecklist: ), ConnectorBuilderChecklistItem( step="Set testing values (enter secrets in the Connector Builder UI)", - done=bool(testing_values), + done=bool(testing_values) or takes_no_inputs, detail=self.url, ), ConnectorBuilderChecklistItem( diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 8fc8ad9d9..9e576e75a 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -591,6 +591,37 @@ def test_get_checklist_without_draft(monkeypatch: pytest.MonkeyPatch) -> None: ] +def test_get_checklist_input_free_connector(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_project_store(monkeypatch, draft=MANIFEST) + + checklist = CloudConnectorBuilderProject(_fake_workspace(), "bp").get_checklist() + + assert [item.done for item in checklist.items] == [ + True, + True, + True, + True, + None, + False, + ] + + +def test_publish_spec_uses_api_field_names() -> None: + manifest = { + "spec": { + "connection_specification": {"type": "object"}, + "documentation_url": "https://example.com/docs", + "advanced_auth": {"auth_flow_type": "oauth2.0"}, + } + } + + spec = connector_builder._publish_spec(manifest) + + assert spec["connectionSpecification"] == {"type": "object"} + assert spec["documentationUrl"] == "https://example.com/docs" + assert spec["advancedAuth"] == {"auth_flow_type": "oauth2.0"} + + def test_readiness_report_withholds_pass_when_dynamic_streams_are_capped( monkeypatch: pytest.MonkeyPatch, ) -> None: From ea4d7e5d3742b59e80a745b7de03b79724905d52 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:08:15 +0000 Subject: [PATCH 08/47] feat(mcp): merge Connector Builder checklist into readiness report with explicit streams Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 278 +++++++++++------- airbyte/mcp/cloud.py | 70 ++--- tests/unit_tests/fixtures/arg_trace_plan.json | 6 +- .../test_cloud_connector_builder.py | 200 ++++++------- 4 files changed, 283 insertions(+), 271 deletions(-) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index 06125a0ba..309040fd7 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -9,7 +9,7 @@ from __future__ import annotations import difflib -from collections import Counter +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -31,6 +31,9 @@ _FAILING_LOG_LEVELS = frozenset({"ERROR", "FATAL"}) _MANIFEST_KEYS = frozenset({"streams", "definitions", "check"}) _YAML_LINE_WIDTH = 2**31 - 1 +_READINESS_MAX_WORKERS = 5 +READINESS_MAX_STREAMS = 25 +"""Most streams `run_readiness_report()` reads in one call.""" ManifestTextEditMode = Literal["replace_full", "replace_text", "replace_all"] """How `update_draft_manifest_text()` edits the manifest text. @@ -260,7 +263,13 @@ class ConnectorBuilderStreamReadiness(BaseModel): passed: bool record_count: int = 0 page_count: int = 0 - test_read_limit_reached: bool = False + read_to_end: bool = Field( + default=False, + description=( + "True when the stream ended within the test-read caps. False means the read hit a " + "record, page, or slice cap, so later pages were never read." + ), + ) http_errors: list[ConnectorBuilderPageSummary] = Field(default_factory=list) problem_logs: list[dict[str, Any]] = Field(default_factory=list) inferred_schema: dict[str, Any] | None = None @@ -288,44 +297,109 @@ def from_stream_read( passed=result.record_count > 0 and not has_errors, record_count=result.record_count, page_count=result.page_count, - test_read_limit_reached=result.test_read_limit_reached, + read_to_end=not result.test_read_limit_reached, http_errors=http_errors, problem_logs=problem_logs, inferred_schema=result.inferred_schema, ) -class ConnectorBuilderReadinessReport(BaseModel): - """Result of test-reading every stream of a Connector Builder draft.""" - - passed: bool - stream_count: int - passed_stream_count: int - capped_dynamic_streams: list[str] = Field( - default_factory=list, - description=( - "Dynamic streams that generated `stream_limit` streams or more, so some generated " - "streams may be untested. The report can't pass while this is non-empty." - ), - ) - streams: list[ConnectorBuilderStreamReadiness] - - class ConnectorBuilderChecklistItem(BaseModel): - """One step toward a finished custom source, with status derived from the project.""" + """One step toward a finished custom source.""" step: str done: bool | None = Field( - description="True or False when known; None when it needs a test read to decide.", + description="True or False when known; None when this report can't tell.", ) detail: str | None = None -class ConnectorBuilderChecklist(BaseModel): - """Progress toward a finished custom source, derived from the project's current state.""" +class ConnectorBuilderReadinessReport(BaseModel): + """Checklist and per-stream test reads for a Connector Builder draft.""" - items: list[ConnectorBuilderChecklistItem] + passed: bool = Field( + description=( + "True when every checklist step before publishing is done: every stream of the " + "draft passed and was read to the end." + ), + ) + stream_count: int + passed_stream_count: int + untested_streams: list[str] = Field( + default_factory=list, + description="Streams declared in the draft that weren't in `stream_names`.", + ) + checklist: list[ConnectorBuilderChecklistItem] next_steps: list[str] + streams: list[ConnectorBuilderStreamReadiness] + + +def _readiness_checklist( + project_data: dict[str, Any], + draft: dict[str, Any], + streams: list[ConnectorBuilderStreamReadiness], + untested_streams: list[str], + url: str, +) -> list[ConnectorBuilderChecklistItem]: + spec = (draft.get("spec") or {}).get("connection_specification") + takes_no_inputs = isinstance(spec, dict) and not spec.get("properties") + declared_streams = [ + str(stream.get("name")) for stream in draft.get("streams") or [] if isinstance(stream, dict) + ] + testing_values = project_data.get("testingValues") or {} + source_definition_id = project_data["builderProject"].get("sourceDefinitionId") + failed = [stream.stream_name for stream in streams if not stream.passed] + not_read_to_end = [stream.stream_name for stream in streams if not stream.read_to_end] + return [ + ConnectorBuilderChecklistItem(step="Save a draft manifest", done=True), + ConnectorBuilderChecklistItem( + step="Define the connection spec (user inputs)", + done=isinstance(spec, dict), + ), + ConnectorBuilderChecklistItem( + step="Define at least one stream", + done=bool(declared_streams) or bool(draft.get("dynamic_streams")), + detail=", ".join(declared_streams) or None, + ), + ConnectorBuilderChecklistItem( + step="Set testing values (enter secrets in the Connector Builder UI)", + done=bool(testing_values) or takes_no_inputs, + detail=url, + ), + ConnectorBuilderChecklistItem( + step="Pass a test read on every stream", + done=not failed and not untested_streams, + detail="; ".join( + part + for part in ( + f"failed: {', '.join(failed)}" if failed else "", + f"untested: {', '.join(untested_streams)}" if untested_streams else "", + ) + if part + ) + or None, + ), + ConnectorBuilderChecklistItem( + step="Read every stream to the end within the test-read caps", + done=not not_read_to_end and not untested_streams, + detail=( + f"hit a cap: {', '.join(not_read_to_end)}. Check these with a full sync " + "after publishing." + if not_read_to_end + else None + ), + ), + ConnectorBuilderChecklistItem( + step="Publish the custom source definition", + done=source_definition_id is not None, + detail=source_definition_id, + ), + ConnectorBuilderChecklistItem( + step="Run a full sync of every stream to a no-op destination", + done=None, + detail="Not tracked by this report. Run it after publishing.", + ), + ] class CloudConnectorBuilderProject: @@ -416,8 +490,8 @@ def draft_manifest(self) -> dict[str, Any] | None: """The unpublished draft manifest, or `None` if the project has no draft.""" return _draft_from_project_data(self.get_project_data()) - def _require_draft_manifest(self) -> dict[str, Any]: - manifest = self.draft_manifest + def _require_draft_manifest(self, project_data: dict[str, Any] | None = None) -> dict[str, Any]: + manifest = _draft_from_project_data(project_data or self.get_project_data()) if manifest is None: raise exc.AirbyteLibInputError( message="This Connector Builder project has no draft manifest.", @@ -545,10 +619,27 @@ def test_read_stream( page_limit: Maximum pages to fetch per slice (1 to 20). slice_limit: Maximum slices to read (1 to 20). """ + return self._test_read_stream( + self._require_draft_manifest(), + stream_name, + record_limit=record_limit, + page_limit=page_limit, + slice_limit=slice_limit, + ) + + def _test_read_stream( + self, + manifest: dict[str, Any], + stream_name: str, + *, + record_limit: int, + page_limit: int, + slice_limit: int, + ) -> ConnectorBuilderStreamReadResult: response = api_util.read_connector_builder_project_stream( workspace_id=self.owner_workspace_id, builder_project_id=self.builder_project_id, - manifest=self._require_draft_manifest(), + manifest=manifest, stream_name=stream_name, record_limit=record_limit, page_limit=page_limit, @@ -588,108 +679,71 @@ def resolve_manifest( def run_readiness_report( self, + stream_names: list[str], *, - record_limit: int = 100, - page_limit: int = 5, - slice_limit: int = 5, - stream_limit: int = 5, + record_limit: int = 10_000, + page_limit: int = 20, + slice_limit: int = 20, ) -> ConnectorBuilderReadinessReport: - """Test-read every stream of the draft on Airbyte Cloud and judge each one. + """Check the draft's progress and test-read the given streams on Airbyte Cloud. + + Reads run in parallel. A stream passes when its test read returns records with no + HTTP errors or error logs, and is `read_to_end` when it ended within the test-read + caps (which default to Cloud's maximums). The checklist is derived from the project, + so nothing is stored. `passed` needs every declared stream tested, passing, and read + to the end; generated dynamic streams count only when named in `stream_names`. - Streams come from `resolve_manifest()`, so dynamic streams are included. A stream - passes when its test read returns records with no HTTP errors or error logs. A - dynamic stream that generates `stream_limit` streams or more may have untested - streams, so it's listed in `capped_dynamic_streams` and the report doesn't pass. + Args: + stream_names: Streams to test-read, 1 to `READINESS_MAX_STREAMS` per call. For + dynamic streams, get the generated names from `resolve_manifest()`. + record_limit: Maximum records to read per stream (1 to 10,000). + page_limit: Maximum pages to fetch per slice (1 to 20). + slice_limit: Maximum slices to read (1 to 20). """ - resolved = self.resolve_manifest(stream_limit=stream_limit) - generated_counts = Counter( - stream["dynamic_stream_name"] - for stream in resolved.manifest.get("streams") or [] - if isinstance(stream, dict) and stream.get("dynamic_stream_name") - ) - capped_dynamic_streams = sorted( - str(name) for name, count in generated_counts.items() if count >= stream_limit - ) - stream_names = resolved.stream_names - streams: list[ConnectorBuilderStreamReadiness] = [] - for stream_name in stream_names: + stream_names = list(dict.fromkeys(stream_names)) + if not 1 <= len(stream_names) <= READINESS_MAX_STREAMS: + raise exc.AirbyteLibInputError( + message=f"Pass 1 to {READINESS_MAX_STREAMS} stream names.", + guidance="Split larger sets of streams across several calls.", + context={"stream_count": len(stream_names)}, + ) + project_data = self.get_project_data() + draft = self._require_draft_manifest(project_data) + + def read(stream_name: str) -> ConnectorBuilderStreamReadiness: try: - result = self.test_read_stream( + result = self._test_read_stream( + draft, stream_name, record_limit=record_limit, page_limit=page_limit, slice_limit=slice_limit, ) except exc.AirbyteLibError as ex: - streams.append( - ConnectorBuilderStreamReadiness( - stream_name=stream_name, passed=False, error=str(ex) - ) + return ConnectorBuilderStreamReadiness( + stream_name=stream_name, passed=False, error=str(ex) ) - continue - streams.append(ConnectorBuilderStreamReadiness.from_stream_read(result)) + return ConnectorBuilderStreamReadiness.from_stream_read(result) - passed_count = sum(stream.passed for stream in streams) + with ThreadPoolExecutor(max_workers=min(_READINESS_MAX_WORKERS, len(stream_names))) as pool: + streams = list(pool.map(read, stream_names)) + + untested_streams = [ + str(stream.get("name")) + for stream in draft.get("streams") or [] + if isinstance(stream, dict) and stream.get("name") not in stream_names + ] + checklist = _readiness_checklist(project_data, draft, streams, untested_streams, self.url) return ConnectorBuilderReadinessReport( - passed=(bool(streams) and passed_count == len(streams) and not capped_dynamic_streams), + passed=all(item.done for item in checklist[:6]), stream_count=len(streams), - passed_stream_count=passed_count, - capped_dynamic_streams=capped_dynamic_streams, + passed_stream_count=sum(stream.passed for stream in streams), + untested_streams=untested_streams, + checklist=checklist, + next_steps=[item.step for item in checklist if item.done is not True], streams=streams, ) - def get_checklist(self) -> ConnectorBuilderChecklist: - """Derive a checklist of the steps to a finished custom source from the project state. - - Nothing is stored: each step's status is read from the project with one request. - Test results aren't stored either, so that step stays unknown (`done=None`) until - `run_readiness_report()` is run. - """ - project_data = self.get_project_data() - draft = _draft_from_project_data(project_data) - spec = ((draft or {}).get("spec") or {}).get("connection_specification") - takes_no_inputs = isinstance(spec, dict) and not spec.get("properties") - stream_names = [ - stream.get("name") - for stream in (draft or {}).get("streams") or [] - if isinstance(stream, dict) - ] - has_dynamic_streams = bool((draft or {}).get("dynamic_streams")) - testing_values = project_data.get("testingValues") or {} - source_definition_id = project_data["builderProject"].get("sourceDefinitionId") - - items = [ - ConnectorBuilderChecklistItem(step="Save a draft manifest", done=draft is not None), - ConnectorBuilderChecklistItem( - step="Define the connection spec (user inputs)", - done=isinstance(spec, dict), - ), - ConnectorBuilderChecklistItem( - step="Define at least one stream", - done=bool(stream_names) or has_dynamic_streams, - detail=", ".join(str(name) for name in stream_names) or None, - ), - ConnectorBuilderChecklistItem( - step="Set testing values (enter secrets in the Connector Builder UI)", - done=bool(testing_values) or takes_no_inputs, - detail=self.url, - ), - ConnectorBuilderChecklistItem( - step="Pass a test read on every stream", - done=None, - detail="Unknown until checked: run the readiness report.", - ), - ConnectorBuilderChecklistItem( - step="Publish the custom source definition", - done=source_definition_id is not None, - detail=source_definition_id, - ), - ] - return ConnectorBuilderChecklist( - items=items, - next_steps=[item.step for item in items if item.done is not True], - ) - def publish( self, name: str, @@ -736,8 +790,8 @@ def __repr__(self) -> str: __all__ = [ + "READINESS_MAX_STREAMS", "CloudConnectorBuilderProject", - "ConnectorBuilderChecklist", "ConnectorBuilderChecklistItem", "ConnectorBuilderPageSummary", "ConnectorBuilderReadinessReport", diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 3c9fbadf0..f0ad00797 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -36,6 +36,7 @@ from airbyte._util import api_util from airbyte.cloud.client import _MAX_WORKSPACES_TO_VALIDATE, CloudClient from airbyte.cloud.connector_builder import ( + READINESS_MAX_STREAMS, CloudConnectorBuilderProject, ManifestTextEditMode, ) @@ -3891,6 +3892,17 @@ def resolve_connector_builder_manifest( @mcp_tool(read_only=True, idempotent=True, external_access=True) def run_connector_builder_readiness_report( ctx: Context, + stream_names: Annotated[ + str | list[str], + Field( + description=( + f"Streams to test-read, 1 to {READINESS_MAX_STREAMS} per call. Pass every " + "stream of the draft (split larger sets across calls). For dynamic streams, " + "get the generated names from `resolve_connector_builder_manifest`." + ), + ), + TraceArg.FINGERPRINT, + ], *, builder_project_id: Annotated[ str | None, @@ -3909,65 +3921,31 @@ def run_connector_builder_readiness_report( TraceArg.OMIT, ], ) -> dict[str, Any]: - """Test-read every stream of a Connector Builder project's draft on Airbyte Cloud. - - Resolves the draft (including dynamic streams), test-reads each stream with the project's - testing values, and returns per-stream pass/fail, record and page counts, HTTP errors, - warning and error logs, and inferred schemas, plus an overall verdict. A stream passes when - it returns records with no HTTP errors or error logs. + """Check a Connector Builder draft's progress and test-read its streams on Airbyte Cloud. + + Returns a checklist of the steps to a finished custom source, with next steps, plus + per-stream results from parallel test reads using the project's testing values: pass/fail, + `read_to_end`, record and page counts, HTTP errors, warning and error logs, and inferred + schemas. A stream passes when it returns records with no HTTP errors or error logs. + Cloud test reads stop at 20 pages per slice and 20 slices, so a stream that hits a cap + isn't `read_to_end` and the report doesn't pass. After publishing, run a full sync of + every stream: `deploy_noop_destination_to_cloud`, deploy the source with + `deploy_connector_to_cloud`, then `create_connection_on_cloud` and `run_cloud_sync`. """ workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) project = workspace.get_connector_builder_project( builder_project_id, definition_id=definition_id, ) - report = project.run_readiness_report() + report = project.run_readiness_report(resolve_list_of_strings(stream_names)) report_data = report.model_dump() report_data["streams"] = [ _redact_stream_diagnostics(stream) for stream in report_data["streams"] ] - return { - "builder_project_id": project.builder_project_id, - **report_data, - } - - -@mcp_tool(read_only=True, idempotent=True) -def get_connector_builder_checklist( - ctx: Context, - *, - builder_project_id: Annotated[ - str | None, - Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), - ] = None, - definition_id: Annotated[ - str | None, - Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), - ] = None, - workspace_id: Annotated[ - str | None, - Field( - description=WORKSPACE_ID_TIP_TEXT, - default=None, - ), - TraceArg.OMIT, - ], -) -> dict[str, Any]: - """Get the steps to a finished custom source and which are done, with next steps. - - Status is derived from the Connector Builder project's current state, so it can't go stale. - Stream test results aren't stored: use `run_connector_builder_readiness_report` for those. - """ - workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) - project = workspace.get_connector_builder_project( - builder_project_id, - definition_id=definition_id, - ) - checklist = project.get_checklist() return { "builder_project_id": project.builder_project_id, "connector_builder_project_url": project.url, - **checklist.model_dump(), + **report_data, } diff --git a/tests/unit_tests/fixtures/arg_trace_plan.json b/tests/unit_tests/fixtures/arg_trace_plan.json index c5477c9a6..f2bf5b8f3 100644 --- a/tests/unit_tests/fixtures/arg_trace_plan.json +++ b/tests/unit_tests/fixtures/arg_trace_plan.json @@ -149,11 +149,6 @@ "connection_id": "hash", "workspace_id": "omit" }, - "get_connector_builder_checklist": { - "builder_project_id": "hash", - "definition_id": "hash", - "workspace_id": "omit" - }, "get_connector_builder_draft_manifest": { "builder_project_id": "hash", "definition_id": "hash", @@ -317,6 +312,7 @@ "run_connector_builder_readiness_report": { "builder_project_id": "hash", "definition_id": "hash", + "stream_names": "fingerprint list", "workspace_id": "omit" }, "run_sql_query": { diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 9e576e75a..3b7f46d8b 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -272,6 +272,7 @@ def _patch_project_store( *, draft: dict[str, Any] | None, source_definition_id: str | None = None, + testing_values: dict[str, Any] | None = None, ) -> dict[str, Any]: store: dict[str, Any] = {"draft": draft, "saved": []} @@ -284,6 +285,7 @@ def fake_get(**_: Any) -> dict[str, Any]: "sourceDefinitionId": source_definition_id, }, "declarativeManifest": {"manifest": store["draft"], "isDraft": True}, + "testingValues": testing_values, } def fake_update(**kwargs: Any) -> None: @@ -465,59 +467,64 @@ def _stream_read( record_count: int, status: int = 200, logs: list[dict[str, Any]] | None = None, + limit_reached: bool = False, ) -> ConnectorBuilderStreamReadResult: return ConnectorBuilderStreamReadResult( stream_name=stream_name, record_count=record_count, slice_count=1, page_count=1, - test_read_limit_reached=False, + test_read_limit_reached=limit_reached, records=[], pages=[ConnectorBuilderPageSummary(status=status, record_count=record_count)], logs=logs or [], ) +def _patch_reads( + monkeypatch: pytest.MonkeyPatch, + project: CloudConnectorBuilderProject, + reads: dict[str, ConnectorBuilderStreamReadResult], +) -> None: + def fake_read( + _manifest: Any, stream_name: str, **_: Any + ) -> ConnectorBuilderStreamReadResult: + if stream_name not in reads: + raise AirbyteLibInputError(message="read failed") + return reads[stream_name] + + monkeypatch.setattr(project, "_test_read_stream", fake_read) + + def test_run_readiness_report_judges_each_stream( monkeypatch: pytest.MonkeyPatch, ) -> None: + names = ["ok", "empty", "http_error", "error_log", "warn_log", "broken"] + _patch_project_store( + monkeypatch, draft={**MANIFEST, "streams": [{"name": name} for name in names]} + ) project = CloudConnectorBuilderProject(_fake_workspace(), "bp") - monkeypatch.setattr( + _patch_reads( + monkeypatch, project, - "resolve_manifest", - lambda **_: ConnectorBuilderResolvedManifest( - manifest={}, - stream_names=[ - "ok", - "empty", - "http_error", + { + "ok": _stream_read("ok", record_count=3), + "empty": _stream_read("empty", record_count=0), + "http_error": _stream_read("http_error", record_count=1, status=401), + "error_log": _stream_read( "error_log", - "warn_log", - "broken", - ], - ), + record_count=1, + logs=[{"level": "ERROR", "message": "boom"}], + ), + "warn_log": _stream_read( + "warn_log", record_count=1, logs=[{"level": "WARN", "message": "hmm"}] + ), + }, ) - reads = { - "ok": _stream_read("ok", record_count=3), - "empty": _stream_read("empty", record_count=0), - "http_error": _stream_read("http_error", record_count=1, status=401), - "error_log": _stream_read( - "error_log", record_count=1, logs=[{"level": "ERROR", "message": "boom"}] - ), - "warn_log": _stream_read( - "warn_log", record_count=1, logs=[{"level": "WARN", "message": "hmm"}] - ), - } - - def fake_read(stream_name: str, **_: Any) -> ConnectorBuilderStreamReadResult: - if stream_name == "broken": - raise AirbyteLibInputError(message="read failed") - return reads[stream_name] - - monkeypatch.setattr(project, "test_read_stream", fake_read) - report = project.run_readiness_report() + report = project.run_readiness_report(names) + assert [stream.stream_name for stream in report.streams] == names assert {stream.stream_name: stream.passed for stream in report.streams} == { "ok": True, "empty": False, @@ -532,78 +539,81 @@ def fake_read(stream_name: str, **_: Any) -> ConnectorBuilderStreamReadResult: assert by_name["http_error"].http_errors[0].status == 401 assert by_name["warn_log"].problem_logs == [{"level": "WARN", "message": "hmm"}] assert by_name["broken"].error is not None + assert report.checklist[4].detail == "failed: empty, http_error, error_log, broken" -def test_run_readiness_report_without_streams_fails( - monkeypatch: pytest.MonkeyPatch, -) -> None: +@pytest.mark.parametrize("count", [0, 26]) +def test_run_readiness_report_rejects_stream_counts_out_of_range(count: int) -> None: project = CloudConnectorBuilderProject(_fake_workspace(), "bp") - monkeypatch.setattr( - project, - "resolve_manifest", - lambda **_: ConnectorBuilderResolvedManifest(manifest={}, stream_names=[]), - ) - assert project.run_readiness_report().passed is False + with pytest.raises(AirbyteLibInputError): + project.run_readiness_report([f"s{i}" for i in range(count)]) -def test_get_checklist_derives_status_from_project( +def test_run_readiness_report_without_draft_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: - _patch_project_store( - monkeypatch, - draft={ - "version": "6.0.0", - "spec": {"connection_specification": {"properties": {"api_key": {}}}}, - "streams": [{"name": "users"}], - }, - ) + _patch_project_store(monkeypatch, draft=None) + with pytest.raises(AirbyteLibInputError): + CloudConnectorBuilderProject(_fake_workspace(), "bp").run_readiness_report([ + "users" + ]) - checklist = CloudConnectorBuilderProject(_fake_workspace(), "bp").get_checklist() - assert [item.done for item in checklist.items] == [ - True, - True, - True, - False, - None, - False, - ] - assert checklist.items[2].detail == "users" - assert checklist.next_steps == [ - "Set testing values (enter secrets in the Connector Builder UI)", - "Pass a test read on every stream", +def test_readiness_report_passes_only_when_every_stream_is_read_to_end( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store(monkeypatch, draft=MANIFEST) + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + reads = { + "users": _stream_read("users", record_count=1), + "orders": _stream_read("orders", record_count=1, limit_reached=True), + } + _patch_reads(monkeypatch, project, reads) + + partial = project.run_readiness_report(["users"]) + capped = project.run_readiness_report(["users", "orders"]) + reads["orders"] = _stream_read("orders", record_count=1) + full = project.run_readiness_report(["users", "orders", "users"]) + + assert (partial.passed, partial.untested_streams) == (False, ["orders"]) + assert capped.passed is False + assert capped.passed_stream_count == 2 + assert [s.read_to_end for s in capped.streams] == [True, False] + assert capped.checklist[5].done is False + assert full.passed is True + assert full.stream_count == 2 + assert full.next_steps == [ "Publish the custom source definition", + "Run a full sync of every stream to a no-op destination", ] -def test_get_checklist_without_draft(monkeypatch: pytest.MonkeyPatch) -> None: - _patch_project_store(monkeypatch, draft=None, source_definition_id="def") +def test_readiness_checklist_requires_testing_values_when_spec_has_inputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + draft = { + "version": "6.0.0", + "spec": {"connection_specification": {"properties": {"api_key": {}}}}, + "streams": [{"name": "users"}], + } + _patch_project_store(monkeypatch, draft=draft, source_definition_id="def") + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + _patch_reads(monkeypatch, project, {"users": _stream_read("users", record_count=1)}) - checklist = CloudConnectorBuilderProject(_fake_workspace(), "bp").get_checklist() + report = project.run_readiness_report(["users"]) - assert [item.done for item in checklist.items] == [ - False, - False, - False, - False, - None, + assert [item.done for item in report.checklist] == [ + True, True, - ] - - -def test_get_checklist_input_free_connector(monkeypatch: pytest.MonkeyPatch) -> None: - _patch_project_store(monkeypatch, draft=MANIFEST) - - checklist = CloudConnectorBuilderProject(_fake_workspace(), "bp").get_checklist() - - assert [item.done for item in checklist.items] == [ True, + False, True, True, True, None, - False, ] + assert report.passed is False + assert report.checklist[2].detail == "users" def test_publish_spec_uses_api_field_names() -> None: @@ -622,32 +632,6 @@ def test_publish_spec_uses_api_field_names() -> None: assert spec["advancedAuth"] == {"auth_flow_type": "oauth2.0"} -def test_readiness_report_withholds_pass_when_dynamic_streams_are_capped( - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") - generated = [{"name": f"t{i}", "dynamic_stream_name": "tables"} for i in range(2)] - monkeypatch.setattr( - project, - "resolve_manifest", - lambda **_: ConnectorBuilderResolvedManifest.from_manifest({ - "streams": [{"name": "users"}, *generated] - }), - ) - monkeypatch.setattr( - project, - "test_read_stream", - lambda stream_name, **_: _stream_read(stream_name, record_count=1), - ) - - capped = project.run_readiness_report(stream_limit=2) - uncapped = project.run_readiness_report(stream_limit=3) - - assert (capped.passed_stream_count, capped.passed) == (3, False) - assert capped.capped_dynamic_streams == ["tables"] - assert (uncapped.passed, uncapped.capped_dynamic_streams) == (True, []) - - def test_publish_duplicate_name_leaves_draft_unchanged( monkeypatch: pytest.MonkeyPatch, ) -> None: From 0ca475e7b22f0e5c97c7317060ab01db4c6b8e76 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:15:50 +0000 Subject: [PATCH 09/47] fix(cloud): keep readiness results on request failures and return clean read errors Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 12 +++++++++- .../test_cloud_connector_builder.py | 22 ++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index 309040fd7..eeb8082ba 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Literal +import requests import yaml from pydantic import BaseModel, Field @@ -720,8 +721,17 @@ def read(stream_name: str) -> ConnectorBuilderStreamReadiness: slice_limit=slice_limit, ) except exc.AirbyteLibError as ex: + error = ex.get_message() + if ex.guidance: + error += f" {ex.guidance}" return ConnectorBuilderStreamReadiness( - stream_name=stream_name, passed=False, error=str(ex) + stream_name=stream_name, passed=False, error=error + ) + except requests.RequestException as ex: + return ConnectorBuilderStreamReadiness( + stream_name=stream_name, + passed=False, + error=f"Request to Airbyte Cloud failed: {type(ex).__name__}", ) return ConnectorBuilderStreamReadiness.from_stream_read(result) diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 3b7f46d8b..b0b869380 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock import pytest +import requests from airbyte._util import api_util from airbyte.cloud import connector_builder @@ -489,8 +490,12 @@ def _patch_reads( def fake_read( _manifest: Any, stream_name: str, **_: Any ) -> ConnectorBuilderStreamReadResult: + if stream_name == "dropped": + raise requests.ConnectionError("connection reset") if stream_name not in reads: - raise AirbyteLibInputError(message="read failed") + raise AirbyteLibInputError( + message="read failed", guidance="Fix the stream." + ) return reads[stream_name] monkeypatch.setattr(project, "_test_read_stream", fake_read) @@ -499,7 +504,7 @@ def fake_read( def test_run_readiness_report_judges_each_stream( monkeypatch: pytest.MonkeyPatch, ) -> None: - names = ["ok", "empty", "http_error", "error_log", "warn_log", "broken"] + names = ["ok", "empty", "http_error", "error_log", "warn_log", "broken", "dropped"] _patch_project_store( monkeypatch, draft={**MANIFEST, "streams": [{"name": name} for name in names]} ) @@ -532,14 +537,21 @@ def test_run_readiness_report_judges_each_stream( "error_log": False, "warn_log": True, "broken": False, + "dropped": False, } assert report.passed is False - assert (report.stream_count, report.passed_stream_count) == (6, 2) + assert (report.stream_count, report.passed_stream_count) == (7, 2) by_name = {stream.stream_name: stream for stream in report.streams} assert by_name["http_error"].http_errors[0].status == 401 assert by_name["warn_log"].problem_logs == [{"level": "WARN", "message": "hmm"}] - assert by_name["broken"].error is not None - assert report.checklist[4].detail == "failed: empty, http_error, error_log, broken" + assert by_name["broken"].error == "read failed Fix the stream." + assert ( + by_name["dropped"].error == "Request to Airbyte Cloud failed: ConnectionError" + ) + assert ( + report.checklist[4].detail + == "failed: empty, http_error, error_log, broken, dropped" + ) @pytest.mark.parametrize("count", [0, 26]) From 69789159e294766e32888d719e6107b8461e0750 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:23:18 +0000 Subject: [PATCH 10/47] fix(cloud): derive readiness stream coverage from the resolved manifest Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 36 ++++++++++++++----- .../test_cloud_connector_builder.py | 21 +++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index eeb8082ba..016f55c05 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -338,15 +338,13 @@ class ConnectorBuilderReadinessReport(BaseModel): def _readiness_checklist( project_data: dict[str, Any], draft: dict[str, Any], + declared_streams: list[str], streams: list[ConnectorBuilderStreamReadiness], untested_streams: list[str], url: str, ) -> list[ConnectorBuilderChecklistItem]: spec = (draft.get("spec") or {}).get("connection_specification") takes_no_inputs = isinstance(spec, dict) and not spec.get("properties") - declared_streams = [ - str(stream.get("name")) for stream in draft.get("streams") or [] if isinstance(stream, dict) - ] testing_values = project_data.get("testingValues") or {} source_definition_id = project_data["builderProject"].get("sourceDefinitionId") failed = [stream.stream_name for stream in streams if not stream.passed] @@ -678,6 +676,27 @@ def resolve_manifest( ) return ConnectorBuilderResolvedManifest.from_manifest(resolved_manifest) + def _declared_stream_names(self, draft: dict[str, Any]) -> list[str]: + """Names of the draft's static streams, with `$ref`s and `$parameters` resolved.""" + resolved = api_util.full_resolve_connector_builder_project_manifest( + workspace_id=self.owner_workspace_id, + builder_project_id=self.builder_project_id, + manifest=draft, + stream_limit=1, + api_root=self.workspace.api_root, + client_id=self.workspace.client_id, + client_secret=self.workspace.client_secret, + bearer_token=self.workspace.bearer_token, + config_api_root=self.workspace.config_api_root, + ) + return [ + stream["name"] + for stream in resolved.get("streams") or [] + if isinstance(stream, dict) + and isinstance(stream.get("name"), str) + and "dynamic_stream_name" not in stream + ] + def run_readiness_report( self, stream_names: list[str], @@ -738,12 +757,11 @@ def read(stream_name: str) -> ConnectorBuilderStreamReadiness: with ThreadPoolExecutor(max_workers=min(_READINESS_MAX_WORKERS, len(stream_names))) as pool: streams = list(pool.map(read, stream_names)) - untested_streams = [ - str(stream.get("name")) - for stream in draft.get("streams") or [] - if isinstance(stream, dict) and stream.get("name") not in stream_names - ] - checklist = _readiness_checklist(project_data, draft, streams, untested_streams, self.url) + declared_streams = self._declared_stream_names(draft) + untested_streams = [name for name in declared_streams if name not in stream_names] + checklist = _readiness_checklist( + project_data, draft, declared_streams, streams, untested_streams, self.url + ) return ConnectorBuilderReadinessReport( passed=all(item.done for item in checklist[:6]), stream_count=len(streams), diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index b0b869380..87f6b7bdc 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -294,6 +294,11 @@ def fake_update(**kwargs: Any) -> None: store["saved"].append(kwargs) monkeypatch.setattr(api_util, "get_connector_builder_project", fake_get) + monkeypatch.setattr( + api_util, + "full_resolve_connector_builder_project_manifest", + lambda **_: store.get("resolved") or store["draft"], + ) monkeypatch.setattr(api_util, "update_connector_builder_project", fake_update) monkeypatch.setattr(api_util, "validate_yaml_manifest", lambda *_, **__: None) return store @@ -668,3 +673,19 @@ def test_publish_duplicate_name_leaves_draft_unchanged( ) assert store["saved"] == [] + + +def test_readiness_uses_resolved_stream_names(monkeypatch: pytest.MonkeyPatch) -> None: + store = _patch_project_store( + monkeypatch, draft={"streams": [{"$ref": "#/definitions/streams/users"}]} + ) + store["resolved"] = { + "streams": [ + {"name": "users"}, + {"name": "generated", "dynamic_stream_name": "tables"}, + ] + } + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + _patch_reads(monkeypatch, project, {"users": _stream_read("users", record_count=1)}) + report = project.run_readiness_report(["users"]) + assert report.untested_streams == [] From 02f875634cfa0e05e390c330613701f0aecf8049 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:25:36 +0000 Subject: [PATCH 11/47] fix(cloud): drop the per-call stream cap on the readiness report Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 12 +++--------- airbyte/mcp/cloud.py | 5 ++--- tests/unit_tests/test_cloud_connector_builder.py | 5 ++--- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index 016f55c05..fcf2570e0 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -33,7 +33,6 @@ _MANIFEST_KEYS = frozenset({"streams", "definitions", "check"}) _YAML_LINE_WIDTH = 2**31 - 1 _READINESS_MAX_WORKERS = 5 -READINESS_MAX_STREAMS = 25 """Most streams `run_readiness_report()` reads in one call.""" ManifestTextEditMode = Literal["replace_full", "replace_text", "replace_all"] @@ -714,19 +713,15 @@ def run_readiness_report( to the end; generated dynamic streams count only when named in `stream_names`. Args: - stream_names: Streams to test-read, 1 to `READINESS_MAX_STREAMS` per call. For + stream_names: Streams to test-read (at least 1). For dynamic streams, get the generated names from `resolve_manifest()`. record_limit: Maximum records to read per stream (1 to 10,000). page_limit: Maximum pages to fetch per slice (1 to 20). slice_limit: Maximum slices to read (1 to 20). """ stream_names = list(dict.fromkeys(stream_names)) - if not 1 <= len(stream_names) <= READINESS_MAX_STREAMS: - raise exc.AirbyteLibInputError( - message=f"Pass 1 to {READINESS_MAX_STREAMS} stream names.", - guidance="Split larger sets of streams across several calls.", - context={"stream_count": len(stream_names)}, - ) + if not stream_names: + raise exc.AirbyteLibInputError(message="Pass at least 1 stream name.") project_data = self.get_project_data() draft = self._require_draft_manifest(project_data) @@ -818,7 +813,6 @@ def __repr__(self) -> str: __all__ = [ - "READINESS_MAX_STREAMS", "CloudConnectorBuilderProject", "ConnectorBuilderChecklistItem", "ConnectorBuilderPageSummary", diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index f0ad00797..98c727407 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -36,7 +36,6 @@ from airbyte._util import api_util from airbyte.cloud.client import _MAX_WORKSPACES_TO_VALIDATE, CloudClient from airbyte.cloud.connector_builder import ( - READINESS_MAX_STREAMS, CloudConnectorBuilderProject, ManifestTextEditMode, ) @@ -3896,8 +3895,8 @@ def run_connector_builder_readiness_report( str | list[str], Field( description=( - f"Streams to test-read, 1 to {READINESS_MAX_STREAMS} per call. Pass every " - "stream of the draft (split larger sets across calls). For dynamic streams, " + "Streams to test-read (at least 1). Pass every stream of the draft. " + "For dynamic streams, " "get the generated names from `resolve_connector_builder_manifest`." ), ), diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 87f6b7bdc..d64a9dd3b 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -559,11 +559,10 @@ def test_run_readiness_report_judges_each_stream( ) -@pytest.mark.parametrize("count", [0, 26]) -def test_run_readiness_report_rejects_stream_counts_out_of_range(count: int) -> None: +def test_run_readiness_report_requires_stream_names() -> None: project = CloudConnectorBuilderProject(_fake_workspace(), "bp") with pytest.raises(AirbyteLibInputError): - project.run_readiness_report([f"s{i}" for i in range(count)]) + project.run_readiness_report([]) def test_run_readiness_report_without_draft_fails( From 27de22dd69cd1d14be671506b8d72f3252f1855e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:28:39 +0000 Subject: [PATCH 12/47] feat(cloud): scope readiness to the named streams and add timeout_seconds Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 80 ++++++++----------- airbyte/mcp/cloud.py | 19 ++++- tests/unit_tests/fixtures/arg_trace_plan.json | 1 + .../test_cloud_connector_builder.py | 45 ++++++----- 4 files changed, 77 insertions(+), 68 deletions(-) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index fcf2570e0..ba1ba1efa 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -9,7 +9,7 @@ from __future__ import annotations import difflib -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import ThreadPoolExecutor, wait from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -319,15 +319,18 @@ class ConnectorBuilderReadinessReport(BaseModel): passed: bool = Field( description=( - "True when every checklist step before publishing is done: every stream of the " - "draft passed and was read to the end." + "True when every named stream passed and was read to the end. Covers only the " + "named streams; a full sync validates the whole connector." ), ) stream_count: int passed_stream_count: int - untested_streams: list[str] = Field( + pending_streams: list[str] = Field( default_factory=list, - description="Streams declared in the draft that weren't in `stream_names`.", + description=( + "Named streams that hadn't finished when `timeout_seconds` ran out. " + "Pass them in another call to resume." + ), ) checklist: list[ConnectorBuilderChecklistItem] next_steps: list[str] @@ -337,9 +340,8 @@ class ConnectorBuilderReadinessReport(BaseModel): def _readiness_checklist( project_data: dict[str, Any], draft: dict[str, Any], - declared_streams: list[str], streams: list[ConnectorBuilderStreamReadiness], - untested_streams: list[str], + pending_streams: list[str], url: str, ) -> list[ConnectorBuilderChecklistItem]: spec = (draft.get("spec") or {}).get("connection_specification") @@ -356,8 +358,7 @@ def _readiness_checklist( ), ConnectorBuilderChecklistItem( step="Define at least one stream", - done=bool(declared_streams) or bool(draft.get("dynamic_streams")), - detail=", ".join(declared_streams) or None, + done=bool(draft.get("streams")) or bool(draft.get("dynamic_streams")), ), ConnectorBuilderChecklistItem( step="Set testing values (enter secrets in the Connector Builder UI)", @@ -365,21 +366,21 @@ def _readiness_checklist( detail=url, ), ConnectorBuilderChecklistItem( - step="Pass a test read on every stream", - done=not failed and not untested_streams, + step="Pass a test read on the named streams", + done=not failed and not pending_streams, detail="; ".join( part for part in ( f"failed: {', '.join(failed)}" if failed else "", - f"untested: {', '.join(untested_streams)}" if untested_streams else "", + f"pending: {', '.join(pending_streams)}" if pending_streams else "", ) if part ) or None, ), ConnectorBuilderChecklistItem( - step="Read every stream to the end within the test-read caps", - done=not not_read_to_end and not untested_streams, + step="Read the named streams to the end within the test-read caps", + done=not not_read_to_end and not pending_streams, detail=( f"hit a cap: {', '.join(not_read_to_end)}. Check these with a full sync " "after publishing." @@ -675,27 +676,6 @@ def resolve_manifest( ) return ConnectorBuilderResolvedManifest.from_manifest(resolved_manifest) - def _declared_stream_names(self, draft: dict[str, Any]) -> list[str]: - """Names of the draft's static streams, with `$ref`s and `$parameters` resolved.""" - resolved = api_util.full_resolve_connector_builder_project_manifest( - workspace_id=self.owner_workspace_id, - builder_project_id=self.builder_project_id, - manifest=draft, - stream_limit=1, - api_root=self.workspace.api_root, - client_id=self.workspace.client_id, - client_secret=self.workspace.client_secret, - bearer_token=self.workspace.bearer_token, - config_api_root=self.workspace.config_api_root, - ) - return [ - stream["name"] - for stream in resolved.get("streams") or [] - if isinstance(stream, dict) - and isinstance(stream.get("name"), str) - and "dynamic_stream_name" not in stream - ] - def run_readiness_report( self, stream_names: list[str], @@ -703,14 +683,15 @@ def run_readiness_report( record_limit: int = 10_000, page_limit: int = 20, slice_limit: int = 20, + timeout_seconds: float | None = None, ) -> ConnectorBuilderReadinessReport: """Check the draft's progress and test-read the given streams on Airbyte Cloud. Reads run in parallel. A stream passes when its test read returns records with no HTTP errors or error logs, and is `read_to_end` when it ended within the test-read caps (which default to Cloud's maximums). The checklist is derived from the project, - so nothing is stored. `passed` needs every declared stream tested, passing, and read - to the end; generated dynamic streams count only when named in `stream_names`. + so nothing is stored. `passed` covers only the named streams: each must pass and be + read to the end. A full sync after publishing validates the whole connector. Args: stream_names: Streams to test-read (at least 1). For @@ -718,6 +699,8 @@ def run_readiness_report( record_limit: Maximum records to read per stream (1 to 10,000). page_limit: Maximum pages to fetch per slice (1 to 20). slice_limit: Maximum slices to read (1 to 20). + timeout_seconds: Return after this many seconds with the streams finished so far; + the rest are listed in `pending_streams`. No limit by default. """ stream_names = list(dict.fromkeys(stream_names)) if not stream_names: @@ -749,19 +732,24 @@ def read(stream_name: str) -> ConnectorBuilderStreamReadiness: ) return ConnectorBuilderStreamReadiness.from_stream_read(result) - with ThreadPoolExecutor(max_workers=min(_READINESS_MAX_WORKERS, len(stream_names))) as pool: - streams = list(pool.map(read, stream_names)) + pool = ThreadPoolExecutor(max_workers=min(_READINESS_MAX_WORKERS, len(stream_names))) + try: + futures = [pool.submit(read, name) for name in stream_names] + done, _ = wait(futures, timeout=timeout_seconds) + finally: + pool.shutdown(wait=False, cancel_futures=True) + streams = [future.result() for future in futures if future in done] + pending_streams = [ + name for name, future in zip(stream_names, futures, strict=True) if future not in done + ] - declared_streams = self._declared_stream_names(draft) - untested_streams = [name for name in declared_streams if name not in stream_names] - checklist = _readiness_checklist( - project_data, draft, declared_streams, streams, untested_streams, self.url - ) + checklist = _readiness_checklist(project_data, draft, streams, pending_streams, self.url) return ConnectorBuilderReadinessReport( - passed=all(item.done for item in checklist[:6]), + passed=not pending_streams + and all(stream.passed and stream.read_to_end for stream in streams), stream_count=len(streams), passed_stream_count=sum(stream.passed for stream in streams), - untested_streams=untested_streams, + pending_streams=pending_streams, checklist=checklist, next_steps=[item.step for item in checklist if item.done is not True], streams=streams, diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 98c727407..afc8dd73e 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -3895,7 +3895,7 @@ def run_connector_builder_readiness_report( str | list[str], Field( description=( - "Streams to test-read (at least 1). Pass every stream of the draft. " + "Streams to test-read (at least 1). The report covers only these streams. " "For dynamic streams, " "get the generated names from `resolve_connector_builder_manifest`." ), @@ -3919,6 +3919,17 @@ def run_connector_builder_readiness_report( ), TraceArg.OMIT, ], + timeout_seconds: Annotated[ + float | None, + Field( + description=( + "Optional. Return after this many seconds with the streams finished so far. " + "Unfinished streams are listed in `pending_streams`; pass them in another call " + "to resume. No limit by default." + ), + default=None, + ), + ] = None, ) -> dict[str, Any]: """Check a Connector Builder draft's progress and test-read its streams on Airbyte Cloud. @@ -3930,13 +3941,17 @@ def run_connector_builder_readiness_report( isn't `read_to_end` and the report doesn't pass. After publishing, run a full sync of every stream: `deploy_noop_destination_to_cloud`, deploy the source with `deploy_connector_to_cloud`, then `create_connection_on_cloud` and `run_cloud_sync`. + The verdict covers only the named streams; the full sync validates the whole connector. """ workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) project = workspace.get_connector_builder_project( builder_project_id, definition_id=definition_id, ) - report = project.run_readiness_report(resolve_list_of_strings(stream_names)) + report = project.run_readiness_report( + resolve_list_of_strings(stream_names), + timeout_seconds=timeout_seconds, + ) report_data = report.model_dump() report_data["streams"] = [ _redact_stream_diagnostics(stream) for stream in report_data["streams"] diff --git a/tests/unit_tests/fixtures/arg_trace_plan.json b/tests/unit_tests/fixtures/arg_trace_plan.json index f2bf5b8f3..59e84cd76 100644 --- a/tests/unit_tests/fixtures/arg_trace_plan.json +++ b/tests/unit_tests/fixtures/arg_trace_plan.json @@ -313,6 +313,7 @@ "builder_project_id": "hash", "definition_id": "hash", "stream_names": "fingerprint list", + "timeout_seconds": "hash", "workspace_id": "omit" }, "run_sql_query": { diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index d64a9dd3b..4f3b0c01a 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -7,6 +7,7 @@ from typing import Any from unittest.mock import MagicMock +import threading import pytest import requests @@ -294,11 +295,6 @@ def fake_update(**kwargs: Any) -> None: store["saved"].append(kwargs) monkeypatch.setattr(api_util, "get_connector_builder_project", fake_get) - monkeypatch.setattr( - api_util, - "full_resolve_connector_builder_project_manifest", - lambda **_: store.get("resolved") or store["draft"], - ) monkeypatch.setattr(api_util, "update_connector_builder_project", fake_update) monkeypatch.setattr(api_util, "validate_yaml_manifest", lambda *_, **__: None) return store @@ -591,7 +587,7 @@ def test_readiness_report_passes_only_when_every_stream_is_read_to_end( reads["orders"] = _stream_read("orders", record_count=1) full = project.run_readiness_report(["users", "orders", "users"]) - assert (partial.passed, partial.untested_streams) == (False, ["orders"]) + assert partial.passed is True assert capped.passed is False assert capped.passed_stream_count == 2 assert [s.read_to_end for s in capped.streams] == [True, False] @@ -628,8 +624,7 @@ def test_readiness_checklist_requires_testing_values_when_spec_has_inputs( True, None, ] - assert report.passed is False - assert report.checklist[2].detail == "users" + assert report.passed is True def test_publish_spec_uses_api_field_names() -> None: @@ -674,17 +669,27 @@ def test_publish_duplicate_name_leaves_draft_unchanged( assert store["saved"] == [] -def test_readiness_uses_resolved_stream_names(monkeypatch: pytest.MonkeyPatch) -> None: - store = _patch_project_store( - monkeypatch, draft={"streams": [{"$ref": "#/definitions/streams/users"}]} +def test_readiness_timeout_returns_pending_streams( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store( + monkeypatch, draft={"streams": [{"name": "fast"}, {"name": "slow"}]} ) - store["resolved"] = { - "streams": [ - {"name": "users"}, - {"name": "generated", "dynamic_stream_name": "tables"}, - ] - } project = CloudConnectorBuilderProject(_fake_workspace(), "bp") - _patch_reads(monkeypatch, project, {"users": _stream_read("users", record_count=1)}) - report = project.run_readiness_report(["users"]) - assert report.untested_streams == [] + release = threading.Event() + + def fake_read( + _manifest: Any, stream_name: str, **_: Any + ) -> ConnectorBuilderStreamReadResult: + if stream_name == "slow": + release.wait(5) + return _stream_read(stream_name, record_count=1) + + monkeypatch.setattr(project, "_test_read_stream", fake_read) + try: + report = project.run_readiness_report(["fast", "slow"], timeout_seconds=0.5) + finally: + release.set() + assert [stream.stream_name for stream in report.streams] == ["fast"] + assert report.pending_streams == ["slow"] + assert report.passed is False From d106ecd76c01cc5e7020238813ed98f28fbe4def Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:30:49 +0000 Subject: [PATCH 13/47] feat(cloud): split readiness results into passed/failed/pending/not-attempted with resume guidance Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 69 +++++++++++++++---- airbyte/mcp/cloud.py | 4 +- .../test_cloud_connector_builder.py | 41 +++++++---- 3 files changed, 84 insertions(+), 30 deletions(-) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index ba1ba1efa..d0c1c2898 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -323,20 +323,55 @@ class ConnectorBuilderReadinessReport(BaseModel): "named streams; a full sync validates the whole connector." ), ) - stream_count: int - passed_stream_count: int + passed_streams: list[str] = Field(default_factory=list) + failed_streams: list[str] = Field(default_factory=list) pending_streams: list[str] = Field( default_factory=list, - description=( - "Named streams that hadn't finished when `timeout_seconds` ran out. " - "Pass them in another call to resume." - ), + description="Streams still reading when `timeout_seconds` ran out, so aborted.", + ) + not_attempted_streams: list[str] = Field( + default_factory=list, + description="Streams not started before `timeout_seconds` ran out.", ) checklist: list[ConnectorBuilderChecklistItem] next_steps: list[str] streams: list[ConnectorBuilderStreamReadiness] +def _readiness_next_steps( + checklist: list[ConnectorBuilderChecklistItem], + streams: list[ConnectorBuilderStreamReadiness], + pending_streams: list[str], + not_attempted_streams: list[str], +) -> list[str]: + steps = [item.step for item in checklist[:4] if item.done is not True] + failed = [stream.stream_name for stream in streams if not stream.passed] + capped = [stream.stream_name for stream in streams if stream.passed and not stream.read_to_end] + if failed: + steps.append(f"Fix the failed streams and test them again: {', '.join(failed)}") + if pending_streams: + steps.append( + "These streams were aborted at the timeout. Retry them with a longer " + f"`timeout_seconds`, or check them with the full sync: {', '.join(pending_streams)}" + ) + if not_attempted_streams and not failed: + steps.append( + "Run the report again with the not-attempted streams, skipping any already " + f"validated or unchanged since: {', '.join(not_attempted_streams)}" + ) + if not (failed or pending_streams or not_attempted_streams): + steps.append( + "All named streams passed. Once every stream is validated, publish the custom " + "source definition (if not yet) and run a full sync of every stream to a no-op " + "destination." + ) + if capped: + steps.append( + f"These streams hit a test-read cap; check them with the full sync: {', '.join(capped)}" + ) + return steps + + def _readiness_checklist( project_data: dict[str, Any], draft: dict[str, Any], @@ -739,19 +774,27 @@ def read(stream_name: str) -> ConnectorBuilderStreamReadiness: finally: pool.shutdown(wait=False, cancel_futures=True) streams = [future.result() for future in futures if future in done] - pending_streams = [ - name for name, future in zip(stream_names, futures, strict=True) if future not in done + unfinished = [ + (name, future) + for name, future in zip(stream_names, futures, strict=True) + if future not in done ] + pending_streams = [name for name, future in unfinished if not future.cancelled()] + not_attempted_streams = [name for name, future in unfinished if future.cancelled()] + remaining = [name for name, _ in unfinished] - checklist = _readiness_checklist(project_data, draft, streams, pending_streams, self.url) + checklist = _readiness_checklist(project_data, draft, streams, remaining, self.url) return ConnectorBuilderReadinessReport( - passed=not pending_streams + passed=not remaining and all(stream.passed and stream.read_to_end for stream in streams), - stream_count=len(streams), - passed_stream_count=sum(stream.passed for stream in streams), + passed_streams=[stream.stream_name for stream in streams if stream.passed], + failed_streams=[stream.stream_name for stream in streams if not stream.passed], pending_streams=pending_streams, + not_attempted_streams=not_attempted_streams, checklist=checklist, - next_steps=[item.step for item in checklist if item.done is not True], + next_steps=_readiness_next_steps( + checklist, streams, pending_streams, not_attempted_streams + ), streams=streams, ) diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index afc8dd73e..5ca85435f 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -3924,8 +3924,8 @@ def run_connector_builder_readiness_report( Field( description=( "Optional. Return after this many seconds with the streams finished so far. " - "Unfinished streams are listed in `pending_streams`; pass them in another call " - "to resume. No limit by default." + "Streams still reading are aborted (`pending_streams`); streams not started are " + "listed in `not_attempted_streams` to pass in another call. No limit by default." ), default=None, ), diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 4f3b0c01a..745237a4a 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -541,7 +541,14 @@ def test_run_readiness_report_judges_each_stream( "dropped": False, } assert report.passed is False - assert (report.stream_count, report.passed_stream_count) == (7, 2) + assert report.passed_streams == ["ok", "warn_log"] + assert report.failed_streams == [ + "empty", + "http_error", + "error_log", + "broken", + "dropped", + ] by_name = {stream.stream_name: stream for stream in report.streams} assert by_name["http_error"].http_errors[0].status == 401 assert by_name["warn_log"].problem_logs == [{"level": "WARN", "message": "hmm"}] @@ -589,15 +596,13 @@ def test_readiness_report_passes_only_when_every_stream_is_read_to_end( assert partial.passed is True assert capped.passed is False - assert capped.passed_stream_count == 2 + assert capped.passed_streams == ["users", "orders"] + assert "hit a test-read cap" in capped.next_steps[-1] assert [s.read_to_end for s in capped.streams] == [True, False] assert capped.checklist[5].done is False assert full.passed is True - assert full.stream_count == 2 - assert full.next_steps == [ - "Publish the custom source definition", - "Run a full sync of every stream to a no-op destination", - ] + assert full.passed_streams == ["users", "orders"] + assert full.next_steps[0].startswith("All named streams passed.") def test_readiness_checklist_requires_testing_values_when_spec_has_inputs( @@ -669,27 +674,33 @@ def test_publish_duplicate_name_leaves_draft_unchanged( assert store["saved"] == [] -def test_readiness_timeout_returns_pending_streams( +def test_readiness_timeout_splits_pending_and_not_attempted( monkeypatch: pytest.MonkeyPatch, ) -> None: - _patch_project_store( - monkeypatch, draft={"streams": [{"name": "fast"}, {"name": "slow"}]} - ) + _patch_project_store(monkeypatch, draft=MANIFEST) project = CloudConnectorBuilderProject(_fake_workspace(), "bp") release = threading.Event() + slow = {f"slow{i}" for i in range(5)} def fake_read( _manifest: Any, stream_name: str, **_: Any ) -> ConnectorBuilderStreamReadResult: - if stream_name == "slow": + if stream_name in slow: release.wait(5) return _stream_read(stream_name, record_count=1) monkeypatch.setattr(project, "_test_read_stream", fake_read) + names = ["fast", *sorted(slow), "queued"] try: - report = project.run_readiness_report(["fast", "slow"], timeout_seconds=0.5) + report = project.run_readiness_report(names, timeout_seconds=0.5) finally: release.set() - assert [stream.stream_name for stream in report.streams] == ["fast"] - assert report.pending_streams == ["slow"] + assert report.passed_streams == ["fast"] + assert report.pending_streams == sorted(slow) + assert report.not_attempted_streams == ["queued"] assert report.passed is False + assert any( + "not-attempted streams" in step and "queued" in step + for step in report.next_steps + ) + assert any("aborted at the timeout" in step for step in report.next_steps) From d506f86a571d4cc04384a4ff3f81e882b3dfb67d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:32:58 +0000 Subject: [PATCH 14/47] refactor(mcp): rename readiness report tool to run_connector_builder_readiness_checks Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 4 ++-- airbyte/mcp/cloud.py | 4 ++-- tests/unit_tests/fixtures/arg_trace_plan.json | 2 +- .../test_cloud_connector_builder.py | 22 +++++++++---------- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index d0c1c2898..dfa53dc10 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -33,7 +33,7 @@ _MANIFEST_KEYS = frozenset({"streams", "definitions", "check"}) _YAML_LINE_WIDTH = 2**31 - 1 _READINESS_MAX_WORKERS = 5 -"""Most streams `run_readiness_report()` reads in one call.""" +"""Most streams `run_readiness_checks()` reads in one call.""" ManifestTextEditMode = Literal["replace_full", "replace_text", "replace_all"] """How `update_draft_manifest_text()` edits the manifest text. @@ -711,7 +711,7 @@ def resolve_manifest( ) return ConnectorBuilderResolvedManifest.from_manifest(resolved_manifest) - def run_readiness_report( + def run_readiness_checks( self, stream_names: list[str], *, diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 5ca85435f..3d82d8317 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -3889,7 +3889,7 @@ def resolve_connector_builder_manifest( @mcp_tool(read_only=True, idempotent=True, external_access=True) -def run_connector_builder_readiness_report( +def run_connector_builder_readiness_checks( ctx: Context, stream_names: Annotated[ str | list[str], @@ -3948,7 +3948,7 @@ def run_connector_builder_readiness_report( builder_project_id, definition_id=definition_id, ) - report = project.run_readiness_report( + report = project.run_readiness_checks( resolve_list_of_strings(stream_names), timeout_seconds=timeout_seconds, ) diff --git a/tests/unit_tests/fixtures/arg_trace_plan.json b/tests/unit_tests/fixtures/arg_trace_plan.json index 59e84cd76..fc7c3a615 100644 --- a/tests/unit_tests/fixtures/arg_trace_plan.json +++ b/tests/unit_tests/fixtures/arg_trace_plan.json @@ -309,7 +309,7 @@ "wait_timeout": "hash", "workspace_id": "omit" }, - "run_connector_builder_readiness_report": { + "run_connector_builder_readiness_checks": { "builder_project_id": "hash", "definition_id": "hash", "stream_names": "fingerprint list", diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 745237a4a..0dc0b2433 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -502,7 +502,7 @@ def fake_read( monkeypatch.setattr(project, "_test_read_stream", fake_read) -def test_run_readiness_report_judges_each_stream( +def test_run_readiness_checks_judges_each_stream( monkeypatch: pytest.MonkeyPatch, ) -> None: names = ["ok", "empty", "http_error", "error_log", "warn_log", "broken", "dropped"] @@ -528,7 +528,7 @@ def test_run_readiness_report_judges_each_stream( }, ) - report = project.run_readiness_report(names) + report = project.run_readiness_checks(names) assert [stream.stream_name for stream in report.streams] == names assert {stream.stream_name: stream.passed for stream in report.streams} == { @@ -562,18 +562,18 @@ def test_run_readiness_report_judges_each_stream( ) -def test_run_readiness_report_requires_stream_names() -> None: +def test_run_readiness_checks_requires_stream_names() -> None: project = CloudConnectorBuilderProject(_fake_workspace(), "bp") with pytest.raises(AirbyteLibInputError): - project.run_readiness_report([]) + project.run_readiness_checks([]) -def test_run_readiness_report_without_draft_fails( +def test_run_readiness_checks_without_draft_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: _patch_project_store(monkeypatch, draft=None) with pytest.raises(AirbyteLibInputError): - CloudConnectorBuilderProject(_fake_workspace(), "bp").run_readiness_report([ + CloudConnectorBuilderProject(_fake_workspace(), "bp").run_readiness_checks([ "users" ]) @@ -589,10 +589,10 @@ def test_readiness_report_passes_only_when_every_stream_is_read_to_end( } _patch_reads(monkeypatch, project, reads) - partial = project.run_readiness_report(["users"]) - capped = project.run_readiness_report(["users", "orders"]) + partial = project.run_readiness_checks(["users"]) + capped = project.run_readiness_checks(["users", "orders"]) reads["orders"] = _stream_read("orders", record_count=1) - full = project.run_readiness_report(["users", "orders", "users"]) + full = project.run_readiness_checks(["users", "orders", "users"]) assert partial.passed is True assert capped.passed is False @@ -617,7 +617,7 @@ def test_readiness_checklist_requires_testing_values_when_spec_has_inputs( project = CloudConnectorBuilderProject(_fake_workspace(), "bp") _patch_reads(monkeypatch, project, {"users": _stream_read("users", record_count=1)}) - report = project.run_readiness_report(["users"]) + report = project.run_readiness_checks(["users"]) assert [item.done for item in report.checklist] == [ True, @@ -692,7 +692,7 @@ def fake_read( monkeypatch.setattr(project, "_test_read_stream", fake_read) names = ["fast", *sorted(slow), "queued"] try: - report = project.run_readiness_report(names, timeout_seconds=0.5) + report = project.run_readiness_checks(names, timeout_seconds=0.5) finally: release.set() assert report.passed_streams == ["fast"] From c0b22660d1964537247a00f890b35d2bc204606f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:49:50 +0000 Subject: [PATCH 15/47] feat(mcp): add Connector Builder test source + full-sync opt-in; gate builder tools behind insiders; bound readiness read timeouts Co-Authored-By: AJ Steers --- airbyte/_util/api_util.py | 5 +- airbyte/cloud/connector_builder.py | 236 +++++++++++++++- airbyte/mcp/cloud.py | 103 +++++-- tests/unit_tests/fixtures/arg_trace_plan.json | 4 + .../test_cloud_connector_builder.py | 265 +++++++++++++++++- tests/unit_tests/test_mcp_tool_utils.py | 9 +- 6 files changed, 589 insertions(+), 33 deletions(-) diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index d013017fc..e6cd74a52 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -3226,10 +3226,12 @@ def read_connector_builder_project_stream( # noqa: PLR0913 client_secret: SecretString | None, bearer_token: SecretString | None, config_api_root: str | None = None, + timeout_seconds: float | None = None, ) -> dict[str, Any]: """Run a test read of one stream on Airbyte Cloud, using the project's testing values. - Calls `POST /v1/connector_builder_projects/read_stream`. + Calls `POST /v1/connector_builder_projects/read_stream`. `timeout_seconds` bounds the HTTP + request only; it doesn't cancel the read on the Cloud side. """ return _make_config_api_request( path="/connector_builder_projects/read_stream", @@ -3247,6 +3249,7 @@ def read_connector_builder_project_stream( # noqa: PLR0913 client_id=client_id, client_secret=client_secret, bearer_token=bearer_token, + timeout=None if timeout_seconds is None else (timeout_seconds, timeout_seconds), ) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index dfa53dc10..eb0ec8a48 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -9,6 +9,7 @@ from __future__ import annotations import difflib +import time from concurrent.futures import ThreadPoolExecutor, wait from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -23,7 +24,7 @@ if TYPE_CHECKING: - from airbyte.cloud.connectors import CustomCloudSourceDefinition + from airbyte.cloud.connectors import CloudSource, CustomCloudSourceDefinition from airbyte.cloud.workspaces import CloudWorkspace @@ -33,7 +34,15 @@ _MANIFEST_KEYS = frozenset({"streams", "definitions", "check"}) _YAML_LINE_WIDTH = 2**31 - 1 _READINESS_MAX_WORKERS = 5 -"""Most streams `run_readiness_checks()` reads in one call.""" +"""Most streams `run_readiness_checks()` reads at once.""" +_MIN_READ_TIMEOUT = 1.0 +"""Shortest HTTP timeout for a test read started just before the readiness deadline.""" +_SECRETS_MASK = "**********" +"""Placeholder Cloud returns instead of a stored secret value.""" +_FULL_SYNC_STREAM_LIMIT = 100 +"""Most streams per dynamic stream selected for the full sync (Cloud's resolve maximum).""" +CONNECTOR_BUILDER_TEST_DESTINATION_NAME = "Connector Builder Test (No-op)" +"""Name of the no-op destination that full syncs find or create by default.""" ManifestTextEditMode = Literal["replace_full", "replace_text", "replace_all"] """How `update_draft_manifest_text()` edits the manifest text. @@ -314,6 +323,16 @@ class ConnectorBuilderChecklistItem(BaseModel): detail: str | None = None +class ConnectorBuilderFullSync(BaseModel): + """A full sync started for a published Connector Builder project.""" + + source_id: str + destination_id: str + connection_id: str + job_id: int + job_url: str + + class ConnectorBuilderReadinessReport(BaseModel): """Checklist and per-stream test reads for a Connector Builder draft.""" @@ -327,7 +346,10 @@ class ConnectorBuilderReadinessReport(BaseModel): failed_streams: list[str] = Field(default_factory=list) pending_streams: list[str] = Field( default_factory=list, - description="Streams still reading when `timeout_seconds` ran out, so aborted.", + description=( + "Streams still reading when `timeout_seconds` ran out. Their results are dropped, " + "and Cloud may still finish those reads." + ), ) not_attempted_streams: list[str] = Field( default_factory=list, @@ -336,6 +358,10 @@ class ConnectorBuilderReadinessReport(BaseModel): checklist: list[ConnectorBuilderChecklistItem] next_steps: list[str] streams: list[ConnectorBuilderStreamReadiness] + full_sync: ConnectorBuilderFullSync | None = Field( + default=None, + description="The full sync started with `run_full_sync=True`, if any.", + ) def _readiness_next_steps( @@ -351,8 +377,9 @@ def _readiness_next_steps( steps.append(f"Fix the failed streams and test them again: {', '.join(failed)}") if pending_streams: steps.append( - "These streams were aborted at the timeout. Retry them with a longer " - f"`timeout_seconds`, or check them with the full sync: {', '.join(pending_streams)}" + "These streams were still reading at the timeout, so their results were dropped. " + "Retry them with a longer `timeout_seconds`, or check them with the full sync: " + f"{', '.join(pending_streams)}" ) if not_attempted_streams and not failed: steps.append( @@ -436,7 +463,31 @@ def _readiness_checklist( ] -class CloudConnectorBuilderProject: +def _drop_masked_secrets(value: Any) -> Any: # noqa: ANN401 + """Return a copy of `value` without the masked secret fields Cloud returns.""" + if isinstance(value, dict): + return { + key: _drop_masked_secrets(nested) + for key, nested in value.items() + if nested != _SECRETS_MASK + } + if isinstance(value, list): + return [_drop_masked_secrets(nested) for nested in value] + return value + + +def _spec_has_secrets(schema: Any) -> bool: # noqa: ANN401 + """Return whether a connection spec marks any field `airbyte_secret`.""" + if isinstance(schema, dict): + return schema.get("airbyte_secret") is True or any( + _spec_has_secrets(nested) for nested in schema.values() + ) + if isinstance(schema, list): + return any(_spec_has_secrets(nested) for nested in schema) + return False + + +class CloudConnectorBuilderProject: # noqa: PLR0904 # Builder project API facade. """A Connector Builder project in an Airbyte Cloud workspace.""" def __init__( @@ -669,6 +720,7 @@ def _test_read_stream( record_limit: int, page_limit: int, slice_limit: int, + timeout_seconds: float | None = None, ) -> ConnectorBuilderStreamReadResult: response = api_util.read_connector_builder_project_stream( workspace_id=self.owner_workspace_id, @@ -683,6 +735,7 @@ def _test_read_stream( client_secret=self.workspace.client_secret, bearer_token=self.workspace.bearer_token, config_api_root=self.workspace.config_api_root, + timeout_seconds=timeout_seconds, ) return ConnectorBuilderStreamReadResult.from_api_response(stream_name, response) @@ -719,6 +772,9 @@ def run_readiness_checks( page_limit: int = 20, slice_limit: int = 20, timeout_seconds: float | None = None, + run_full_sync: bool = False, + test_source_id: str | None = None, + test_destination_id: str | None = None, ) -> ConnectorBuilderReadinessReport: """Check the draft's progress and test-read the given streams on Airbyte Cloud. @@ -735,15 +791,37 @@ def run_readiness_checks( page_limit: Maximum pages to fetch per slice (1 to 20). slice_limit: Maximum slices to read (1 to 20). timeout_seconds: Return after this many seconds with the streams finished so far; - the rest are listed in `pending_streams`. No limit by default. + the rest are listed in `pending_streams` and `not_attempted_streams`. No limit + by default. + run_full_sync: If every named stream finished and passed (or none are named), + publish the draft and start a full sync with `run_full_sync()`. + test_source_id: The source for the full sync. See `run_full_sync()`. + test_destination_id: The destination for the full sync. See `run_full_sync()`. """ stream_names = list(dict.fromkeys(stream_names)) - if not stream_names: - raise exc.AirbyteLibInputError(message="Pass at least 1 stream name.") + if not stream_names and not run_full_sync: + raise exc.AirbyteLibInputError( + message="Pass at least 1 stream name, or set `run_full_sync`.", + ) project_data = self.get_project_data() draft = self._require_draft_manifest(project_data) + if not stream_names: + return self._readiness_report(project_data, draft, [], [], []).model_copy( + update={ + "full_sync": self.run_full_sync( + test_source_id=test_source_id, + test_destination_id=test_destination_id, + ), + }, + ) + + deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds def read(stream_name: str) -> ConnectorBuilderStreamReadiness: + # Bound each HTTP request by the deadline so worker threads end with the call. + request_timeout = ( + None if deadline is None else max(deadline - time.monotonic(), _MIN_READ_TIMEOUT) + ) try: result = self._test_read_stream( draft, @@ -751,6 +829,7 @@ def read(stream_name: str) -> ConnectorBuilderStreamReadiness: record_limit=record_limit, page_limit=page_limit, slice_limit=slice_limit, + timeout_seconds=request_timeout, ) except exc.AirbyteLibError as ex: error = ex.get_message() @@ -781,8 +860,27 @@ def read(stream_name: str) -> ConnectorBuilderStreamReadiness: ] pending_streams = [name for name, future in unfinished if not future.cancelled()] not_attempted_streams = [name for name, future in unfinished if future.cancelled()] - remaining = [name for name, _ in unfinished] + report = self._readiness_report( + project_data, draft, streams, pending_streams, not_attempted_streams + ) + if run_full_sync and not ( + report.failed_streams or report.pending_streams or report.not_attempted_streams + ): + report.full_sync = self.run_full_sync( + test_source_id=test_source_id, + test_destination_id=test_destination_id, + ) + return report + def _readiness_report( + self, + project_data: dict[str, Any], + draft: dict[str, Any], + streams: list[ConnectorBuilderStreamReadiness], + pending_streams: list[str], + not_attempted_streams: list[str], + ) -> ConnectorBuilderReadinessReport: + remaining = pending_streams + not_attempted_streams checklist = _readiness_checklist(project_data, draft, streams, remaining, self.url) return ConnectorBuilderReadinessReport( passed=not remaining @@ -838,13 +936,131 @@ def publish( definition_type="yaml", ) + @property + def test_source_name(self) -> str: + """Name of the source that `create_test_source()` creates for full syncs.""" + return f"{self.name} (Connector Builder test)" + + def find_test_source(self) -> CloudSource | None: + """Return the source made by `create_test_source()` for this project, if it exists.""" + source_definition_id = self.source_definition_id + if source_definition_id is None: + return None + return next( + ( + source + for source in self.workspace.list_sources(name=self.test_source_name) + if source.definition_id == source_definition_id + ), + None, + ) + + def create_test_source(self) -> CloudSource: + """Create a source on the published definition for full syncs. + + The source gets the project's non-secret testing values. When the spec has secret + fields, the source is a draft (deferred credentials): a person enters the secrets at + its `connector_url`, then tests and saves. Publish the project first. + """ + project_data = self.get_project_data() + source_definition_id = project_data["builderProject"].get("sourceDefinitionId") + if source_definition_id is None: + raise exc.AirbyteLibInputError( + message="Publish the Connector Builder project before creating a test source.", + ) + draft = self._require_draft_manifest(project_data) + spec = (draft.get("spec") or {}).get("connection_specification") + config = _drop_masked_secrets(project_data.get("testingValues") or {}) + return self.workspace.deploy_source( + name=self.test_source_name, + source=config, + definition_id=source_definition_id, + defer_credentials=_spec_has_secrets(spec), + ) + + def run_full_sync( + self, + *, + test_source_id: str | None = None, + test_destination_id: str | None = None, + pre_validate: bool = True, + ) -> ConnectorBuilderFullSync: + """Publish the draft and start a full sync of every stream to a no-op destination. + + Returns without waiting for the sync to finish. + + Args: + test_source_id: Source on the published definition to sync from. Defaults to the + source from `create_test_source()`. + test_destination_id: Destination to sync to. Defaults to the workspace's + `CONNECTOR_BUILDER_TEST_DESTINATION_NAME` destination, created as a no-op + destination if missing. + pre_validate: Whether to validate the manifest client-side before publishing. + """ + self.publish(self.name, pre_validate=pre_validate) + if test_source_id is None: + test_source = self.find_test_source() + if test_source is None: + raise exc.AirbyteLibInputError( + message="No test source found for this Connector Builder project.", + guidance=( + "Pass `test_source_id`, or create one with `create_test_source()` " + "and have the user complete its settings." + ), + ) + test_source_id = test_source.connector_id + if test_destination_id is None: + test_destination_id = self._get_or_create_test_destination() + + stream_names = self.resolve_manifest(stream_limit=_FULL_SYNC_STREAM_LIMIT).stream_names + connection_name = f"{self.name} (Connector Builder full sync)" + connection = next( + ( + connection + for connection in self.workspace.list_connections(name=connection_name) + if connection.source_id == test_source_id + and connection.destination_id == test_destination_id + ), + None, + ) + if connection is None: + connection = self.workspace.deploy_connection( + connection_name, + source=test_source_id, + destination=test_destination_id, + selected_streams=stream_names, + ) + else: + connection.set_selected_streams(stream_names) + sync_result = connection.run_sync(wait=False) + return ConnectorBuilderFullSync( + source_id=test_source_id, + destination_id=test_destination_id, + connection_id=connection.connection_id, + job_id=sync_result.job_id, + job_url=sync_result.job_url, + ) + + def _get_or_create_test_destination(self) -> str: + existing = self.workspace.list_destinations(name=CONNECTOR_BUILDER_TEST_DESTINATION_NAME) + if existing: + return existing[0].connector_id + from airbyte.destinations.util import get_noop_destination # noqa: PLC0415 + + return self.workspace.deploy_destination( + name=CONNECTOR_BUILDER_TEST_DESTINATION_NAME, + destination=get_noop_destination(), + ).connector_id + def __repr__(self) -> str: """Return a string representation of the project.""" return f"CloudConnectorBuilderProject(builder_project_id={self.builder_project_id!r})" __all__ = [ + "CONNECTOR_BUILDER_TEST_DESTINATION_NAME", "CloudConnectorBuilderProject", + "ConnectorBuilderFullSync", "ConnectorBuilderChecklistItem", "ConnectorBuilderPageSummary", "ConnectorBuilderReadinessReport", diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 3d82d8317..4a611e2aa 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -36,6 +36,7 @@ from airbyte._util import api_util from airbyte.cloud.client import _MAX_WORKSPACES_TO_VALIDATE, CloudClient from airbyte.cloud.connector_builder import ( + CONNECTOR_BUILDER_TEST_DESTINATION_NAME, CloudConnectorBuilderProject, ManifestTextEditMode, ) @@ -3535,7 +3536,7 @@ def get_connector_builder_draft_manifest( return project.get_info().model_dump() -@mcp_tool(extra_help_text=_manifest_yaml_source_hint) +@mcp_tool(extra_help_text=_manifest_yaml_source_hint, insiders_only=True) def create_connector_builder_project( ctx: Context, name: Annotated[ @@ -3579,8 +3580,20 @@ def create_connector_builder_project( default=True, ), ] = True, + create_test_source: Annotated[ + bool, + Field( + description=( + "Optional. Publish the initial manifest (which needs its spec) and create a " + "test source on it for a later full sync. Ask the user to enter their " + "credentials at both `connector_builder_project_url` (testing values) and " + "`test_source_settings_url` (then test and save) before you start building." + ), + default=False, + ), + ] = False, ) -> dict[str, Any]: - """Create an unpublished Connector Builder project to develop a custom YAML source. + """Create a Connector Builder project to develop a custom YAML source. The project holds the draft manifest and the testing values used for test reads. Nothing appears in the workspace's source list until the project is published with @@ -3597,15 +3610,26 @@ def create_connector_builder_project( pre_validate=pre_validate, ) register_guid_created_in_session(project.builder_project_id) - return { + result: dict[str, Any] = { "builder_project_id": project.builder_project_id, "definition_id": None, "connector_builder_project_url": project.url, "name": name, } + if create_test_source: + definition = project.publish(name, pre_validate=pre_validate) + register_guid_created_in_session(definition.definition_id) + test_source = project.create_test_source() + register_guid_created_in_session(test_source.connector_id) + result |= { + "definition_id": definition.definition_id, + "test_source_id": test_source.connector_id, + "test_source_settings_url": test_source.connector_url, + } + return result -@mcp_tool(destructive=True) +@mcp_tool(destructive=True, insiders_only=True) def update_connector_builder_manifest_text( ctx: Context, edit_mode: Annotated[ @@ -3687,7 +3711,7 @@ def update_connector_builder_manifest_text( ) -@mcp_tool(destructive=True, idempotent=True) +@mcp_tool(destructive=True, idempotent=True, insiders_only=True) def set_connector_builder_testing_values( ctx: Context, *, @@ -3773,7 +3797,7 @@ def _redact_stream_diagnostics(stream_data: dict[str, Any]) -> dict[str, Any]: return stream_data -@mcp_tool(read_only=True, external_access=True) +@mcp_tool(read_only=True, external_access=True, insiders_only=True) def test_connector_builder_stream( ctx: Context, stream_name: Annotated[ @@ -3839,7 +3863,7 @@ def test_connector_builder_stream( } -@mcp_tool(read_only=True, idempotent=True, external_access=True) +@mcp_tool(read_only=True, idempotent=True, external_access=True, insiders_only=True) def resolve_connector_builder_manifest( ctx: Context, *, @@ -3888,20 +3912,21 @@ def resolve_connector_builder_manifest( } -@mcp_tool(read_only=True, idempotent=True, external_access=True) -def run_connector_builder_readiness_checks( +@mcp_tool(external_access=True, insiders_only=True) +def run_connector_builder_readiness_checks( # noqa: PLR0913 # Explicit args mirror MCP tool schema. ctx: Context, stream_names: Annotated[ - str | list[str], + str | list[str] | None, Field( description=( - "Streams to test-read (at least 1). The report covers only these streams. " - "For dynamic streams, " - "get the generated names from `resolve_connector_builder_manifest`." + "Streams to test-read. The report covers only these streams. Required unless " + "`run_full_sync` is set. For dynamic streams, get the generated names from " + "`resolve_connector_builder_manifest`." ), + default=None, ), TraceArg.FINGERPRINT, - ], + ] = None, *, builder_project_id: Annotated[ str | None, @@ -3924,12 +3949,44 @@ def run_connector_builder_readiness_checks( Field( description=( "Optional. Return after this many seconds with the streams finished so far. " - "Streams still reading are aborted (`pending_streams`); streams not started are " + "Streams still reading are dropped (`pending_streams`); streams not started are " "listed in `not_attempted_streams` to pass in another call. No limit by default." ), default=None, ), ] = None, + run_full_sync: Annotated[ + bool, + Field( + description=( + "Optional. If every named stream passed (or none are named), publish the draft " + "and start a full sync of every stream to a no-op destination. Returns the job " + "in `full_sync`; poll it with `get_cloud_sync_status`." + ), + default=False, + ), + ] = False, + test_source_id: Annotated[ + str | None, + Field( + description=( + "Optional. Source on the published definition for the full sync. Defaults to " + "the test source from `create_connector_builder_project(create_test_source=True)`." + ), + default=None, + ), + ] = None, + test_destination_id: Annotated[ + str | None, + Field( + description=( + "Optional. Destination for the full sync. Defaults to the workspace's " + f"'{CONNECTOR_BUILDER_TEST_DESTINATION_NAME}' destination, created as a no-op " + "destination if missing." + ), + default=None, + ), + ] = None, ) -> dict[str, Any]: """Check a Connector Builder draft's progress and test-read its streams on Airbyte Cloud. @@ -3938,20 +3995,26 @@ def run_connector_builder_readiness_checks( `read_to_end`, record and page counts, HTTP errors, warning and error logs, and inferred schemas. A stream passes when it returns records with no HTTP errors or error logs. Cloud test reads stop at 20 pages per slice and 20 slices, so a stream that hits a cap - isn't `read_to_end` and the report doesn't pass. After publishing, run a full sync of - every stream: `deploy_noop_destination_to_cloud`, deploy the source with - `deploy_connector_to_cloud`, then `create_connection_on_cloud` and `run_cloud_sync`. - The verdict covers only the named streams; the full sync validates the whole connector. + isn't `read_to_end` and the report doesn't pass. The verdict covers only the named + streams; a full sync validates the whole connector. With `run_full_sync`, the tool + publishes the draft and starts that sync once the named streams pass. """ workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) project = workspace.get_connector_builder_project( builder_project_id, definition_id=definition_id, ) + if run_full_sync: + _check_builder_project_created_in_session(project) report = project.run_readiness_checks( - resolve_list_of_strings(stream_names), + resolve_list_of_strings(stream_names) or [], timeout_seconds=timeout_seconds, + run_full_sync=run_full_sync, + test_source_id=test_source_id, + test_destination_id=test_destination_id, ) + if report.full_sync is not None: + register_guid_created_in_session(report.full_sync.connection_id) report_data = report.model_dump() report_data["streams"] = [ _redact_stream_diagnostics(stream) for stream in report_data["streams"] diff --git a/tests/unit_tests/fixtures/arg_trace_plan.json b/tests/unit_tests/fixtures/arg_trace_plan.json index fc7c3a615..e941c9e48 100644 --- a/tests/unit_tests/fixtures/arg_trace_plan.json +++ b/tests/unit_tests/fixtures/arg_trace_plan.json @@ -18,6 +18,7 @@ "workspace_id": "omit" }, "create_connector_builder_project": { + "create_test_source": "value allowed=[false,true]", "manifest_yaml_file": "hash", "manifest_yaml_text": "hash", "name": "fingerprint", @@ -312,7 +313,10 @@ "run_connector_builder_readiness_checks": { "builder_project_id": "hash", "definition_id": "hash", + "run_full_sync": "value allowed=[false,true]", "stream_names": "fingerprint list", + "test_destination_id": "hash", + "test_source_id": "hash", "timeout_seconds": "hash", "workspace_id": "omit" }, diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 0dc0b2433..04c263973 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -703,4 +703,267 @@ def fake_read( "not-attempted streams" in step and "queued" in step for step in report.next_steps ) - assert any("aborted at the timeout" in step for step in report.next_steps) + assert any("still reading at the timeout" in step for step in report.next_steps) + + +def test_readiness_bounds_read_requests_by_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store(monkeypatch, draft=MANIFEST) + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + timeouts: list[float | None] = [] + + def fake_read( + _manifest: Any, stream_name: str, **kwargs: Any + ) -> ConnectorBuilderStreamReadResult: + timeouts.append(kwargs["timeout_seconds"]) + return _stream_read(stream_name, record_count=1) + + monkeypatch.setattr(project, "_test_read_stream", fake_read) + project.run_readiness_checks(["users"], timeout_seconds=30) + project.run_readiness_checks(["users"]) + + assert timeouts[0] is not None and 0 < timeouts[0] <= 30 + assert timeouts[1] is None + + +def test_drop_masked_secrets() -> None: + values = { + "api_key": "**********", + "region": "us", + "auth": {"token": "**********", "kind": "bearer"}, + "items": [{"secret": "**********", "id": 1}], + } + + assert connector_builder._drop_masked_secrets(values) == { + "region": "us", + "auth": {"kind": "bearer"}, + "items": [{"id": 1}], + } + + +@pytest.mark.parametrize( + ("spec", "expected"), + [ + pytest.param({"type": "object", "properties": {}}, False, id="no-inputs"), + pytest.param( + {"properties": {"region": {"type": "string"}}}, False, id="no-secrets" + ), + pytest.param( + { + "properties": { + "auth": { + "oneOf": [{"properties": {"key": {"airbyte_secret": True}}}] + } + } + }, + True, + id="nested-secret", + ), + ], +) +def test_spec_has_secrets(spec: dict[str, Any], expected: bool) -> None: + assert connector_builder._spec_has_secrets(spec) is expected + + +def _secret_manifest() -> dict[str, Any]: + return { + **MANIFEST, + "spec": { + "connection_specification": { + "type": "object", + "properties": { + "api_key": {"type": "string", "airbyte_secret": True}, + "region": {"type": "string"}, + }, + }, + }, + } + + +@pytest.mark.parametrize( + ("manifest", "deferred"), + [ + pytest.param(_secret_manifest(), True, id="secret-spec-deferred"), + pytest.param(MANIFEST, False, id="no-secret-spec-ready"), + ], +) +def test_create_test_source_uses_non_secret_testing_values( + monkeypatch: pytest.MonkeyPatch, + manifest: dict[str, Any], + deferred: bool, +) -> None: + _patch_project_store( + monkeypatch, + draft=manifest, + source_definition_id="def", + testing_values={"api_key": "**********", "region": "us"}, + ) + workspace = _fake_workspace() + workspace.deploy_source = MagicMock() + project = CloudConnectorBuilderProject(workspace, "bp") + + project.create_test_source() + + workspace.deploy_source.assert_called_once_with( + name="My API (Connector Builder test)", + source={"region": "us"}, + definition_id="def", + defer_credentials=deferred, + ) + + +def test_create_test_source_requires_publish(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_project_store(monkeypatch, draft=MANIFEST) + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + + with pytest.raises(AirbyteLibInputError, match="Publish"): + project.create_test_source() + + +def _full_sync_project( + monkeypatch: pytest.MonkeyPatch, + *, + destinations: list[Any], + connections: list[Any], + sources: list[Any] | None = None, +) -> tuple[CloudConnectorBuilderProject, Any]: + _patch_project_store(monkeypatch, draft=MANIFEST, source_definition_id="def") + workspace = _fake_workspace() + workspace.list_sources = MagicMock(return_value=sources or []) + workspace.list_destinations = MagicMock(return_value=destinations) + workspace.list_connections = MagicMock(return_value=connections) + workspace.deploy_destination = MagicMock( + return_value=SimpleNamespace(connector_id="new-dest") + ) + new_connection = MagicMock(connection_id="conn") + new_connection.run_sync.return_value = SimpleNamespace( + job_id=7, job_url="https://job" + ) + workspace.deploy_connection = MagicMock(return_value=new_connection) + project = CloudConnectorBuilderProject(workspace, "bp") + monkeypatch.setattr(project, "publish", MagicMock()) + monkeypatch.setattr( + project, + "resolve_manifest", + MagicMock(return_value=SimpleNamespace(stream_names=["users", "orders"])), + ) + return project, workspace + + +def test_run_full_sync_creates_noop_destination_and_connection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + project, workspace = _full_sync_project( + monkeypatch, + destinations=[], + connections=[], + sources=[ + SimpleNamespace(connector_id="other", definition_id="other-def"), + SimpleNamespace(connector_id="src", definition_id="def"), + ], + ) + + result = project.run_full_sync() + + project.publish.assert_called_once_with("My API", pre_validate=True) + workspace.list_sources.assert_called_once_with( + name="My API (Connector Builder test)" + ) + assert workspace.deploy_destination.call_args.kwargs["name"] == ( + connector_builder.CONNECTOR_BUILDER_TEST_DESTINATION_NAME + ) + workspace.deploy_connection.assert_called_once_with( + "My API (Connector Builder full sync)", + source="src", + destination="new-dest", + selected_streams=["users", "orders"], + ) + assert result.model_dump() == { + "source_id": "src", + "destination_id": "new-dest", + "connection_id": "conn", + "job_id": 7, + "job_url": "https://job", + } + + +def test_run_full_sync_reuses_destination_and_connection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + existing = MagicMock(connection_id="old", source_id="src", destination_id="dest") + existing.run_sync.return_value = SimpleNamespace(job_id=8, job_url="https://job8") + project, workspace = _full_sync_project( + monkeypatch, + destinations=[SimpleNamespace(connector_id="dest")], + connections=[existing], + ) + + result = project.run_full_sync(test_source_id="src") + + workspace.deploy_destination.assert_not_called() + workspace.deploy_connection.assert_not_called() + existing.set_selected_streams.assert_called_once_with(["users", "orders"]) + existing.run_sync.assert_called_once_with(wait=False) + assert (result.connection_id, result.job_id) == ("old", 8) + + +def test_run_full_sync_without_test_source_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + project, _ = _full_sync_project(monkeypatch, destinations=[], connections=[]) + + with pytest.raises(AirbyteLibInputError, match="No test source"): + project.run_full_sync() + + +@pytest.mark.parametrize( + ("stream_names", "record_count", "expect_sync"), + [ + pytest.param([], 0, True, id="no-streams"), + pytest.param(["users"], 1, True, id="all-passed"), + pytest.param(["users"], 0, False, id="failed-skips-sync"), + ], +) +def test_readiness_full_sync_gating( + monkeypatch: pytest.MonkeyPatch, + stream_names: list[str], + record_count: int, + expect_sync: bool, +) -> None: + _patch_project_store(monkeypatch, draft=MANIFEST) + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + full_sync = connector_builder.ConnectorBuilderFullSync( + source_id="src", + destination_id="dest", + connection_id="conn", + job_id=1, + job_url="u", + ) + run_full_sync = MagicMock(return_value=full_sync) + monkeypatch.setattr(project, "run_full_sync", run_full_sync) + monkeypatch.setattr( + project, + "_test_read_stream", + lambda _m, name, **_: _stream_read(name, record_count=record_count), + ) + + report = project.run_readiness_checks( + stream_names, run_full_sync=True, test_source_id="src" + ) + + assert (report.full_sync is not None) is expect_sync + if expect_sync: + run_full_sync.assert_called_once_with( + test_source_id="src", test_destination_id=None + ) + + +def test_readiness_requires_streams_without_full_sync( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store(monkeypatch, draft=MANIFEST) + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + + with pytest.raises(AirbyteLibInputError, match="run_full_sync"): + project.run_readiness_checks([]) diff --git a/tests/unit_tests/test_mcp_tool_utils.py b/tests/unit_tests/test_mcp_tool_utils.py index 6970e6591..dc2eca715 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -295,7 +295,14 @@ def test_server_tools_register_expected_policies() -> None: insiders_tools = { name for name, policy in _TOOL_POLICIES.items() if policy.insiders_only } - assert insiders_tools == set() + assert insiders_tools == { + "create_connector_builder_project", + "update_connector_builder_manifest_text", + "set_connector_builder_testing_values", + "test_connector_builder_stream", + "resolve_connector_builder_manifest", + "run_connector_builder_readiness_checks", + } @pytest.mark.parametrize( From 71ca7ab1b70140750bfdfe70e625ca2c102496e1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:00:30 +0000 Subject: [PATCH 16/47] fix(cloud): resolve before first publish clears the draft; validate test source; reject capped dynamic streams; register only created connections Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 60 ++++++++- airbyte/mcp/cloud.py | 2 +- .../test_cloud_connector_builder.py | 115 +++++++++++++++++- 3 files changed, 167 insertions(+), 10 deletions(-) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index eb0ec8a48..8b40065ac 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -10,6 +10,7 @@ import difflib import time +from collections import Counter from concurrent.futures import ThreadPoolExecutor, wait from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -329,6 +330,7 @@ class ConnectorBuilderFullSync(BaseModel): source_id: str destination_id: str connection_id: str + connection_created: bool job_id: int job_url: str @@ -476,6 +478,16 @@ def _drop_masked_secrets(value: Any) -> Any: # noqa: ANN401 return value +def _capped_dynamic_streams(resolved_manifest: dict[str, Any], stream_limit: int) -> list[str]: + """Return the dynamic streams whose generated streams reached `stream_limit` when resolved.""" + counts = Counter( + stream.get("dynamic_stream_name") + for stream in resolved_manifest.get("streams") or [] + if isinstance(stream, dict) and stream.get("dynamic_stream_name") + ) + return sorted(name for name, count in counts.items() if count >= stream_limit) + + def _spec_has_secrets(schema: Any) -> bool: # noqa: ANN401 """Return whether a connection spec marks any field `airbyte_secret`.""" if isinstance(schema, dict): @@ -743,6 +755,7 @@ def resolve_manifest( self, *, stream_limit: int = 5, + manifest: dict[str, Any] | None = None, ) -> ConnectorBuilderResolvedManifest: """Resolve the draft manifest on Airbyte Cloud, including dynamic streams. @@ -750,11 +763,12 @@ def resolve_manifest( Args: stream_limit: Maximum generated streams to return per dynamic stream (1 to 100). + manifest: Manifest to resolve instead of the draft. """ resolved_manifest = api_util.full_resolve_connector_builder_project_manifest( workspace_id=self.owner_workspace_id, builder_project_id=self.builder_project_id, - manifest=self._require_draft_manifest(), + manifest=manifest if manifest is not None else self._require_draft_manifest(), stream_limit=stream_limit, api_root=self.workspace.api_root, client_id=self.workspace.client_id, @@ -968,8 +982,11 @@ def create_test_source(self) -> CloudSource: raise exc.AirbyteLibInputError( message="Publish the Connector Builder project before creating a test source.", ) - draft = self._require_draft_manifest(project_data) - spec = (draft.get("spec") or {}).get("connection_specification") + # The first publish clears the draft, so fall back to the published manifest. + manifest = _draft_from_project_data(project_data) or self._published_manifest( + source_definition_id + ) + spec = ((manifest or {}).get("spec") or {}).get("connection_specification") config = _drop_masked_secrets(project_data.get("testingValues") or {}) return self.workspace.deploy_source( name=self.test_source_name, @@ -987,7 +1004,9 @@ def run_full_sync( ) -> ConnectorBuilderFullSync: """Publish the draft and start a full sync of every stream to a no-op destination. - Returns without waiting for the sync to finish. + Without a draft, syncs the published version. Fails if a dynamic stream generates more + streams than resolve returns (100), since the sync couldn't select them all. Returns + without waiting for the sync to finish. Args: test_source_id: Source on the published definition to sync from. Defaults to the @@ -997,7 +1016,29 @@ def run_full_sync( destination if missing. pre_validate: Whether to validate the manifest client-side before publishing. """ - self.publish(self.name, pre_validate=pre_validate) + project_data = self.get_project_data() + manifest = _draft_from_project_data(project_data) + if manifest is None: + manifest = self._published_manifest( + project_data["builderProject"].get("sourceDefinitionId") + ) + if manifest is None: + self._require_draft_manifest(project_data) + # Resolve before publishing: the first publish clears the draft. + resolved = self.resolve_manifest(stream_limit=_FULL_SYNC_STREAM_LIMIT, manifest=manifest) + capped = _capped_dynamic_streams(resolved.manifest, _FULL_SYNC_STREAM_LIMIT) + if capped: + raise exc.AirbyteLibInputError( + message=( + f"Dynamic streams generate {_FULL_SYNC_STREAM_LIMIT} or more streams, " + "more than a full sync can select." + ), + guidance="Sync the published source with `run_cloud_sync` instead.", + context={"dynamic_streams": capped}, + ) + if _draft_from_project_data(project_data) is not None: + self.publish(self.name, pre_validate=pre_validate) + source_definition_id = self.source_definition_id if test_source_id is None: test_source = self.find_test_source() if test_source is None: @@ -1009,10 +1050,15 @@ def run_full_sync( ), ) test_source_id = test_source.connector_id + elif self.workspace.get_source(test_source_id).definition_id != source_definition_id: + raise exc.AirbyteLibInputError( + message="`test_source_id` isn't a source of this project's published definition.", + context={"test_source_id": test_source_id, "definition_id": source_definition_id}, + ) if test_destination_id is None: test_destination_id = self._get_or_create_test_destination() - stream_names = self.resolve_manifest(stream_limit=_FULL_SYNC_STREAM_LIMIT).stream_names + stream_names = resolved.stream_names connection_name = f"{self.name} (Connector Builder full sync)" connection = next( ( @@ -1023,6 +1069,7 @@ def run_full_sync( ), None, ) + connection_created = connection is None if connection is None: connection = self.workspace.deploy_connection( connection_name, @@ -1037,6 +1084,7 @@ def run_full_sync( source_id=test_source_id, destination_id=test_destination_id, connection_id=connection.connection_id, + connection_created=connection_created, job_id=sync_result.job_id, job_url=sync_result.job_url, ) diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 4a611e2aa..c7cb8721b 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -4013,7 +4013,7 @@ def run_connector_builder_readiness_checks( # noqa: PLR0913 # Explicit args mi test_source_id=test_source_id, test_destination_id=test_destination_id, ) - if report.full_sync is not None: + if report.full_sync is not None and report.full_sync.connection_created: register_guid_created_in_session(report.full_sync.connection_id) report_data = report.model_dump() report_data["streams"] = [ diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 04c263973..1c7aee7e4 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -827,9 +827,12 @@ def _full_sync_project( destinations: list[Any], connections: list[Any], sources: list[Any] | None = None, + draft: dict[str, Any] | None = MANIFEST, + resolved_streams: list[dict[str, Any]] | None = None, ) -> tuple[CloudConnectorBuilderProject, Any]: - _patch_project_store(monkeypatch, draft=MANIFEST, source_definition_id="def") + _patch_project_store(monkeypatch, draft=draft, source_definition_id="def") workspace = _fake_workspace() + workspace.get_source = MagicMock(return_value=SimpleNamespace(definition_id="def")) workspace.list_sources = MagicMock(return_value=sources or []) workspace.list_destinations = MagicMock(return_value=destinations) workspace.list_connections = MagicMock(return_value=connections) @@ -846,8 +849,14 @@ def _full_sync_project( monkeypatch.setattr( project, "resolve_manifest", - MagicMock(return_value=SimpleNamespace(stream_names=["users", "orders"])), + MagicMock( + return_value=SimpleNamespace( + stream_names=["users", "orders"], + manifest={"streams": resolved_streams or []}, + ) + ), ) + monkeypatch.setattr(project, "_published_manifest", lambda _id: MANIFEST) return project, workspace @@ -883,6 +892,7 @@ def test_run_full_sync_creates_noop_destination_and_connection( "source_id": "src", "destination_id": "new-dest", "connection_id": "conn", + "connection_created": True, "job_id": 7, "job_url": "https://job", } @@ -905,7 +915,12 @@ def test_run_full_sync_reuses_destination_and_connection( workspace.deploy_connection.assert_not_called() existing.set_selected_streams.assert_called_once_with(["users", "orders"]) existing.run_sync.assert_called_once_with(wait=False) - assert (result.connection_id, result.job_id) == ("old", 8) + workspace.get_source.assert_called_once_with("src") + assert (result.connection_id, result.connection_created, result.job_id) == ( + "old", + False, + 8, + ) def test_run_full_sync_without_test_source_fails( @@ -937,6 +952,7 @@ def test_readiness_full_sync_gating( source_id="src", destination_id="dest", connection_id="conn", + connection_created=True, job_id=1, job_url="u", ) @@ -967,3 +983,96 @@ def test_readiness_requires_streams_without_full_sync( with pytest.raises(AirbyteLibInputError, match="run_full_sync"): project.run_readiness_checks([]) + + +def test_run_full_sync_resolves_draft_before_publish( + monkeypatch: pytest.MonkeyPatch, +) -> None: + project, _ = _full_sync_project( + monkeypatch, + destinations=[SimpleNamespace(connector_id="dest")], + connections=[], + ) + calls: list[str] = [] + project.resolve_manifest.side_effect = lambda **kwargs: ( + calls.append("resolve"), + SimpleNamespace(stream_names=["users"], manifest={"streams": []}), + )[1] + project.publish.side_effect = lambda *_, **__: calls.append("publish") + + project.run_full_sync(test_source_id="src") + + assert calls == ["resolve", "publish"] + assert project.resolve_manifest.call_args.kwargs["manifest"] == MANIFEST + + +def test_run_full_sync_without_draft_syncs_published_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + project, workspace = _full_sync_project( + monkeypatch, + destinations=[SimpleNamespace(connector_id="dest")], + connections=[], + draft=None, + ) + + project.run_full_sync(test_source_id="src") + + project.publish.assert_not_called() + assert project.resolve_manifest.call_args.kwargs["manifest"] == MANIFEST + workspace.deploy_connection.assert_called_once() + + +def test_run_full_sync_rejects_source_of_other_definition( + monkeypatch: pytest.MonkeyPatch, +) -> None: + project, workspace = _full_sync_project( + monkeypatch, destinations=[], connections=[] + ) + workspace.get_source.return_value = SimpleNamespace(definition_id="other-def") + + with pytest.raises(AirbyteLibInputError, match="published definition"): + project.run_full_sync(test_source_id="src") + + workspace.deploy_connection.assert_not_called() + + +def test_run_full_sync_rejects_capped_dynamic_streams( + monkeypatch: pytest.MonkeyPatch, +) -> None: + limit = connector_builder._FULL_SYNC_STREAM_LIMIT + project, workspace = _full_sync_project( + monkeypatch, + destinations=[], + connections=[], + resolved_streams=[ + {"name": "users", "dynamic_stream_name": None}, + *({"name": f"t{i}", "dynamic_stream_name": "tables"} for i in range(limit)), + ], + ) + + with pytest.raises(AirbyteLibInputError, match="Dynamic streams") as excinfo: + project.run_full_sync(test_source_id="src") + + assert excinfo.value.context == {"dynamic_streams": ["tables"]} + project.publish.assert_not_called() + workspace.deploy_connection.assert_not_called() + + +def test_create_test_source_after_publish_cleared_draft( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store( + monkeypatch, + draft=None, + source_definition_id="def", + testing_values={"api_key": "**********", "region": "us"}, + ) + workspace = _fake_workspace() + workspace.deploy_source = MagicMock() + project = CloudConnectorBuilderProject(workspace, "bp") + monkeypatch.setattr(project, "_published_manifest", lambda _id: _secret_manifest()) + + project.create_test_source() + + assert workspace.deploy_source.call_args.kwargs["defer_credentials"] is True From 273eaf0c8e419d5de6a9305ba6a1763893716980 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:00:59 +0000 Subject: [PATCH 17/47] fix(cloud): type capped dynamic stream counter Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index 8b40065ac..6b953f69f 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -480,10 +480,10 @@ def _drop_masked_secrets(value: Any) -> Any: # noqa: ANN401 def _capped_dynamic_streams(resolved_manifest: dict[str, Any], stream_limit: int) -> list[str]: """Return the dynamic streams whose generated streams reached `stream_limit` when resolved.""" - counts = Counter( - stream.get("dynamic_stream_name") + counts: Counter[str] = Counter( + stream["dynamic_stream_name"] for stream in resolved_manifest.get("streams") or [] - if isinstance(stream, dict) and stream.get("dynamic_stream_name") + if isinstance(stream, dict) and isinstance(stream.get("dynamic_stream_name"), str) ) return sorted(name for name, count in counts.items() if count >= stream_limit) From cfac686fb03f5a29ebd4efc539bf93c1c2d2ed74 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:10:46 +0000 Subject: [PATCH 18/47] fix(cloud): defer test source on missing required fields; full-sync-only checks on published manifest; register created test destination Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 41 +++++++++++++++---- airbyte/mcp/cloud.py | 7 +++- .../test_cloud_connector_builder.py | 39 ++++++++++++++++++ 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index 6b953f69f..a5ecc731e 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -329,6 +329,7 @@ class ConnectorBuilderFullSync(BaseModel): source_id: str destination_id: str + destination_created: bool connection_id: str connection_created: bool job_id: int @@ -415,7 +416,10 @@ def _readiness_checklist( failed = [stream.stream_name for stream in streams if not stream.passed] not_read_to_end = [stream.stream_name for stream in streams if not stream.read_to_end] return [ - ConnectorBuilderChecklistItem(step="Save a draft manifest", done=True), + ConnectorBuilderChecklistItem( + step="Save a draft manifest", + done=bool(project_data["builderProject"].get("hasDraft")), + ), ConnectorBuilderChecklistItem( step="Define the connection spec (user inputs)", done=isinstance(spec, dict), @@ -488,6 +492,17 @@ def _capped_dynamic_streams(resolved_manifest: dict[str, Any], stream_limit: int return sorted(name for name, count in counts.items() if count >= stream_limit) +def _spec_missing_required(schema: Any, config: dict[str, Any]) -> bool: # noqa: ANN401 + """Return whether `config` lacks a required top-level field that has no default.""" + if not isinstance(schema, dict): + return False + properties = schema.get("properties") or {} + return any( + name not in config and "default" not in (properties.get(name) or {}) + for name in schema.get("required") or [] + ) + + def _spec_has_secrets(schema: Any) -> bool: # noqa: ANN401 """Return whether a connection spec marks any field `airbyte_secret`.""" if isinstance(schema, dict): @@ -818,9 +833,15 @@ def run_readiness_checks( message="Pass at least 1 stream name, or set `run_full_sync`.", ) project_data = self.get_project_data() - draft = self._require_draft_manifest(project_data) if not stream_names: - return self._readiness_report(project_data, draft, [], [], []).model_copy( + # A full sync alone can run on the published manifest; the first publish clears + # the draft. + manifest = _draft_from_project_data(project_data) or self._published_manifest( + project_data["builderProject"].get("sourceDefinitionId") + ) + if manifest is None: + manifest = self._require_draft_manifest(project_data) + return self._readiness_report(project_data, manifest, [], [], []).model_copy( update={ "full_sync": self.run_full_sync( test_source_id=test_source_id, @@ -829,6 +850,7 @@ def run_readiness_checks( }, ) + draft = self._require_draft_manifest(project_data) deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds def read(stream_name: str) -> ConnectorBuilderStreamReadiness: @@ -992,7 +1014,7 @@ def create_test_source(self) -> CloudSource: name=self.test_source_name, source=config, definition_id=source_definition_id, - defer_credentials=_spec_has_secrets(spec), + defer_credentials=_spec_has_secrets(spec) or _spec_missing_required(spec, config), ) def run_full_sync( @@ -1055,8 +1077,9 @@ def run_full_sync( message="`test_source_id` isn't a source of this project's published definition.", context={"test_source_id": test_source_id, "definition_id": source_definition_id}, ) + destination_created = False if test_destination_id is None: - test_destination_id = self._get_or_create_test_destination() + test_destination_id, destination_created = self._get_or_create_test_destination() stream_names = resolved.stream_names connection_name = f"{self.name} (Connector Builder full sync)" @@ -1083,22 +1106,24 @@ def run_full_sync( return ConnectorBuilderFullSync( source_id=test_source_id, destination_id=test_destination_id, + destination_created=destination_created, connection_id=connection.connection_id, connection_created=connection_created, job_id=sync_result.job_id, job_url=sync_result.job_url, ) - def _get_or_create_test_destination(self) -> str: + def _get_or_create_test_destination(self) -> tuple[str, bool]: + """Return the test destination's ID and whether this call created it.""" existing = self.workspace.list_destinations(name=CONNECTOR_BUILDER_TEST_DESTINATION_NAME) if existing: - return existing[0].connector_id + return existing[0].connector_id, False from airbyte.destinations.util import get_noop_destination # noqa: PLC0415 return self.workspace.deploy_destination( name=CONNECTOR_BUILDER_TEST_DESTINATION_NAME, destination=get_noop_destination(), - ).connector_id + ).connector_id, True def __repr__(self) -> str: """Return a string representation of the project.""" diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index c7cb8721b..4dfa87dbc 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -4013,8 +4013,11 @@ def run_connector_builder_readiness_checks( # noqa: PLR0913 # Explicit args mi test_source_id=test_source_id, test_destination_id=test_destination_id, ) - if report.full_sync is not None and report.full_sync.connection_created: - register_guid_created_in_session(report.full_sync.connection_id) + if report.full_sync is not None: + if report.full_sync.destination_created: + register_guid_created_in_session(report.full_sync.destination_id) + if report.full_sync.connection_created: + register_guid_created_in_session(report.full_sync.connection_id) report_data = report.model_dump() report_data["streams"] = [ _redact_stream_diagnostics(stream) for stream in report_data["streams"] diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 1c7aee7e4..c67130cc8 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -786,6 +786,21 @@ def _secret_manifest() -> dict[str, Any]: [ pytest.param(_secret_manifest(), True, id="secret-spec-deferred"), pytest.param(MANIFEST, False, id="no-secret-spec-ready"), + pytest.param( + { + **MANIFEST, + "spec": { + "type": "Spec", + "connection_specification": { + "type": "object", + "required": ["base_url"], + "properties": {"base_url": {"type": "string"}}, + }, + }, + }, + True, + id="missing-required-deferred", + ), ], ) def test_create_test_source_uses_non_secret_testing_values( @@ -892,6 +907,7 @@ def test_run_full_sync_creates_noop_destination_and_connection( "source_id": "src", "destination_id": "new-dest", "connection_id": "conn", + "destination_created": True, "connection_created": True, "job_id": 7, "job_url": "https://job", @@ -952,6 +968,7 @@ def test_readiness_full_sync_gating( source_id="src", destination_id="dest", connection_id="conn", + destination_created=False, connection_created=True, job_id=1, job_url="u", @@ -1076,3 +1093,25 @@ def test_create_test_source_after_publish_cleared_draft( project.create_test_source() assert workspace.deploy_source.call_args.kwargs["defer_credentials"] is True + + +def test_run_readiness_checks_full_sync_only_without_draft( + monkeypatch: pytest.MonkeyPatch, +) -> None: + project, workspace = _full_sync_project( + monkeypatch, + destinations=[SimpleNamespace(connector_id="dest")], + connections=[], + draft=None, + ) + + report = project.run_readiness_checks([], run_full_sync=True, test_source_id="src") + + assert report.full_sync is not None + assert report.full_sync.destination_created is False + project.publish.assert_not_called() + workspace.deploy_connection.assert_called_once() + draft_step = next( + item for item in report.checklist if item.step == "Save a draft manifest" + ) + assert draft_step.done is False From f68523f5aa8b295cccddac2656865f3ae4fe8866 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:18:21 +0000 Subject: [PATCH 19/47] fix(cloud): reuse default full-sync destination only when it is dev-null Co-Authored-By: AJ Steers --- airbyte/_util/api_util.py | 6 ++- airbyte/cloud/connector_builder.py | 16 ++++++- .../test_cloud_connector_builder.py | 44 +++++++++++++++++-- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index e6cd74a52..3d69341fd 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -94,6 +94,10 @@ _T = TypeVar("_T") +DEV_NULL_DESTINATION_DEFINITION_ID = "a7bcc9d8-13b3-4e49-b80d-d020b90045e3" +"""Definition ID of the `destination-dev-null` (no-op) connector on Airbyte Cloud.""" + + def _register_queued_job_status() -> None: """Register the `queued` job status on the `airbyte-api` SDK enum. @@ -1585,7 +1589,7 @@ def create_destination( if _get_destination_type_str(config) == "dev-null": # TODO: We have to hard-code the definition ID for dev-null destination. # https://github.com/airbytehq/PyAirbyte/issues/743 - definition_id_override = "a7bcc9d8-13b3-4e49-b80d-d020b90045e3" + definition_id_override = DEV_NULL_DESTINATION_DEFINITION_ID try: response: api.CreateDestinationResponse = airbyte_instance.destinations.create_destination( models.DestinationCreateRequest( diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index a5ecc731e..5266b3a1b 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -1116,8 +1116,22 @@ def run_full_sync( def _get_or_create_test_destination(self) -> tuple[str, bool]: """Return the test destination's ID and whether this call created it.""" existing = self.workspace.list_destinations(name=CONNECTOR_BUILDER_TEST_DESTINATION_NAME) + noop = [ + destination + for destination in existing + if destination.definition_id == api_util.DEV_NULL_DESTINATION_DEFINITION_ID + ] + if noop: + return noop[0].connector_id, False if existing: - return existing[0].connector_id, False + raise exc.AirbyteLibInputError( + message=( + f"Destination `{CONNECTOR_BUILDER_TEST_DESTINATION_NAME}` exists " + "but isn't a no-op (dev-null) destination." + ), + guidance="Pass `test_destination_id`, or rename that destination.", + context={"destination_ids": [destination.connector_id for destination in existing]}, + ) from airbyte.destinations.util import get_noop_destination # noqa: PLC0415 return self.workspace.deploy_destination( diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index c67130cc8..57bd304fd 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -921,7 +921,12 @@ def test_run_full_sync_reuses_destination_and_connection( existing.run_sync.return_value = SimpleNamespace(job_id=8, job_url="https://job8") project, workspace = _full_sync_project( monkeypatch, - destinations=[SimpleNamespace(connector_id="dest")], + destinations=[ + SimpleNamespace( + connector_id="dest", + definition_id=api_util.DEV_NULL_DESTINATION_DEFINITION_ID, + ) + ], connections=[existing], ) @@ -1007,7 +1012,12 @@ def test_run_full_sync_resolves_draft_before_publish( ) -> None: project, _ = _full_sync_project( monkeypatch, - destinations=[SimpleNamespace(connector_id="dest")], + destinations=[ + SimpleNamespace( + connector_id="dest", + definition_id=api_util.DEV_NULL_DESTINATION_DEFINITION_ID, + ) + ], connections=[], ) calls: list[str] = [] @@ -1028,7 +1038,12 @@ def test_run_full_sync_without_draft_syncs_published_version( ) -> None: project, workspace = _full_sync_project( monkeypatch, - destinations=[SimpleNamespace(connector_id="dest")], + destinations=[ + SimpleNamespace( + connector_id="dest", + definition_id=api_util.DEV_NULL_DESTINATION_DEFINITION_ID, + ) + ], connections=[], draft=None, ) @@ -1100,7 +1115,12 @@ def test_run_readiness_checks_full_sync_only_without_draft( ) -> None: project, workspace = _full_sync_project( monkeypatch, - destinations=[SimpleNamespace(connector_id="dest")], + destinations=[ + SimpleNamespace( + connector_id="dest", + definition_id=api_util.DEV_NULL_DESTINATION_DEFINITION_ID, + ) + ], connections=[], draft=None, ) @@ -1115,3 +1135,19 @@ def test_run_readiness_checks_full_sync_only_without_draft( item for item in report.checklist if item.step == "Save a draft manifest" ) assert draft_step.done is False + + +def test_run_full_sync_rejects_non_noop_destination_with_default_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + project, workspace = _full_sync_project( + monkeypatch, + destinations=[SimpleNamespace(connector_id="s3", definition_id="s3-def")], + connections=[], + ) + + with pytest.raises(AirbyteLibInputError, match="isn't a no-op"): + project.run_full_sync(test_source_id="src") + + workspace.deploy_destination.assert_not_called() + workspace.deploy_connection.assert_not_called() From 07647ca42975c82f4cad1372129c48a43a425005 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:21:07 +0000 Subject: [PATCH 20/47] test: tolerate Windows clock granularity in readiness timeout test Co-Authored-By: AJ Steers --- tests/unit_tests/test_cloud_connector_builder.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 57bd304fd..0fe74ed52 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -723,7 +723,8 @@ def fake_read( project.run_readiness_checks(["users"], timeout_seconds=30) project.run_readiness_checks(["users"]) - assert timeouts[0] is not None and 0 < timeouts[0] <= 30 + # Windows clock granularity can make the remaining time a hair over the timeout. + assert timeouts[0] is not None and 0 < timeouts[0] <= 30 + 1e-3 assert timeouts[1] is None From cc2d3f8bdb0c3c2ccb10af999c37300074fec688 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:27:23 +0000 Subject: [PATCH 21/47] fix(mcp): reject create_test_source without an initial manifest Co-Authored-By: AJ Steers --- airbyte/mcp/cloud.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 4dfa87dbc..95e5be884 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -3603,6 +3603,11 @@ def create_connector_builder_project( manifest_yaml_text=manifest_yaml_text, manifest_yaml_file=manifest_yaml_file, ) + if create_test_source and processed_manifest is None: + raise AirbyteLibInputError( + message="`create_test_source` needs an initial manifest to publish.", + guidance="Pass `manifest_yaml_text` or `manifest_yaml_file`.", + ) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) project = workspace.create_connector_builder_project( name, From ea755ddc9ae2b3487f357bec6f17783c6cae2733 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:05:19 +0000 Subject: [PATCH 22/47] fix(cloud): check full-sync prerequisites before publish, use published spec, drop test_destination_id Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder.py | 38 ++++++++----------- airbyte/mcp/cloud.py | 15 +------- tests/unit_tests/fixtures/arg_trace_plan.json | 1 - .../test_cloud_connector_builder.py | 34 ++++++++++++++--- 4 files changed, 46 insertions(+), 42 deletions(-) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index 5266b3a1b..25b44ce70 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -697,10 +697,15 @@ def set_testing_values( ) -> None: """Replace the project's testing values, which Cloud stores in its secret storage. - The spec comes from the draft manifest, so save a draft first. Fields marked - `airbyte_secret` in the spec are stored as secrets. + The spec comes from the draft manifest, or the published one when there's no draft. + Fields marked `airbyte_secret` in the spec are stored as secrets. """ - manifest = self._require_draft_manifest() + project_data = self.get_project_data() + manifest = _draft_from_project_data(project_data) or self._published_manifest( + project_data["builderProject"].get("sourceDefinitionId") + ) + if manifest is None: + manifest = self._require_draft_manifest(project_data) api_util.update_connector_builder_project_testing_values( workspace_id=self.owner_workspace_id, builder_project_id=self.builder_project_id, @@ -803,7 +808,6 @@ def run_readiness_checks( timeout_seconds: float | None = None, run_full_sync: bool = False, test_source_id: str | None = None, - test_destination_id: str | None = None, ) -> ConnectorBuilderReadinessReport: """Check the draft's progress and test-read the given streams on Airbyte Cloud. @@ -825,7 +829,6 @@ def run_readiness_checks( run_full_sync: If every named stream finished and passed (or none are named), publish the draft and start a full sync with `run_full_sync()`. test_source_id: The source for the full sync. See `run_full_sync()`. - test_destination_id: The destination for the full sync. See `run_full_sync()`. """ stream_names = list(dict.fromkeys(stream_names)) if not stream_names and not run_full_sync: @@ -845,7 +848,6 @@ def run_readiness_checks( update={ "full_sync": self.run_full_sync( test_source_id=test_source_id, - test_destination_id=test_destination_id, ), }, ) @@ -904,7 +906,6 @@ def read(stream_name: str) -> ConnectorBuilderStreamReadiness: ): report.full_sync = self.run_full_sync( test_source_id=test_source_id, - test_destination_id=test_destination_id, ) return report @@ -1004,10 +1005,8 @@ def create_test_source(self) -> CloudSource: raise exc.AirbyteLibInputError( message="Publish the Connector Builder project before creating a test source.", ) - # The first publish clears the draft, so fall back to the published manifest. - manifest = _draft_from_project_data(project_data) or self._published_manifest( - source_definition_id - ) + # The source runs the published version, so its spec decides the setup. + manifest = self._published_manifest(source_definition_id) spec = ((manifest or {}).get("spec") or {}).get("connection_specification") config = _drop_masked_secrets(project_data.get("testingValues") or {}) return self.workspace.deploy_source( @@ -1021,7 +1020,6 @@ def run_full_sync( self, *, test_source_id: str | None = None, - test_destination_id: str | None = None, pre_validate: bool = True, ) -> ConnectorBuilderFullSync: """Publish the draft and start a full sync of every stream to a no-op destination. @@ -1033,9 +1031,6 @@ def run_full_sync( Args: test_source_id: Source on the published definition to sync from. Defaults to the source from `create_test_source()`. - test_destination_id: Destination to sync to. Defaults to the workspace's - `CONNECTOR_BUILDER_TEST_DESTINATION_NAME` destination, created as a no-op - destination if missing. pre_validate: Whether to validate the manifest client-side before publishing. """ project_data = self.get_project_data() @@ -1058,9 +1053,8 @@ def run_full_sync( guidance="Sync the published source with `run_cloud_sync` instead.", context={"dynamic_streams": capped}, ) - if _draft_from_project_data(project_data) is not None: - self.publish(self.name, pre_validate=pre_validate) - source_definition_id = self.source_definition_id + # Check the source and destination first: the first publish clears the draft. + source_definition_id = project_data["builderProject"].get("sourceDefinitionId") if test_source_id is None: test_source = self.find_test_source() if test_source is None: @@ -1077,9 +1071,9 @@ def run_full_sync( message="`test_source_id` isn't a source of this project's published definition.", context={"test_source_id": test_source_id, "definition_id": source_definition_id}, ) - destination_created = False - if test_destination_id is None: - test_destination_id, destination_created = self._get_or_create_test_destination() + test_destination_id, destination_created = self._get_or_create_test_destination() + if _draft_from_project_data(project_data) is not None: + self.publish(self.name, pre_validate=pre_validate) stream_names = resolved.stream_names connection_name = f"{self.name} (Connector Builder full sync)" @@ -1129,7 +1123,7 @@ def _get_or_create_test_destination(self) -> tuple[str, bool]: f"Destination `{CONNECTOR_BUILDER_TEST_DESTINATION_NAME}` exists " "but isn't a no-op (dev-null) destination." ), - guidance="Pass `test_destination_id`, or rename that destination.", + guidance="Rename or delete that destination.", context={"destination_ids": [destination.connector_id for destination in existing]}, ) from airbyte.destinations.util import get_noop_destination # noqa: PLC0415 diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 95e5be884..9d76480d5 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -36,7 +36,6 @@ from airbyte._util import api_util from airbyte.cloud.client import _MAX_WORKSPACES_TO_VALIDATE, CloudClient from airbyte.cloud.connector_builder import ( - CONNECTOR_BUILDER_TEST_DESTINATION_NAME, CloudConnectorBuilderProject, ManifestTextEditMode, ) @@ -3918,7 +3917,7 @@ def resolve_connector_builder_manifest( @mcp_tool(external_access=True, insiders_only=True) -def run_connector_builder_readiness_checks( # noqa: PLR0913 # Explicit args mirror MCP tool schema. +def run_connector_builder_readiness_checks( ctx: Context, stream_names: Annotated[ str | list[str] | None, @@ -3981,17 +3980,6 @@ def run_connector_builder_readiness_checks( # noqa: PLR0913 # Explicit args mi default=None, ), ] = None, - test_destination_id: Annotated[ - str | None, - Field( - description=( - "Optional. Destination for the full sync. Defaults to the workspace's " - f"'{CONNECTOR_BUILDER_TEST_DESTINATION_NAME}' destination, created as a no-op " - "destination if missing." - ), - default=None, - ), - ] = None, ) -> dict[str, Any]: """Check a Connector Builder draft's progress and test-read its streams on Airbyte Cloud. @@ -4016,7 +4004,6 @@ def run_connector_builder_readiness_checks( # noqa: PLR0913 # Explicit args mi timeout_seconds=timeout_seconds, run_full_sync=run_full_sync, test_source_id=test_source_id, - test_destination_id=test_destination_id, ) if report.full_sync is not None: if report.full_sync.destination_created: diff --git a/tests/unit_tests/fixtures/arg_trace_plan.json b/tests/unit_tests/fixtures/arg_trace_plan.json index e941c9e48..7a18a8403 100644 --- a/tests/unit_tests/fixtures/arg_trace_plan.json +++ b/tests/unit_tests/fixtures/arg_trace_plan.json @@ -315,7 +315,6 @@ "definition_id": "hash", "run_full_sync": "value allowed=[false,true]", "stream_names": "fingerprint list", - "test_destination_id": "hash", "test_source_id": "hash", "timeout_seconds": "hash", "workspace_id": "omit" diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 0fe74ed52..1b36e1c21 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -365,6 +365,25 @@ def test_set_testing_values_uses_draft_spec(monkeypatch: pytest.MonkeyPatch) -> } +def test_set_testing_values_without_draft_uses_published_spec( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store(monkeypatch, draft=None, source_definition_id="def") + update_testing_values = MagicMock() + monkeypatch.setattr( + api_util, + "update_connector_builder_project_testing_values", + update_testing_values, + ) + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + monkeypatch.setattr(project, "_published_manifest", lambda _id: _secret_manifest()) + + project.set_testing_values({"api_key": "k"}) + + spec = update_testing_values.call_args.kwargs["spec"] + assert spec["connectionSpecification"]["properties"]["api_key"]["airbyte_secret"] + + def test_publish_unpublished_project(monkeypatch: pytest.MonkeyPatch) -> None: _patch_project_store(monkeypatch, draft=MANIFEST) publish = MagicMock(return_value="def") @@ -811,13 +830,15 @@ def test_create_test_source_uses_non_secret_testing_values( ) -> None: _patch_project_store( monkeypatch, - draft=manifest, + draft=MANIFEST, source_definition_id="def", testing_values={"api_key": "**********", "region": "us"}, ) workspace = _fake_workspace() workspace.deploy_source = MagicMock() project = CloudConnectorBuilderProject(workspace, "bp") + # The source runs the published version, so its spec wins over the draft's. + monkeypatch.setattr(project, "_published_manifest", lambda _id: manifest) project.create_test_source() @@ -948,11 +969,16 @@ def test_run_full_sync_reuses_destination_and_connection( def test_run_full_sync_without_test_source_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: - project, _ = _full_sync_project(monkeypatch, destinations=[], connections=[]) + project, workspace = _full_sync_project( + monkeypatch, destinations=[], connections=[] + ) with pytest.raises(AirbyteLibInputError, match="No test source"): project.run_full_sync() + project.publish.assert_not_called() + workspace.deploy_destination.assert_not_called() + @pytest.mark.parametrize( ("stream_names", "record_count", "expect_sync"), @@ -993,9 +1019,7 @@ def test_readiness_full_sync_gating( assert (report.full_sync is not None) is expect_sync if expect_sync: - run_full_sync.assert_called_once_with( - test_source_id="src", test_destination_id=None - ) + run_full_sync.assert_called_once_with(test_source_id="src") def test_readiness_requires_streams_without_full_sync( From 28b248afb1edaf439bbf369f7cba8f2bca265d1f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:09:04 +0000 Subject: [PATCH 23/47] fix(cloud): select full-sync streams from the test source's discovered catalog Co-Authored-By: AJ Steers --- airbyte/_util/api_util.py | 28 ++++++++ airbyte/cloud/connector_builder.py | 61 ++++++---------- .../test_cloud_connector_builder.py | 71 +++++++++---------- 3 files changed, 82 insertions(+), 78 deletions(-) diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index 3d69341fd..6972049b1 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -926,6 +926,34 @@ def list_destinations( # Get and run connections +def get_source_stream_names( + source_id: str, + *, + api_root: str, + client_id: SecretString | None, + client_secret: SecretString | None, + bearer_token: SecretString | None, +) -> list[str]: + """Discover a source's streams (bypassing the cached catalog) and return their names.""" + airbyte_instance = get_airbyte_server_instance( + client_id=client_id, + client_secret=client_secret, + bearer_token=bearer_token, + api_root=api_root, + ) + try: + response = airbyte_instance.streams.get_stream_properties( + api.GetStreamPropertiesRequest(source_id=source_id, ignore_cache=True), + ) + except SDKError as e: + raise _wrap_sdk_error(e, {"source_id": source_id, "api_root": api_root}) from e + return [ + stream.stream_name + for stream in response.stream_properties_response or [] + if stream.stream_name + ] + + def get_connection( workspace_id: str, connection_id: str, diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index 25b44ce70..9f32ccdb4 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -10,7 +10,6 @@ import difflib import time -from collections import Counter from concurrent.futures import ThreadPoolExecutor, wait from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -40,7 +39,6 @@ """Shortest HTTP timeout for a test read started just before the readiness deadline.""" _SECRETS_MASK = "**********" """Placeholder Cloud returns instead of a stored secret value.""" -_FULL_SYNC_STREAM_LIMIT = 100 """Most streams per dynamic stream selected for the full sync (Cloud's resolve maximum).""" CONNECTOR_BUILDER_TEST_DESTINATION_NAME = "Connector Builder Test (No-op)" """Name of the no-op destination that full syncs find or create by default.""" @@ -482,16 +480,6 @@ def _drop_masked_secrets(value: Any) -> Any: # noqa: ANN401 return value -def _capped_dynamic_streams(resolved_manifest: dict[str, Any], stream_limit: int) -> list[str]: - """Return the dynamic streams whose generated streams reached `stream_limit` when resolved.""" - counts: Counter[str] = Counter( - stream["dynamic_stream_name"] - for stream in resolved_manifest.get("streams") or [] - if isinstance(stream, dict) and isinstance(stream.get("dynamic_stream_name"), str) - ) - return sorted(name for name, count in counts.items() if count >= stream_limit) - - def _spec_missing_required(schema: Any, config: dict[str, Any]) -> bool: # noqa: ANN401 """Return whether `config` lacks a required top-level field that has no default.""" if not isinstance(schema, dict): @@ -775,7 +763,6 @@ def resolve_manifest( self, *, stream_limit: int = 5, - manifest: dict[str, Any] | None = None, ) -> ConnectorBuilderResolvedManifest: """Resolve the draft manifest on Airbyte Cloud, including dynamic streams. @@ -783,12 +770,11 @@ def resolve_manifest( Args: stream_limit: Maximum generated streams to return per dynamic stream (1 to 100). - manifest: Manifest to resolve instead of the draft. """ resolved_manifest = api_util.full_resolve_connector_builder_project_manifest( workspace_id=self.owner_workspace_id, builder_project_id=self.builder_project_id, - manifest=manifest if manifest is not None else self._require_draft_manifest(), + manifest=self._require_draft_manifest(), stream_limit=stream_limit, api_root=self.workspace.api_root, client_id=self.workspace.client_id, @@ -1024,9 +1010,9 @@ def run_full_sync( ) -> ConnectorBuilderFullSync: """Publish the draft and start a full sync of every stream to a no-op destination. - Without a draft, syncs the published version. Fails if a dynamic stream generates more - streams than resolve returns (100), since the sync couldn't select them all. Returns - without waiting for the sync to finish. + Without a draft, syncs the published version. Selects every stream the test source + discovers, so dynamic streams follow the source's own credentials. Returns without + waiting for the sync to finish. Args: test_source_id: Source on the published definition to sync from. Defaults to the @@ -1034,27 +1020,11 @@ def run_full_sync( pre_validate: Whether to validate the manifest client-side before publishing. """ project_data = self.get_project_data() - manifest = _draft_from_project_data(project_data) - if manifest is None: - manifest = self._published_manifest( - project_data["builderProject"].get("sourceDefinitionId") - ) - if manifest is None: - self._require_draft_manifest(project_data) - # Resolve before publishing: the first publish clears the draft. - resolved = self.resolve_manifest(stream_limit=_FULL_SYNC_STREAM_LIMIT, manifest=manifest) - capped = _capped_dynamic_streams(resolved.manifest, _FULL_SYNC_STREAM_LIMIT) - if capped: - raise exc.AirbyteLibInputError( - message=( - f"Dynamic streams generate {_FULL_SYNC_STREAM_LIMIT} or more streams, " - "more than a full sync can select." - ), - guidance="Sync the published source with `run_cloud_sync` instead.", - context={"dynamic_streams": capped}, - ) - # Check the source and destination first: the first publish clears the draft. + has_draft = _draft_from_project_data(project_data) is not None source_definition_id = project_data["builderProject"].get("sourceDefinitionId") + if not has_draft and source_definition_id is None: + self._require_draft_manifest(project_data) + # Check the source and destination first: the first publish clears the draft. if test_source_id is None: test_source = self.find_test_source() if test_source is None: @@ -1072,10 +1042,21 @@ def run_full_sync( context={"test_source_id": test_source_id, "definition_id": source_definition_id}, ) test_destination_id, destination_created = self._get_or_create_test_destination() - if _draft_from_project_data(project_data) is not None: + if has_draft: self.publish(self.name, pre_validate=pre_validate) - stream_names = resolved.stream_names + stream_names = api_util.get_source_stream_names( + test_source_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, + ) + if not stream_names: + raise exc.AirbyteLibInputError( + message="The test source discovered no streams.", + context={"test_source_id": test_source_id}, + ) connection_name = f"{self.name} (Connector Builder full sync)" connection = next( ( diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 1b36e1c21..e31052d2b 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -865,7 +865,7 @@ def _full_sync_project( connections: list[Any], sources: list[Any] | None = None, draft: dict[str, Any] | None = MANIFEST, - resolved_streams: list[dict[str, Any]] | None = None, + discovered_streams: list[str] | None = None, ) -> tuple[CloudConnectorBuilderProject, Any]: _patch_project_store(monkeypatch, draft=draft, source_definition_id="def") workspace = _fake_workspace() @@ -884,13 +884,12 @@ def _full_sync_project( project = CloudConnectorBuilderProject(workspace, "bp") monkeypatch.setattr(project, "publish", MagicMock()) monkeypatch.setattr( - project, - "resolve_manifest", + api_util, + "get_source_stream_names", MagicMock( - return_value=SimpleNamespace( - stream_names=["users", "orders"], - manifest={"streams": resolved_streams or []}, - ) + return_value=["users", "orders"] + if discovered_streams is None + else discovered_streams ), ) monkeypatch.setattr(project, "_published_manifest", lambda _id: MANIFEST) @@ -1032,10 +1031,10 @@ def test_readiness_requires_streams_without_full_sync( project.run_readiness_checks([]) -def test_run_full_sync_resolves_draft_before_publish( +def test_run_full_sync_selects_streams_discovered_after_publish( monkeypatch: pytest.MonkeyPatch, ) -> None: - project, _ = _full_sync_project( + project, workspace = _full_sync_project( monkeypatch, destinations=[ SimpleNamespace( @@ -1044,18 +1043,37 @@ def test_run_full_sync_resolves_draft_before_publish( ) ], connections=[], + discovered_streams=["users", "tables_a", "tables_b"], ) calls: list[str] = [] - project.resolve_manifest.side_effect = lambda **kwargs: ( - calls.append("resolve"), - SimpleNamespace(stream_names=["users"], manifest={"streams": []}), - )[1] project.publish.side_effect = lambda *_, **__: calls.append("publish") + api_util.get_source_stream_names.side_effect = lambda *_, **__: ( + calls.append("discover"), + ["users", "tables_a", "tables_b"], + )[1] project.run_full_sync(test_source_id="src") - assert calls == ["resolve", "publish"] - assert project.resolve_manifest.call_args.kwargs["manifest"] == MANIFEST + assert calls == ["publish", "discover"] + assert api_util.get_source_stream_names.call_args.args == ("src",) + assert workspace.deploy_connection.call_args.kwargs["selected_streams"] == [ + "users", + "tables_a", + "tables_b", + ] + + +def test_run_full_sync_rejects_source_without_streams( + monkeypatch: pytest.MonkeyPatch, +) -> None: + project, workspace = _full_sync_project( + monkeypatch, destinations=[], connections=[], discovered_streams=[] + ) + + with pytest.raises(AirbyteLibInputError, match="no streams"): + project.run_full_sync(test_source_id="src") + + workspace.deploy_connection.assert_not_called() def test_run_full_sync_without_draft_syncs_published_version( @@ -1076,7 +1094,6 @@ def test_run_full_sync_without_draft_syncs_published_version( project.run_full_sync(test_source_id="src") project.publish.assert_not_called() - assert project.resolve_manifest.call_args.kwargs["manifest"] == MANIFEST workspace.deploy_connection.assert_called_once() @@ -1094,28 +1111,6 @@ def test_run_full_sync_rejects_source_of_other_definition( workspace.deploy_connection.assert_not_called() -def test_run_full_sync_rejects_capped_dynamic_streams( - monkeypatch: pytest.MonkeyPatch, -) -> None: - limit = connector_builder._FULL_SYNC_STREAM_LIMIT - project, workspace = _full_sync_project( - monkeypatch, - destinations=[], - connections=[], - resolved_streams=[ - {"name": "users", "dynamic_stream_name": None}, - *({"name": f"t{i}", "dynamic_stream_name": "tables"} for i in range(limit)), - ], - ) - - with pytest.raises(AirbyteLibInputError, match="Dynamic streams") as excinfo: - project.run_full_sync(test_source_id="src") - - assert excinfo.value.context == {"dynamic_streams": ["tables"]} - project.publish.assert_not_called() - workspace.deploy_connection.assert_not_called() - - def test_create_test_source_after_publish_cleared_draft( monkeypatch: pytest.MonkeyPatch, ) -> None: From 462f7911225415a2272a5848eaee44a3feda77ed Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:11:24 +0000 Subject: [PATCH 24/47] fix(mcp): register full-sync resources as they're created; drop stale dev-null TODO Co-Authored-By: AJ Steers --- airbyte/_util/api_util.py | 2 -- airbyte/cloud/connector_builder.py | 13 +++++++++++++ airbyte/mcp/cloud.py | 6 +----- .../test_cloud_connector_builder.py | 19 ++++++++++++++++++- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index 6972049b1..70153f907 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -1615,8 +1615,6 @@ def create_destination( ) definition_id_override: str | None = None if _get_destination_type_str(config) == "dev-null": - # TODO: We have to hard-code the definition ID for dev-null destination. - # https://github.com/airbytehq/PyAirbyte/issues/743 definition_id_override = DEV_NULL_DESTINATION_DEFINITION_ID try: response: api.CreateDestinationResponse = airbyte_instance.destinations.create_destination( diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder.py index 9f32ccdb4..41f1bb129 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder.py @@ -24,6 +24,8 @@ if TYPE_CHECKING: + from collections.abc import Callable + from airbyte.cloud.connectors import CloudSource, CustomCloudSourceDefinition from airbyte.cloud.workspaces import CloudWorkspace @@ -794,6 +796,7 @@ def run_readiness_checks( timeout_seconds: float | None = None, run_full_sync: bool = False, test_source_id: str | None = None, + on_resource_created: Callable[[str], None] | None = None, ) -> ConnectorBuilderReadinessReport: """Check the draft's progress and test-read the given streams on Airbyte Cloud. @@ -815,6 +818,7 @@ def run_readiness_checks( run_full_sync: If every named stream finished and passed (or none are named), publish the draft and start a full sync with `run_full_sync()`. test_source_id: The source for the full sync. See `run_full_sync()`. + on_resource_created: Passed to `run_full_sync()`. """ stream_names = list(dict.fromkeys(stream_names)) if not stream_names and not run_full_sync: @@ -834,6 +838,7 @@ def run_readiness_checks( update={ "full_sync": self.run_full_sync( test_source_id=test_source_id, + on_resource_created=on_resource_created, ), }, ) @@ -892,6 +897,7 @@ def read(stream_name: str) -> ConnectorBuilderStreamReadiness: ): report.full_sync = self.run_full_sync( test_source_id=test_source_id, + on_resource_created=on_resource_created, ) return report @@ -1007,6 +1013,7 @@ def run_full_sync( *, test_source_id: str | None = None, pre_validate: bool = True, + on_resource_created: Callable[[str], None] | None = None, ) -> ConnectorBuilderFullSync: """Publish the draft and start a full sync of every stream to a no-op destination. @@ -1018,6 +1025,8 @@ def run_full_sync( test_source_id: Source on the published definition to sync from. Defaults to the source from `create_test_source()`. pre_validate: Whether to validate the manifest client-side before publishing. + on_resource_created: Called with the ID of each destination or connection this + call creates, as soon as it exists. """ project_data = self.get_project_data() has_draft = _draft_from_project_data(project_data) is not None @@ -1042,6 +1051,8 @@ def run_full_sync( context={"test_source_id": test_source_id, "definition_id": source_definition_id}, ) test_destination_id, destination_created = self._get_or_create_test_destination() + if destination_created and on_resource_created is not None: + on_resource_created(test_destination_id) if has_draft: self.publish(self.name, pre_validate=pre_validate) @@ -1075,6 +1086,8 @@ def run_full_sync( destination=test_destination_id, selected_streams=stream_names, ) + if on_resource_created is not None: + on_resource_created(connection.connection_id) else: connection.set_selected_streams(stream_names) sync_result = connection.run_sync(wait=False) diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 9d76480d5..e1059f438 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -4004,12 +4004,8 @@ def run_connector_builder_readiness_checks( timeout_seconds=timeout_seconds, run_full_sync=run_full_sync, test_source_id=test_source_id, + on_resource_created=register_guid_created_in_session, ) - if report.full_sync is not None: - if report.full_sync.destination_created: - register_guid_created_in_session(report.full_sync.destination_id) - if report.full_sync.connection_created: - register_guid_created_in_session(report.full_sync.connection_id) report_data = report.model_dump() report_data["streams"] = [ _redact_stream_diagnostics(stream) for stream in report_data["streams"] diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index e31052d2b..d2dae7002 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -935,6 +935,21 @@ def test_run_full_sync_creates_noop_destination_and_connection( } +def test_run_full_sync_reports_created_resources_before_sync_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + project, workspace = _full_sync_project( + monkeypatch, destinations=[], connections=[] + ) + workspace.deploy_connection.return_value.run_sync.side_effect = RuntimeError("boom") + created: list[str] = [] + + with pytest.raises(RuntimeError, match="boom"): + project.run_full_sync(test_source_id="src", on_resource_created=created.append) + + assert created == ["new-dest", "conn"] + + def test_run_full_sync_reuses_destination_and_connection( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1018,7 +1033,9 @@ def test_readiness_full_sync_gating( assert (report.full_sync is not None) is expect_sync if expect_sync: - run_full_sync.assert_called_once_with(test_source_id="src") + run_full_sync.assert_called_once_with( + test_source_id="src", on_resource_created=None + ) def test_readiness_requires_streams_without_full_sync( From 6c48b560bfe16192376dfbba06a2be21ffba0dd6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:30:13 +0000 Subject: [PATCH 25/47] refactor(cloud,mcp): split connector_builder into a package and its own MCP module; readiness checks model Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/__init__.py | 42 ++ airbyte/cloud/connector_builder/_util.py | 271 ++++++++ airbyte/cloud/connector_builder/models.py | 228 +++++++ .../project.py} | 549 ++-------------- airbyte/cloud/connectors.py | 5 +- airbyte/cloud/workspaces.py | 3 +- airbyte/mcp/__init__.py | 3 +- airbyte/mcp/_tool_utils.py | 2 +- airbyte/mcp/cloud.py | 558 +--------------- airbyte/mcp/connector_builder.py | 599 ++++++++++++++++++ airbyte/mcp/server.py | 2 + docs/CONTRIBUTING.md | 1 + docs/generate.py | 2 +- .../test_cloud_connector_builder.py | 41 +- tests/unit_tests/test_mcp_cloud.py | 3 +- tests/unit_tests/test_mcp_docs_includes.py | 1 + .../unit_tests/test_mcp_trusted_execution.py | 3 +- 17 files changed, 1230 insertions(+), 1083 deletions(-) create mode 100644 airbyte/cloud/connector_builder/__init__.py create mode 100644 airbyte/cloud/connector_builder/_util.py create mode 100644 airbyte/cloud/connector_builder/models.py rename airbyte/cloud/{connector_builder.py => connector_builder/project.py} (58%) create mode 100644 airbyte/mcp/connector_builder.py diff --git a/airbyte/cloud/connector_builder/__init__.py b/airbyte/cloud/connector_builder/__init__.py new file mode 100644 index 000000000..e5e60dda0 --- /dev/null +++ b/airbyte/cloud/connector_builder/__init__.py @@ -0,0 +1,42 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Connector Builder projects in Airbyte Cloud. + +A Connector Builder project holds an unpublished draft manifest and the testing values +(including secrets) used to test it. Test reads and manifest resolution run on Airbyte Cloud, +so no connector code runs on the caller's machine. +""" + +from __future__ import annotations + +from airbyte.cloud.connector_builder.models import ( + ConnectorBuilderFullSync, + ConnectorBuilderPageSummary, + ConnectorBuilderProjectInfo, + ConnectorBuilderReadinessCheck, + ConnectorBuilderReadinessReport, + ConnectorBuilderResolvedManifest, + ConnectorBuilderStreamReadiness, + ConnectorBuilderStreamReadResult, + ManifestTextEditMode, + ReadinessCheckResult, +) +from airbyte.cloud.connector_builder.project import ( + CONNECTOR_BUILDER_TEST_DESTINATION_NAME, + CloudConnectorBuilderProject, +) + + +__all__ = [ + "CONNECTOR_BUILDER_TEST_DESTINATION_NAME", + "CloudConnectorBuilderProject", + "ConnectorBuilderFullSync", + "ConnectorBuilderPageSummary", + "ConnectorBuilderProjectInfo", + "ConnectorBuilderReadinessCheck", + "ConnectorBuilderReadinessReport", + "ConnectorBuilderResolvedManifest", + "ConnectorBuilderStreamReadiness", + "ConnectorBuilderStreamReadResult", + "ManifestTextEditMode", + "ReadinessCheckResult", +] diff --git a/airbyte/cloud/connector_builder/_util.py b/airbyte/cloud/connector_builder/_util.py new file mode 100644 index 000000000..1d552914b --- /dev/null +++ b/airbyte/cloud/connector_builder/_util.py @@ -0,0 +1,271 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Internal helpers for Connector Builder projects: manifest text, specs, and readiness.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +from airbyte import exceptions as exc +from airbyte.cloud.connector_builder.models import ( + ConnectorBuilderReadinessCheck, + ConnectorBuilderStreamReadiness, + ManifestTextEditMode, + ReadinessCheckResult, +) + + +_MANIFEST_KEYS = frozenset({"streams", "definitions", "check"}) +_YAML_LINE_WIDTH = 2**31 - 1 +_SECRETS_MASK = "**********" +"""Placeholder Cloud returns instead of a stored secret value.""" + + +def unwrap_builder_manifest(value: object) -> dict[str, Any] | None: + """Return the connector manifest from a builder manifest payload. + + Some builder projects store their draft as a `DeclarativeSourceManifest` + envelope (`{"manifest": {...}, "spec": ..., "version": ...}`) rather than + the bare manifest; unwrap that envelope so the result matches the shape of + `manifest`. + """ + if not isinstance(value, dict): + return None + nested = value.get("manifest") + if isinstance(nested, dict) and not any(key in value for key in _MANIFEST_KEYS): + return nested + return value + + +def render_manifest_text(manifest: dict[str, Any]) -> str: + """Render a manifest as deterministic YAML text, without line wrapping. + + Airbyte Cloud stores the parsed manifest, not its original text, so comments and + formatting are not preserved. Text edits match against this rendering. + """ + return yaml.safe_dump( + manifest, + sort_keys=False, + allow_unicode=True, + width=_YAML_LINE_WIDTH, + ) + + +def apply_manifest_text_edit( + current_text: str, + *, + edit_mode: ManifestTextEditMode, + new_text: str, + old_text: str | None = None, +) -> str: + """Return `current_text` after applying one text edit. + + Args: + current_text: The text to edit. + edit_mode: See `ManifestTextEditMode`. + new_text: The whole new text for `replace_full`, else the replacement for `old_text`. + old_text: The exact text to replace. Required unless `edit_mode` is `replace_full`. + """ + if edit_mode == "replace_full": + if old_text is not None: + raise exc.AirbyteLibInputError( + message="`old_text` is not allowed when `edit_mode` is `replace_full`.", + ) + return new_text + + if not old_text: + raise exc.AirbyteLibInputError( + message=f"`old_text` is required when `edit_mode` is `{edit_mode}`.", + ) + match_count = current_text.count(old_text) + if match_count == 0: + raise exc.AirbyteLibInputError( + message="`old_text` was not found in the draft manifest text.", + guidance="Copy `old_text` verbatim from the latest draft manifest text.", + ) + if edit_mode == "replace_text" and match_count > 1: + raise exc.AirbyteLibInputError( + message=f"`old_text` matches {match_count} places in the draft manifest text.", + guidance=( + "Include more surrounding text in `old_text` so it matches once, " + "or use `replace_all` to replace every match." + ), + context={"match_count": match_count}, + ) + return current_text.replace(old_text, new_text) + + +def parse_manifest_yaml(manifest_yaml: dict[str, Any] | Path | str) -> dict[str, Any]: + """Return a manifest dict from a dict, a YAML file path, or YAML text.""" + if isinstance(manifest_yaml, Path): + manifest_yaml = manifest_yaml.read_text() + if isinstance(manifest_yaml, str): + manifest_yaml = yaml.safe_load(manifest_yaml) + if not isinstance(manifest_yaml, dict): + raise exc.AirbyteLibInputError(message="The manifest must be a YAML mapping.") + return manifest_yaml + + +def builder_testing_values_spec(manifest: dict[str, Any]) -> dict[str, Any]: + """Build the `spec` object that the testing-values API expects, as the Builder UI does.""" + manifest_spec = manifest.get("spec") or {} + return { + "documentationUrl": manifest_spec.get("documentation_url"), + "connectionSpecification": manifest_spec.get("connection_specification") or {}, + "advancedAuth": manifest_spec.get("advanced_auth"), + } + + +def publish_spec(manifest: dict[str, Any]) -> dict[str, Any]: + """Build the connector spec for publishing, with the API's camelCase field names.""" + return {**(manifest.get("spec") or {}), **builder_testing_values_spec(manifest)} + + +def draft_from_project_data(project_data: dict[str, Any]) -> dict[str, Any] | None: + if not project_data["builderProject"].get("hasDraft"): + return None + declarative_manifest = project_data.get("declarativeManifest") or {} + return unwrap_builder_manifest(declarative_manifest.get("manifest")) + + +def _result(passed: bool) -> ReadinessCheckResult: # noqa: FBT001 + return "passed" if passed else "failed" + + +def readiness_next_steps( + checks: list[ConnectorBuilderReadinessCheck], + streams: list[ConnectorBuilderStreamReadiness], + pending_streams: list[str], + not_attempted_streams: list[str], +) -> list[str]: + steps = [check.description for check in checks[:4] if check.result != "passed"] + failed = [stream.stream_name for stream in streams if not stream.passed] + capped = [stream.stream_name for stream in streams if stream.passed and not stream.read_to_end] + if failed: + steps.append(f"Fix the failed streams and test them again: {', '.join(failed)}") + if pending_streams: + steps.append( + "These streams were still reading at the timeout, so their results were dropped. " + "Retry them with a longer `timeout_seconds`, or check them with the full sync: " + f"{', '.join(pending_streams)}" + ) + if not_attempted_streams and not failed: + steps.append( + "Run the report again with the not-attempted streams, skipping any already " + f"validated or unchanged since: {', '.join(not_attempted_streams)}" + ) + if not (failed or pending_streams or not_attempted_streams): + steps.append( + "All named streams passed. Once every stream is validated, publish the custom " + "source definition (if not yet) and run a full sync of every stream to a no-op " + "destination." + ) + if capped: + steps.append( + f"These streams hit a test-read cap; check them with the full sync: {', '.join(capped)}" + ) + return steps + + +def readiness_checks( + project_data: dict[str, Any], + draft: dict[str, Any], + streams: list[ConnectorBuilderStreamReadiness], + pending_streams: list[str], + url: str, +) -> list[ConnectorBuilderReadinessCheck]: + spec = (draft.get("spec") or {}).get("connection_specification") + takes_no_inputs = isinstance(spec, dict) and not spec.get("properties") + testing_values = project_data.get("testingValues") or {} + source_definition_id = project_data["builderProject"].get("sourceDefinitionId") + failed = [stream.stream_name for stream in streams if not stream.passed] + not_read_to_end = [stream.stream_name for stream in streams if not stream.read_to_end] + return [ + ConnectorBuilderReadinessCheck( + description="Save a draft manifest", + result=_result(bool(project_data["builderProject"].get("hasDraft"))), + ), + ConnectorBuilderReadinessCheck( + description="Define the connection spec (user inputs)", + result=_result(isinstance(spec, dict)), + ), + ConnectorBuilderReadinessCheck( + description="Define at least one stream", + result=_result(bool(draft.get("streams")) or bool(draft.get("dynamic_streams"))), + ), + ConnectorBuilderReadinessCheck( + description="Set testing values (enter secrets in the Connector Builder UI)", + result=_result(bool(testing_values) or takes_no_inputs), + detail=url, + ), + ConnectorBuilderReadinessCheck( + description="Pass a test read on the named streams", + result=_result(not failed and not pending_streams), + detail="; ".join( + part + for part in ( + f"failed: {', '.join(failed)}" if failed else "", + f"pending: {', '.join(pending_streams)}" if pending_streams else "", + ) + if part + ) + or None, + ), + ConnectorBuilderReadinessCheck( + description="Read the named streams to the end within the test-read caps", + result=_result(not not_read_to_end and not pending_streams), + detail=( + f"hit a cap: {', '.join(not_read_to_end)}. Check these with a full sync " + "after publishing." + if not_read_to_end + else None + ), + ), + ConnectorBuilderReadinessCheck( + description="Publish the custom source definition", + result=_result(source_definition_id is not None), + detail=source_definition_id, + ), + ConnectorBuilderReadinessCheck( + description="Run a full sync of every stream to a no-op destination", + result="unknown", + detail="Not tracked by this report. Run it after publishing.", + ), + ] + + +def drop_masked_secrets(value: Any) -> Any: # noqa: ANN401 + """Return a copy of `value` without the masked secret fields Cloud returns.""" + if isinstance(value, dict): + return { + key: drop_masked_secrets(nested) + for key, nested in value.items() + if nested != _SECRETS_MASK + } + if isinstance(value, list): + return [drop_masked_secrets(nested) for nested in value] + return value + + +def spec_missing_required(schema: Any, config: dict[str, Any]) -> bool: # noqa: ANN401 + """Return whether `config` lacks a required top-level field that has no default.""" + if not isinstance(schema, dict): + return False + properties = schema.get("properties") or {} + return any( + name not in config and "default" not in (properties.get(name) or {}) + for name in schema.get("required") or [] + ) + + +def spec_has_secrets(schema: Any) -> bool: # noqa: ANN401 + """Return whether a connection spec marks any field `airbyte_secret`.""" + if isinstance(schema, dict): + return schema.get("airbyte_secret") is True or any( + spec_has_secrets(nested) for nested in schema.values() + ) + if isinstance(schema, list): + return any(spec_has_secrets(nested) for nested in schema) + return False diff --git a/airbyte/cloud/connector_builder/models.py b/airbyte/cloud/connector_builder/models.py new file mode 100644 index 000000000..28e810a45 --- /dev/null +++ b/airbyte/cloud/connector_builder/models.py @@ -0,0 +1,228 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Result models for Connector Builder projects in Airbyte Cloud.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +_HTTP_ERROR_STATUS_MIN = 400 +_PROBLEM_LOG_LEVELS = frozenset({"WARN", "ERROR", "FATAL"}) +_FAILING_LOG_LEVELS = frozenset({"ERROR", "FATAL"}) +ManifestTextEditMode = Literal["replace_full", "replace_text", "replace_all"] +"""How `update_draft_manifest_text()` edits the manifest text. + +- `replace_full`: `new_text` is the whole manifest. +- `replace_text`: replace the single exact match of `old_text`; fails on zero or several matches. +- `replace_all`: replace every exact match of `old_text`; fails on zero matches. +""" + + +class ConnectorBuilderPageSummary(BaseModel): + """One HTTP page fetched during a Connector Builder test read.""" + + url: str | None = None + http_method: str | None = None + status: int | None = None + record_count: int = 0 + response_body: str | None = Field( + default=None, + description="The response body, included only for HTTP error responses.", + ) + + +class ConnectorBuilderStreamReadResult(BaseModel): + """Result of a Connector Builder test read of one stream.""" + + stream_name: str + record_count: int + slice_count: int + page_count: int + test_read_limit_reached: bool + records: list[dict[str, Any]] + pages: list[ConnectorBuilderPageSummary] + logs: list[dict[str, Any]] + inferred_schema: dict[str, Any] | None = None + + @classmethod + def from_api_response( + cls, + stream_name: str, + response: dict[str, Any], + ) -> ConnectorBuilderStreamReadResult: + """Build a result from a `read_stream` API response.""" + records: list[dict[str, Any]] = [] + pages: list[ConnectorBuilderPageSummary] = [] + slices = response.get("slices") or [] + for slice_ in slices: + for page in slice_.get("pages") or []: + page_records = page.get("records") or [] + records.extend(page_records) + request = page.get("request") or {} + http_response = page.get("response") or {} + status = http_response.get("status") + pages.append( + ConnectorBuilderPageSummary( + url=request.get("url"), + http_method=request.get("http_method"), + status=status, + record_count=len(page_records), + response_body=( + http_response.get("body") + if isinstance(status, int) and status >= _HTTP_ERROR_STATUS_MIN + else None + ), + ) + ) + + return cls( + stream_name=stream_name, + record_count=len(records), + slice_count=len(slices), + page_count=len(pages), + test_read_limit_reached=bool(response.get("test_read_limit_reached")), + records=records, + pages=pages, + logs=[ + {"level": log.get("level"), "message": log.get("message")} + for log in response.get("logs") or [] + ], + inferred_schema=response.get("inferred_schema"), + ) + + +class ConnectorBuilderResolvedManifest(BaseModel): + """A manifest resolved by Airbyte Cloud, with dynamic streams expanded.""" + + manifest: dict[str, Any] + stream_names: list[str] + + @classmethod + def from_manifest(cls, manifest: dict[str, Any]) -> ConnectorBuilderResolvedManifest: + """Build a result from a resolved manifest.""" + return cls( + manifest=manifest, + stream_names=[ + stream["name"] + for stream in manifest.get("streams") or [] + if isinstance(stream, dict) and isinstance(stream.get("name"), str) + ], + ) + + +class ConnectorBuilderProjectInfo(BaseModel): + """A snapshot of a Connector Builder project, read with a single project request.""" + + builder_project_id: str + definition_id: str | None + connector_builder_project_url: str + name: str + has_draft: bool + draft_manifest: dict[str, Any] | None + draft_manifest_text: str | None + published_manifest: dict[str, Any] | None + + +class ConnectorBuilderStreamReadiness(BaseModel): + """Readiness of one stream, from a Connector Builder test read.""" + + stream_name: str + passed: bool + record_count: int = 0 + page_count: int = 0 + read_to_end: bool = Field( + default=False, + description=( + "True when the stream ended within the test-read caps. False means the read hit a " + "record, page, or slice cap, so later pages were never read." + ), + ) + http_errors: list[ConnectorBuilderPageSummary] = Field(default_factory=list) + problem_logs: list[dict[str, Any]] = Field(default_factory=list) + inferred_schema: dict[str, Any] | None = None + error: str | None = Field( + default=None, + description="The error raised by the test read itself, if it failed to run.", + ) + + @classmethod + def from_stream_read( + cls, result: ConnectorBuilderStreamReadResult + ) -> ConnectorBuilderStreamReadiness: + """Judge a stream from its test read: records returned, no HTTP or error logs.""" + http_errors = [ + page + for page in result.pages + if page.status is not None and page.status >= _HTTP_ERROR_STATUS_MIN + ] + problem_logs = [log for log in result.logs if log.get("level") in _PROBLEM_LOG_LEVELS] + has_errors = bool(http_errors) or any( + log.get("level") in _FAILING_LOG_LEVELS for log in problem_logs + ) + return cls( + stream_name=result.stream_name, + passed=result.record_count > 0 and not has_errors, + record_count=result.record_count, + page_count=result.page_count, + read_to_end=not result.test_read_limit_reached, + http_errors=http_errors, + problem_logs=problem_logs, + inferred_schema=result.inferred_schema, + ) + + +ReadinessCheckResult = Literal["passed", "failed", "unknown"] +"""Outcome of a readiness check; `unknown` when the report can't tell.""" + + +class ConnectorBuilderReadinessCheck(BaseModel): + """One readiness check toward a finished custom source.""" + + description: str + result: ReadinessCheckResult + detail: str | None = None + + +class ConnectorBuilderFullSync(BaseModel): + """A full sync started for a published Connector Builder project.""" + + source_id: str + destination_id: str + destination_created: bool + connection_id: str + connection_created: bool + job_id: int + job_url: str + + +class ConnectorBuilderReadinessReport(BaseModel): + """Readiness checks and per-stream test reads for a Connector Builder draft.""" + + passed: bool = Field( + description=( + "True when every named stream passed and was read to the end. Covers only the " + "named streams; a full sync validates the whole connector." + ), + ) + passed_streams: list[str] = Field(default_factory=list) + failed_streams: list[str] = Field(default_factory=list) + pending_streams: list[str] = Field( + default_factory=list, + description=( + "Streams still reading when `timeout_seconds` ran out. Their results are dropped, " + "and Cloud may still finish those reads." + ), + ) + not_attempted_streams: list[str] = Field( + default_factory=list, + description="Streams not started before `timeout_seconds` ran out.", + ) + checks: list[ConnectorBuilderReadinessCheck] + next_steps: list[str] + streams: list[ConnectorBuilderStreamReadiness] + full_sync: ConnectorBuilderFullSync | None = Field( + default=None, + description="The full sync started with `run_full_sync=True`, if any.", + ) diff --git a/airbyte/cloud/connector_builder.py b/airbyte/cloud/connector_builder/project.py similarity index 58% rename from airbyte/cloud/connector_builder.py rename to airbyte/cloud/connector_builder/project.py index 41f1bb129..bf409544c 100644 --- a/airbyte/cloud/connector_builder.py +++ b/airbyte/cloud/connector_builder/project.py @@ -1,508 +1,57 @@ # Copyright (c) 2026 Airbyte, Inc., all rights reserved. -"""Connector Builder projects in Airbyte Cloud. - -A Connector Builder project holds an unpublished draft manifest and the testing values -(including secrets) used to test it. Test reads and manifest resolution run on Airbyte Cloud, -so no connector code runs on the caller's machine. -""" +"""The `CloudConnectorBuilderProject` class.""" from __future__ import annotations import difflib import time from concurrent.futures import ThreadPoolExecutor, wait -from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any import requests -import yaml -from pydantic import BaseModel, Field from airbyte import exceptions as exc from airbyte._util import api_util from airbyte._util.api_util import get_web_url_root +from airbyte.cloud.connector_builder._util import ( + apply_manifest_text_edit, + builder_testing_values_spec, + draft_from_project_data, + drop_masked_secrets, + parse_manifest_yaml, + publish_spec, + readiness_checks, + readiness_next_steps, + render_manifest_text, + spec_has_secrets, + spec_missing_required, +) +from airbyte.cloud.connector_builder.models import ( + ConnectorBuilderFullSync, + ConnectorBuilderProjectInfo, + ConnectorBuilderReadinessReport, + ConnectorBuilderResolvedManifest, + ConnectorBuilderStreamReadiness, + ConnectorBuilderStreamReadResult, + ManifestTextEditMode, +) if TYPE_CHECKING: from collections.abc import Callable + from pathlib import Path from airbyte.cloud.connectors import CloudSource, CustomCloudSourceDefinition from airbyte.cloud.workspaces import CloudWorkspace -_HTTP_ERROR_STATUS_MIN = 400 -_PROBLEM_LOG_LEVELS = frozenset({"WARN", "ERROR", "FATAL"}) -_FAILING_LOG_LEVELS = frozenset({"ERROR", "FATAL"}) -_MANIFEST_KEYS = frozenset({"streams", "definitions", "check"}) -_YAML_LINE_WIDTH = 2**31 - 1 _READINESS_MAX_WORKERS = 5 """Most streams `run_readiness_checks()` reads at once.""" _MIN_READ_TIMEOUT = 1.0 """Shortest HTTP timeout for a test read started just before the readiness deadline.""" -_SECRETS_MASK = "**********" -"""Placeholder Cloud returns instead of a stored secret value.""" -"""Most streams per dynamic stream selected for the full sync (Cloud's resolve maximum).""" CONNECTOR_BUILDER_TEST_DESTINATION_NAME = "Connector Builder Test (No-op)" """Name of the no-op destination that full syncs find or create by default.""" -ManifestTextEditMode = Literal["replace_full", "replace_text", "replace_all"] -"""How `update_draft_manifest_text()` edits the manifest text. - -- `replace_full`: `new_text` is the whole manifest. -- `replace_text`: replace the single exact match of `old_text`; fails on zero or several matches. -- `replace_all`: replace every exact match of `old_text`; fails on zero matches. -""" - - -def _unwrap_builder_manifest(value: object) -> dict[str, Any] | None: - """Return the connector manifest from a builder manifest payload. - - Some builder projects store their draft as a `DeclarativeSourceManifest` - envelope (`{"manifest": {...}, "spec": ..., "version": ...}`) rather than - the bare manifest; unwrap that envelope so the result matches the shape of - `manifest`. - """ - if not isinstance(value, dict): - return None - nested = value.get("manifest") - if isinstance(nested, dict) and not any(key in value for key in _MANIFEST_KEYS): - return nested - return value - - -def render_manifest_text(manifest: dict[str, Any]) -> str: - """Render a manifest as deterministic YAML text, without line wrapping. - - Airbyte Cloud stores the parsed manifest, not its original text, so comments and - formatting are not preserved. Text edits match against this rendering. - """ - return yaml.safe_dump( - manifest, - sort_keys=False, - allow_unicode=True, - width=_YAML_LINE_WIDTH, - ) - - -def apply_manifest_text_edit( - current_text: str, - *, - edit_mode: ManifestTextEditMode, - new_text: str, - old_text: str | None = None, -) -> str: - """Return `current_text` after applying one text edit. - - Args: - current_text: The text to edit. - edit_mode: See `ManifestTextEditMode`. - new_text: The whole new text for `replace_full`, else the replacement for `old_text`. - old_text: The exact text to replace. Required unless `edit_mode` is `replace_full`. - """ - if edit_mode == "replace_full": - if old_text is not None: - raise exc.AirbyteLibInputError( - message="`old_text` is not allowed when `edit_mode` is `replace_full`.", - ) - return new_text - - if not old_text: - raise exc.AirbyteLibInputError( - message=f"`old_text` is required when `edit_mode` is `{edit_mode}`.", - ) - match_count = current_text.count(old_text) - if match_count == 0: - raise exc.AirbyteLibInputError( - message="`old_text` was not found in the draft manifest text.", - guidance="Copy `old_text` verbatim from the latest draft manifest text.", - ) - if edit_mode == "replace_text" and match_count > 1: - raise exc.AirbyteLibInputError( - message=f"`old_text` matches {match_count} places in the draft manifest text.", - guidance=( - "Include more surrounding text in `old_text` so it matches once, " - "or use `replace_all` to replace every match." - ), - context={"match_count": match_count}, - ) - return current_text.replace(old_text, new_text) - - -def parse_manifest_yaml(manifest_yaml: dict[str, Any] | Path | str) -> dict[str, Any]: - """Return a manifest dict from a dict, a YAML file path, or YAML text.""" - if isinstance(manifest_yaml, Path): - manifest_yaml = manifest_yaml.read_text() - if isinstance(manifest_yaml, str): - manifest_yaml = yaml.safe_load(manifest_yaml) - if not isinstance(manifest_yaml, dict): - raise exc.AirbyteLibInputError(message="The manifest must be a YAML mapping.") - return manifest_yaml - - -def _builder_testing_values_spec(manifest: dict[str, Any]) -> dict[str, Any]: - """Build the `spec` object that the testing-values API expects, as the Builder UI does.""" - manifest_spec = manifest.get("spec") or {} - return { - "documentationUrl": manifest_spec.get("documentation_url"), - "connectionSpecification": manifest_spec.get("connection_specification") or {}, - "advancedAuth": manifest_spec.get("advanced_auth"), - } - - -def _publish_spec(manifest: dict[str, Any]) -> dict[str, Any]: - """Build the connector spec for publishing, with the API's camelCase field names.""" - return {**(manifest.get("spec") or {}), **_builder_testing_values_spec(manifest)} - - -class ConnectorBuilderPageSummary(BaseModel): - """One HTTP page fetched during a Connector Builder test read.""" - - url: str | None = None - http_method: str | None = None - status: int | None = None - record_count: int = 0 - response_body: str | None = Field( - default=None, - description="The response body, included only for HTTP error responses.", - ) - - -class ConnectorBuilderStreamReadResult(BaseModel): - """Result of a Connector Builder test read of one stream.""" - - stream_name: str - record_count: int - slice_count: int - page_count: int - test_read_limit_reached: bool - records: list[dict[str, Any]] - pages: list[ConnectorBuilderPageSummary] - logs: list[dict[str, Any]] - inferred_schema: dict[str, Any] | None = None - - @classmethod - def from_api_response( - cls, - stream_name: str, - response: dict[str, Any], - ) -> ConnectorBuilderStreamReadResult: - """Build a result from a `read_stream` API response.""" - records: list[dict[str, Any]] = [] - pages: list[ConnectorBuilderPageSummary] = [] - slices = response.get("slices") or [] - for slice_ in slices: - for page in slice_.get("pages") or []: - page_records = page.get("records") or [] - records.extend(page_records) - request = page.get("request") or {} - http_response = page.get("response") or {} - status = http_response.get("status") - pages.append( - ConnectorBuilderPageSummary( - url=request.get("url"), - http_method=request.get("http_method"), - status=status, - record_count=len(page_records), - response_body=( - http_response.get("body") - if isinstance(status, int) and status >= _HTTP_ERROR_STATUS_MIN - else None - ), - ) - ) - - return cls( - stream_name=stream_name, - record_count=len(records), - slice_count=len(slices), - page_count=len(pages), - test_read_limit_reached=bool(response.get("test_read_limit_reached")), - records=records, - pages=pages, - logs=[ - {"level": log.get("level"), "message": log.get("message")} - for log in response.get("logs") or [] - ], - inferred_schema=response.get("inferred_schema"), - ) - - -class ConnectorBuilderResolvedManifest(BaseModel): - """A manifest resolved by Airbyte Cloud, with dynamic streams expanded.""" - - manifest: dict[str, Any] - stream_names: list[str] - - @classmethod - def from_manifest(cls, manifest: dict[str, Any]) -> ConnectorBuilderResolvedManifest: - """Build a result from a resolved manifest.""" - return cls( - manifest=manifest, - stream_names=[ - stream["name"] - for stream in manifest.get("streams") or [] - if isinstance(stream, dict) and isinstance(stream.get("name"), str) - ], - ) - - -class ConnectorBuilderProjectInfo(BaseModel): - """A snapshot of a Connector Builder project, read with a single project request.""" - - builder_project_id: str - definition_id: str | None - connector_builder_project_url: str - name: str - has_draft: bool - draft_manifest: dict[str, Any] | None - draft_manifest_text: str | None - published_manifest: dict[str, Any] | None - - -def _draft_from_project_data(project_data: dict[str, Any]) -> dict[str, Any] | None: - if not project_data["builderProject"].get("hasDraft"): - return None - declarative_manifest = project_data.get("declarativeManifest") or {} - return _unwrap_builder_manifest(declarative_manifest.get("manifest")) - - -class ConnectorBuilderStreamReadiness(BaseModel): - """Readiness of one stream, from a Connector Builder test read.""" - - stream_name: str - passed: bool - record_count: int = 0 - page_count: int = 0 - read_to_end: bool = Field( - default=False, - description=( - "True when the stream ended within the test-read caps. False means the read hit a " - "record, page, or slice cap, so later pages were never read." - ), - ) - http_errors: list[ConnectorBuilderPageSummary] = Field(default_factory=list) - problem_logs: list[dict[str, Any]] = Field(default_factory=list) - inferred_schema: dict[str, Any] | None = None - error: str | None = Field( - default=None, - description="The error raised by the test read itself, if it failed to run.", - ) - - @classmethod - def from_stream_read( - cls, result: ConnectorBuilderStreamReadResult - ) -> ConnectorBuilderStreamReadiness: - """Judge a stream from its test read: records returned, no HTTP or error logs.""" - http_errors = [ - page - for page in result.pages - if page.status is not None and page.status >= _HTTP_ERROR_STATUS_MIN - ] - problem_logs = [log for log in result.logs if log.get("level") in _PROBLEM_LOG_LEVELS] - has_errors = bool(http_errors) or any( - log.get("level") in _FAILING_LOG_LEVELS for log in problem_logs - ) - return cls( - stream_name=result.stream_name, - passed=result.record_count > 0 and not has_errors, - record_count=result.record_count, - page_count=result.page_count, - read_to_end=not result.test_read_limit_reached, - http_errors=http_errors, - problem_logs=problem_logs, - inferred_schema=result.inferred_schema, - ) - - -class ConnectorBuilderChecklistItem(BaseModel): - """One step toward a finished custom source.""" - - step: str - done: bool | None = Field( - description="True or False when known; None when this report can't tell.", - ) - detail: str | None = None - - -class ConnectorBuilderFullSync(BaseModel): - """A full sync started for a published Connector Builder project.""" - - source_id: str - destination_id: str - destination_created: bool - connection_id: str - connection_created: bool - job_id: int - job_url: str - - -class ConnectorBuilderReadinessReport(BaseModel): - """Checklist and per-stream test reads for a Connector Builder draft.""" - - passed: bool = Field( - description=( - "True when every named stream passed and was read to the end. Covers only the " - "named streams; a full sync validates the whole connector." - ), - ) - passed_streams: list[str] = Field(default_factory=list) - failed_streams: list[str] = Field(default_factory=list) - pending_streams: list[str] = Field( - default_factory=list, - description=( - "Streams still reading when `timeout_seconds` ran out. Their results are dropped, " - "and Cloud may still finish those reads." - ), - ) - not_attempted_streams: list[str] = Field( - default_factory=list, - description="Streams not started before `timeout_seconds` ran out.", - ) - checklist: list[ConnectorBuilderChecklistItem] - next_steps: list[str] - streams: list[ConnectorBuilderStreamReadiness] - full_sync: ConnectorBuilderFullSync | None = Field( - default=None, - description="The full sync started with `run_full_sync=True`, if any.", - ) - - -def _readiness_next_steps( - checklist: list[ConnectorBuilderChecklistItem], - streams: list[ConnectorBuilderStreamReadiness], - pending_streams: list[str], - not_attempted_streams: list[str], -) -> list[str]: - steps = [item.step for item in checklist[:4] if item.done is not True] - failed = [stream.stream_name for stream in streams if not stream.passed] - capped = [stream.stream_name for stream in streams if stream.passed and not stream.read_to_end] - if failed: - steps.append(f"Fix the failed streams and test them again: {', '.join(failed)}") - if pending_streams: - steps.append( - "These streams were still reading at the timeout, so their results were dropped. " - "Retry them with a longer `timeout_seconds`, or check them with the full sync: " - f"{', '.join(pending_streams)}" - ) - if not_attempted_streams and not failed: - steps.append( - "Run the report again with the not-attempted streams, skipping any already " - f"validated or unchanged since: {', '.join(not_attempted_streams)}" - ) - if not (failed or pending_streams or not_attempted_streams): - steps.append( - "All named streams passed. Once every stream is validated, publish the custom " - "source definition (if not yet) and run a full sync of every stream to a no-op " - "destination." - ) - if capped: - steps.append( - f"These streams hit a test-read cap; check them with the full sync: {', '.join(capped)}" - ) - return steps - - -def _readiness_checklist( - project_data: dict[str, Any], - draft: dict[str, Any], - streams: list[ConnectorBuilderStreamReadiness], - pending_streams: list[str], - url: str, -) -> list[ConnectorBuilderChecklistItem]: - spec = (draft.get("spec") or {}).get("connection_specification") - takes_no_inputs = isinstance(spec, dict) and not spec.get("properties") - testing_values = project_data.get("testingValues") or {} - source_definition_id = project_data["builderProject"].get("sourceDefinitionId") - failed = [stream.stream_name for stream in streams if not stream.passed] - not_read_to_end = [stream.stream_name for stream in streams if not stream.read_to_end] - return [ - ConnectorBuilderChecklistItem( - step="Save a draft manifest", - done=bool(project_data["builderProject"].get("hasDraft")), - ), - ConnectorBuilderChecklistItem( - step="Define the connection spec (user inputs)", - done=isinstance(spec, dict), - ), - ConnectorBuilderChecklistItem( - step="Define at least one stream", - done=bool(draft.get("streams")) or bool(draft.get("dynamic_streams")), - ), - ConnectorBuilderChecklistItem( - step="Set testing values (enter secrets in the Connector Builder UI)", - done=bool(testing_values) or takes_no_inputs, - detail=url, - ), - ConnectorBuilderChecklistItem( - step="Pass a test read on the named streams", - done=not failed and not pending_streams, - detail="; ".join( - part - for part in ( - f"failed: {', '.join(failed)}" if failed else "", - f"pending: {', '.join(pending_streams)}" if pending_streams else "", - ) - if part - ) - or None, - ), - ConnectorBuilderChecklistItem( - step="Read the named streams to the end within the test-read caps", - done=not not_read_to_end and not pending_streams, - detail=( - f"hit a cap: {', '.join(not_read_to_end)}. Check these with a full sync " - "after publishing." - if not_read_to_end - else None - ), - ), - ConnectorBuilderChecklistItem( - step="Publish the custom source definition", - done=source_definition_id is not None, - detail=source_definition_id, - ), - ConnectorBuilderChecklistItem( - step="Run a full sync of every stream to a no-op destination", - done=None, - detail="Not tracked by this report. Run it after publishing.", - ), - ] - - -def _drop_masked_secrets(value: Any) -> Any: # noqa: ANN401 - """Return a copy of `value` without the masked secret fields Cloud returns.""" - if isinstance(value, dict): - return { - key: _drop_masked_secrets(nested) - for key, nested in value.items() - if nested != _SECRETS_MASK - } - if isinstance(value, list): - return [_drop_masked_secrets(nested) for nested in value] - return value - - -def _spec_missing_required(schema: Any, config: dict[str, Any]) -> bool: # noqa: ANN401 - """Return whether `config` lacks a required top-level field that has no default.""" - if not isinstance(schema, dict): - return False - properties = schema.get("properties") or {} - return any( - name not in config and "default" not in (properties.get(name) or {}) - for name in schema.get("required") or [] - ) - - -def _spec_has_secrets(schema: Any) -> bool: # noqa: ANN401 - """Return whether a connection spec marks any field `airbyte_secret`.""" - if isinstance(schema, dict): - return schema.get("airbyte_secret") is True or any( - _spec_has_secrets(nested) for nested in schema.values() - ) - if isinstance(schema, list): - return any(_spec_has_secrets(nested) for nested in schema) - return False - class CloudConnectorBuilderProject: # noqa: PLR0904 # Builder project API facade. """A Connector Builder project in an Airbyte Cloud workspace.""" @@ -572,7 +121,7 @@ def published_manifest(self) -> dict[str, Any] | None: def get_info(self) -> ConnectorBuilderProjectInfo: """Read the project once and return its IDs, URL, draft, and published manifest.""" project_data = self.get_project_data() - draft_manifest = _draft_from_project_data(project_data) + draft_manifest = draft_from_project_data(project_data) source_definition_id = project_data["builderProject"].get("sourceDefinitionId") return ConnectorBuilderProjectInfo( builder_project_id=self.builder_project_id, @@ -590,10 +139,10 @@ def get_info(self) -> ConnectorBuilderProjectInfo: @property def draft_manifest(self) -> dict[str, Any] | None: """The unpublished draft manifest, or `None` if the project has no draft.""" - return _draft_from_project_data(self.get_project_data()) + return draft_from_project_data(self.get_project_data()) def _require_draft_manifest(self, project_data: dict[str, Any] | None = None) -> dict[str, Any]: - manifest = _draft_from_project_data(project_data or self.get_project_data()) + manifest = draft_from_project_data(project_data or self.get_project_data()) if manifest is None: raise exc.AirbyteLibInputError( message="This Connector Builder project has no draft manifest.", @@ -691,7 +240,7 @@ def set_testing_values( Fields marked `airbyte_secret` in the spec are stored as secrets. """ project_data = self.get_project_data() - manifest = _draft_from_project_data(project_data) or self._published_manifest( + manifest = draft_from_project_data(project_data) or self._published_manifest( project_data["builderProject"].get("sourceDefinitionId") ) if manifest is None: @@ -700,7 +249,7 @@ def set_testing_values( workspace_id=self.owner_workspace_id, builder_project_id=self.builder_project_id, testing_values=testing_values, - spec=_builder_testing_values_spec(manifest), + spec=builder_testing_values_spec(manifest), api_root=self.workspace.api_root, client_id=self.workspace.client_id, client_secret=self.workspace.client_secret, @@ -802,7 +351,7 @@ def run_readiness_checks( Reads run in parallel. A stream passes when its test read returns records with no HTTP errors or error logs, and is `read_to_end` when it ended within the test-read - caps (which default to Cloud's maximums). The checklist is derived from the project, + caps (which default to Cloud's maximums). The readiness checks are derived from the project, so nothing is stored. `passed` covers only the named streams: each must pass and be read to the end. A full sync after publishing validates the whole connector. @@ -829,7 +378,7 @@ def run_readiness_checks( if not stream_names: # A full sync alone can run on the published manifest; the first publish clears # the draft. - manifest = _draft_from_project_data(project_data) or self._published_manifest( + manifest = draft_from_project_data(project_data) or self._published_manifest( project_data["builderProject"].get("sourceDefinitionId") ) if manifest is None: @@ -910,7 +459,7 @@ def _readiness_report( not_attempted_streams: list[str], ) -> ConnectorBuilderReadinessReport: remaining = pending_streams + not_attempted_streams - checklist = _readiness_checklist(project_data, draft, streams, remaining, self.url) + checks = readiness_checks(project_data, draft, streams, remaining, self.url) return ConnectorBuilderReadinessReport( passed=not remaining and all(stream.passed and stream.read_to_end for stream in streams), @@ -918,9 +467,9 @@ def _readiness_report( failed_streams=[stream.stream_name for stream in streams if not stream.passed], pending_streams=pending_streams, not_attempted_streams=not_attempted_streams, - checklist=checklist, - next_steps=_readiness_next_steps( - checklist, streams, pending_streams, not_attempted_streams + checks=checks, + next_steps=readiness_next_steps( + checks, streams, pending_streams, not_attempted_streams ), streams=streams, ) @@ -953,7 +502,7 @@ def publish( builder_project_id=self.builder_project_id, name=name, manifest=manifest, - spec=_publish_spec(manifest), + spec=publish_spec(manifest), api_root=self.workspace.api_root, client_id=self.workspace.client_id, client_secret=self.workspace.client_secret, @@ -1000,12 +549,12 @@ def create_test_source(self) -> CloudSource: # The source runs the published version, so its spec decides the setup. manifest = self._published_manifest(source_definition_id) spec = ((manifest or {}).get("spec") or {}).get("connection_specification") - config = _drop_masked_secrets(project_data.get("testingValues") or {}) + config = drop_masked_secrets(project_data.get("testingValues") or {}) return self.workspace.deploy_source( name=self.test_source_name, source=config, definition_id=source_definition_id, - defer_credentials=_spec_has_secrets(spec) or _spec_missing_required(spec, config), + defer_credentials=spec_has_secrets(spec) or spec_missing_required(spec, config), ) def run_full_sync( @@ -1029,7 +578,7 @@ def run_full_sync( call creates, as soon as it exists. """ project_data = self.get_project_data() - has_draft = _draft_from_project_data(project_data) is not None + has_draft = draft_from_project_data(project_data) is not None source_definition_id = project_data["builderProject"].get("sourceDefinitionId") if not has_draft and source_definition_id is None: self._require_draft_manifest(project_data) @@ -1130,21 +679,3 @@ def _get_or_create_test_destination(self) -> tuple[str, bool]: def __repr__(self) -> str: """Return a string representation of the project.""" return f"CloudConnectorBuilderProject(builder_project_id={self.builder_project_id!r})" - - -__all__ = [ - "CONNECTOR_BUILDER_TEST_DESTINATION_NAME", - "CloudConnectorBuilderProject", - "ConnectorBuilderFullSync", - "ConnectorBuilderChecklistItem", - "ConnectorBuilderPageSummary", - "ConnectorBuilderReadinessReport", - "ConnectorBuilderStreamReadiness", - "ConnectorBuilderProjectInfo", - "ConnectorBuilderResolvedManifest", - "ConnectorBuilderStreamReadResult", - "ManifestTextEditMode", - "apply_manifest_text_edit", - "parse_manifest_yaml", - "render_manifest_text", -] diff --git a/airbyte/cloud/connectors.py b/airbyte/cloud/connectors.py index 2b17af3a1..8efb166ae 100644 --- a/airbyte/cloud/connectors.py +++ b/airbyte/cloud/connectors.py @@ -88,7 +88,8 @@ ) from airbyte._util import api_util, text_util from airbyte._util.api_util import get_web_url_root -from airbyte.cloud.connector_builder import CloudConnectorBuilderProject, _unwrap_builder_manifest +from airbyte.cloud.connector_builder import CloudConnectorBuilderProject +from airbyte.cloud.connector_builder._util import unwrap_builder_manifest from airbyte.cloud.models import ( CheckResult, CloudCustomSourceDefinitionInfo, @@ -1575,7 +1576,7 @@ def draft_manifest(self) -> dict[str, Any] | None: return None declarative_manifest = project_data.get("declarativeManifest", {}) - return _unwrap_builder_manifest(declarative_manifest.get("manifest")) + return unwrap_builder_manifest(declarative_manifest.get("manifest")) @property def definition_url(self) -> str: diff --git a/airbyte/cloud/workspaces.py b/airbyte/cloud/workspaces.py index 159900b29..1ef796fe6 100644 --- a/airbyte/cloud/workspaces.py +++ b/airbyte/cloud/workspaces.py @@ -57,7 +57,8 @@ from airbyte.cloud._credentials import _AirbyteCredentials from airbyte.cloud.client_config import CloudClientConfig from airbyte.cloud.connections import CloudConnection -from airbyte.cloud.connector_builder import CloudConnectorBuilderProject, parse_manifest_yaml +from airbyte.cloud.connector_builder import CloudConnectorBuilderProject +from airbyte.cloud.connector_builder._util import parse_manifest_yaml from airbyte.cloud.models import ( ConnectorFeature, ConnectorType, diff --git a/airbyte/mcp/__init__.py b/airbyte/mcp/__init__.py index f6536eed6..3834190de 100644 --- a/airbyte/mcp/__init__.py +++ b/airbyte/mcp/__init__.py @@ -414,11 +414,12 @@ """ # noqa: D415 -from airbyte.mcp import cloud, guidance, interactive, local, registry +from airbyte.mcp import cloud, connector_builder, guidance, interactive, local, registry __all__: list[str] = [ "cloud", + "connector_builder", "guidance", "interactive", "local", diff --git a/airbyte/mcp/_tool_utils.py b/airbyte/mcp/_tool_utils.py index f87bd88da..94f163b06 100644 --- a/airbyte/mcp/_tool_utils.py +++ b/airbyte/mcp/_tool_utils.py @@ -548,7 +548,7 @@ def _normalize_mcp_module(mcp_module: str) -> str: return mcp_module -_CLOUD_MCP_MODULES = frozenset({"cloud", "interactive"}) +_CLOUD_MCP_MODULES = frozenset({"cloud", "connector_builder", "interactive"}) """MCP tool modules backed by Airbyte Cloud APIs. Cloud-scoped policies (pipeline changes, read-only mode, destructive tools) diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index e1059f438..5e0c16934 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -35,10 +35,7 @@ ) from airbyte._util import api_util from airbyte.cloud.client import _MAX_WORKSPACES_TO_VALIDATE, CloudClient -from airbyte.cloud.connector_builder import ( - CloudConnectorBuilderProject, - ManifestTextEditMode, -) +from airbyte.cloud.connector_builder import CloudConnectorBuilderProject from airbyte.cloud.connectors import ( CheckResult, CloudConnector, @@ -3216,6 +3213,15 @@ def get_cloud_organization_billing_status( ) +def _check_builder_project_created_in_session(project: CloudConnectorBuilderProject) -> None: + try: + check_guid_created_in_session(project.builder_project_id) + except AirbyteLocalDemoModeError: + if project.source_definition_id is None: + raise + check_guid_created_in_session(project.source_definition_id) + + def _get_custom_source_definition_description( custom_source: CustomCloudSourceDefinition, ) -> str: @@ -3473,550 +3479,6 @@ def get_custom_source_definition( return result -_BUILDER_PROJECT_ID_DESCRIPTION = ( - "The Connector Builder project ID. Provide exactly one of `builder_project_id` or " - "`definition_id`." -) -_BUILDER_DEFINITION_ID_DESCRIPTION = ( - "The custom source definition ID, for a project that has been published. Provide exactly " - "one of `builder_project_id` or `definition_id`." -) -_TEST_READ_RECORDS_PREVIEW_LIMIT = 10 - - -def _check_builder_project_created_in_session(project: CloudConnectorBuilderProject) -> None: - try: - check_guid_created_in_session(project.builder_project_id) - except AirbyteLocalDemoModeError: - if project.source_definition_id is None: - raise - check_guid_created_in_session(project.source_definition_id) - - -@mcp_tool( - read_only=True, - idempotent=True, -) -def get_connector_builder_draft_manifest( - ctx: Context, - definition_id: Annotated[ - str | None, - Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), - ] = None, - *, - builder_project_id: Annotated[ - str | None, - Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), - ] = None, - workspace_id: Annotated[ - str | None, - Field( - description=WORKSPACE_ID_TIP_TEXT, - default=None, - ), - TraceArg.OMIT, - ], -) -> dict[str, Any]: - """Get the Connector Builder draft manifest of a project or custom source definition. - - Returns the working draft manifest that has been saved but not yet published, both as - a dict and as `draft_manifest_text`. Airbyte Cloud stores the parsed manifest, so the text - is a normalized YAML rendering without comments; text edits with - `update_connector_builder_manifest_text` match against it. - - If no draft exists, 'has_draft' will be False and the draft fields will be None. - The published manifest is included for comparison, or None if never published. - """ - workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) - project = workspace.get_connector_builder_project( - builder_project_id, - definition_id=definition_id, - ) - return project.get_info().model_dump() - - -@mcp_tool(extra_help_text=_manifest_yaml_source_hint, insiders_only=True) -def create_connector_builder_project( - ctx: Context, - name: Annotated[ - str, - Field(description="The name for the Connector Builder project."), - TraceArg.FINGERPRINT, - ], - *, - workspace_id: Annotated[ - str | None, - Field( - description=WORKSPACE_ID_TIP_TEXT, - default=None, - ), - TraceArg.OMIT, - ], - manifest_yaml_text: Annotated[ - str | None, - Field( - description=( - "Optional initial draft. Full YAML contents of the declarative (low-code) " - "connector manifest: the file's text, not a filename or path." - ), - default=None, - ), - ] = None, - manifest_yaml_file: Annotated[ - str | None, - Field( - description=( - "Optional initial draft. Absolute path to a manifest YAML file on the machine " - "running this MCP server." - ), - default=None, - ), - ] = None, - pre_validate: Annotated[ - bool, - Field( - description="Whether to validate the manifest client-side before saving.", - default=True, - ), - ] = True, - create_test_source: Annotated[ - bool, - Field( - description=( - "Optional. Publish the initial manifest (which needs its spec) and create a " - "test source on it for a later full sync. Ask the user to enter their " - "credentials at both `connector_builder_project_url` (testing values) and " - "`test_source_settings_url` (then test and save) before you start building." - ), - default=False, - ), - ] = False, -) -> dict[str, Any]: - """Create a Connector Builder project to develop a custom YAML source. - - The project holds the draft manifest and the testing values used for test reads. - Nothing appears in the workspace's source list until the project is published with - `publish_custom_source_definition`. - """ - processed_manifest = resolve_manifest_yaml( - manifest_yaml_text=manifest_yaml_text, - manifest_yaml_file=manifest_yaml_file, - ) - if create_test_source and processed_manifest is None: - raise AirbyteLibInputError( - message="`create_test_source` needs an initial manifest to publish.", - guidance="Pass `manifest_yaml_text` or `manifest_yaml_file`.", - ) - workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) - project = workspace.create_connector_builder_project( - name, - manifest_yaml=processed_manifest, - pre_validate=pre_validate, - ) - register_guid_created_in_session(project.builder_project_id) - result: dict[str, Any] = { - "builder_project_id": project.builder_project_id, - "definition_id": None, - "connector_builder_project_url": project.url, - "name": name, - } - if create_test_source: - definition = project.publish(name, pre_validate=pre_validate) - register_guid_created_in_session(definition.definition_id) - test_source = project.create_test_source() - register_guid_created_in_session(test_source.connector_id) - result |= { - "definition_id": definition.definition_id, - "test_source_id": test_source.connector_id, - "test_source_settings_url": test_source.connector_url, - } - return result - - -@mcp_tool(destructive=True, insiders_only=True) -def update_connector_builder_manifest_text( - ctx: Context, - edit_mode: Annotated[ - ManifestTextEditMode, - Field( - description=( - "`replace_full`: `new_text` is the whole manifest. " - "`replace_text`: replace the single exact match of `old_text`; fails if " - "`old_text` matches zero or several places (add surrounding text to make it " - "unique). `replace_all`: replace every exact match of `old_text`." - ), - ), - ], - new_text: Annotated[ - str, - Field( - description=( - "The whole manifest YAML for `replace_full`, else the text that replaces " - "`old_text`." - ), - ), - ], - *, - old_text: Annotated[ - str | None, - Field( - description=( - "The exact text to replace, copied verbatim from `draft_manifest_text`. " - "Required unless `edit_mode` is `replace_full`." - ), - default=None, - ), - ] = None, - builder_project_id: Annotated[ - str | None, - Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), - ] = None, - definition_id: Annotated[ - str | None, - Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), - ] = None, - workspace_id: Annotated[ - str | None, - Field( - description=WORKSPACE_ID_TIP_TEXT, - default=None, - ), - TraceArg.OMIT, - ], - pre_validate: Annotated[ - bool, - Field( - description="Whether to validate the edited manifest client-side before saving.", - default=True, - ), - ] = True, -) -> str: - """Edit and save a Connector Builder project's draft manifest. Does not publish anything. - - Text edits match against `draft_manifest_text` from `get_connector_builder_draft_manifest`. - Returns a unified diff from the previous text to the saved text; base later edits on the - saved text. - """ - workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) - project = workspace.get_connector_builder_project( - builder_project_id, - definition_id=definition_id, - ) - _check_builder_project_created_in_session(project) - diff = project.update_draft_manifest_text( - edit_mode=edit_mode, - new_text=new_text, - old_text=old_text, - pre_validate=pre_validate, - ) - return ( - f"Saved the draft manifest of Connector Builder project {project.builder_project_id}." - "\n\n" + (diff or "No changes.") - ) - - -@mcp_tool(destructive=True, idempotent=True, insiders_only=True) -def set_connector_builder_testing_values( - ctx: Context, - *, - builder_project_id: Annotated[ - str | None, - Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), - ] = None, - definition_id: Annotated[ - str | None, - Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), - ] = None, - workspace_id: Annotated[ - str | None, - Field( - description=WORKSPACE_ID_TIP_TEXT, - default=None, - ), - TraceArg.OMIT, - ], - testing_values: Annotated[ - dict | str | None, - Field( - description=( - "Configuration values for test reads, as a JSON object or JSON string. " - "Supports inline secret refs via 'secret_reference::ENV_VAR_NAME' syntax. " - "Replaces all existing testing values." - ), - default=None, - ), - ] = None, - testing_values_secret_name: Annotated[ - str | None, - Field( - description=( - "Optional name of a secret containing testing values in JSON or YAML format. " - "The secret is resolved by the MCP server and merged into testing_values, with " - "secret values taking precedence." - ), - default=None, - ), - TraceArg.FINGERPRINT, - ] = None, -) -> str: - """Replace the testing values (connector config) of a Connector Builder project. - - Test reads use these values. Airbyte Cloud stores fields marked `airbyte_secret` in the - draft manifest's spec in its secret storage. Requires a saved draft manifest. - Don't ask users to paste secrets into the chat: send them the returned project URL to - enter secrets in the Connector Builder's testing values form instead. - """ - if testing_values is None and testing_values_secret_name is None: - raise AirbyteLibInputError( - message="Provide `testing_values` or `testing_values_secret_name`.", - ) - testing_values_dict = resolve_connector_config( - config=testing_values, - config_secret_name=testing_values_secret_name, - ) - workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) - project = workspace.get_connector_builder_project( - builder_project_id, - definition_id=definition_id, - ) - _check_builder_project_created_in_session(project) - project.set_testing_values(testing_values_dict) - return ( - f"Saved testing values for Connector Builder project {project.builder_project_id}. " - f"Users can review them or enter secrets at: {project.url}" - ) - - -def _redact_stream_diagnostics(stream_data: dict[str, Any]) -> dict[str, Any]: - """Redact credentials from a test read's page URLs, error bodies, logs, and error text.""" - for page in [*stream_data.get("pages", []), *stream_data.get("http_errors", [])]: - for key in ("url", "response_body"): - if page.get(key): - page[key] = redact_agent_text(page[key]) - for log in [*stream_data.get("logs", []), *stream_data.get("problem_logs", [])]: - if isinstance(log.get("message"), str): - log["message"] = redact_agent_text(log["message"]) - if stream_data.get("error"): - stream_data["error"] = redact_agent_text(stream_data["error"]) - return stream_data - - -@mcp_tool(read_only=True, external_access=True, insiders_only=True) -def test_connector_builder_stream( - ctx: Context, - stream_name: Annotated[ - str, - Field(description="The name of the stream to test-read."), - TraceArg.FINGERPRINT, - ], - *, - builder_project_id: Annotated[ - str | None, - Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), - ] = None, - definition_id: Annotated[ - str | None, - Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), - ] = None, - workspace_id: Annotated[ - str | None, - Field( - description=WORKSPACE_ID_TIP_TEXT, - default=None, - ), - TraceArg.OMIT, - ], - record_limit: Annotated[ - int, - Field(description="Maximum records to read (1 to 10,000).", default=100, ge=1, le=10000), - TraceArg.VALUE, - ] = 100, - page_limit: Annotated[ - int, - Field(description="Maximum pages to fetch per slice (1 to 20).", default=5, ge=1, le=20), - TraceArg.VALUE, - ] = 5, - slice_limit: Annotated[ - int, - Field(description="Maximum slices to read (1 to 20).", default=5, ge=1, le=20), - TraceArg.VALUE, - ] = 5, -) -> dict[str, Any]: - """Test-read one stream of a Connector Builder project's draft manifest on Airbyte Cloud. - - Uses the project's saved testing values as the connector config. Returns counts, a - per-page summary of HTTP requests (with response bodies for HTTP errors), connector logs, - the inferred schema, and a preview of the first records (`records` holds at most - 10 records; `record_count` is the total read). - """ - workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) - project = workspace.get_connector_builder_project( - builder_project_id, - definition_id=definition_id, - ) - result = project.test_read_stream( - stream_name, - record_limit=record_limit, - page_limit=page_limit, - slice_limit=slice_limit, - ) - return { - **_redact_stream_diagnostics(result.model_dump()), - "records": result.records[:_TEST_READ_RECORDS_PREVIEW_LIMIT], - "builder_project_id": project.builder_project_id, - } - - -@mcp_tool(read_only=True, idempotent=True, external_access=True, insiders_only=True) -def resolve_connector_builder_manifest( - ctx: Context, - *, - builder_project_id: Annotated[ - str | None, - Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), - ] = None, - definition_id: Annotated[ - str | None, - Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), - ] = None, - workspace_id: Annotated[ - str | None, - Field( - description=WORKSPACE_ID_TIP_TEXT, - default=None, - ), - TraceArg.OMIT, - ], - stream_limit: Annotated[ - int, - Field( - description="Maximum generated streams per dynamic stream (1 to 100).", - default=5, - ge=1, - le=100, - ), - TraceArg.VALUE, - ] = 5, -) -> dict[str, Any]: - """Resolve a Connector Builder project's draft manifest on Airbyte Cloud. - - Expands `$ref`s, `$parameters`, and dynamic streams (using the project's testing values), - and lists the resulting stream names. - """ - workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) - project = workspace.get_connector_builder_project( - builder_project_id, - definition_id=definition_id, - ) - resolved = project.resolve_manifest(stream_limit=stream_limit) - return { - "builder_project_id": project.builder_project_id, - "stream_names": resolved.stream_names, - "resolved_manifest": resolved.manifest, - } - - -@mcp_tool(external_access=True, insiders_only=True) -def run_connector_builder_readiness_checks( - ctx: Context, - stream_names: Annotated[ - str | list[str] | None, - Field( - description=( - "Streams to test-read. The report covers only these streams. Required unless " - "`run_full_sync` is set. For dynamic streams, get the generated names from " - "`resolve_connector_builder_manifest`." - ), - default=None, - ), - TraceArg.FINGERPRINT, - ] = None, - *, - builder_project_id: Annotated[ - str | None, - Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), - ] = None, - definition_id: Annotated[ - str | None, - Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), - ] = None, - workspace_id: Annotated[ - str | None, - Field( - description=WORKSPACE_ID_TIP_TEXT, - default=None, - ), - TraceArg.OMIT, - ], - timeout_seconds: Annotated[ - float | None, - Field( - description=( - "Optional. Return after this many seconds with the streams finished so far. " - "Streams still reading are dropped (`pending_streams`); streams not started are " - "listed in `not_attempted_streams` to pass in another call. No limit by default." - ), - default=None, - ), - ] = None, - run_full_sync: Annotated[ - bool, - Field( - description=( - "Optional. If every named stream passed (or none are named), publish the draft " - "and start a full sync of every stream to a no-op destination. Returns the job " - "in `full_sync`; poll it with `get_cloud_sync_status`." - ), - default=False, - ), - ] = False, - test_source_id: Annotated[ - str | None, - Field( - description=( - "Optional. Source on the published definition for the full sync. Defaults to " - "the test source from `create_connector_builder_project(create_test_source=True)`." - ), - default=None, - ), - ] = None, -) -> dict[str, Any]: - """Check a Connector Builder draft's progress and test-read its streams on Airbyte Cloud. - - Returns a checklist of the steps to a finished custom source, with next steps, plus - per-stream results from parallel test reads using the project's testing values: pass/fail, - `read_to_end`, record and page counts, HTTP errors, warning and error logs, and inferred - schemas. A stream passes when it returns records with no HTTP errors or error logs. - Cloud test reads stop at 20 pages per slice and 20 slices, so a stream that hits a cap - isn't `read_to_end` and the report doesn't pass. The verdict covers only the named - streams; a full sync validates the whole connector. With `run_full_sync`, the tool - publishes the draft and starts that sync once the named streams pass. - """ - workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) - project = workspace.get_connector_builder_project( - builder_project_id, - definition_id=definition_id, - ) - if run_full_sync: - _check_builder_project_created_in_session(project) - report = project.run_readiness_checks( - resolve_list_of_strings(stream_names) or [], - timeout_seconds=timeout_seconds, - run_full_sync=run_full_sync, - test_source_id=test_source_id, - on_resource_created=register_guid_created_in_session, - ) - report_data = report.model_dump() - report_data["streams"] = [ - _redact_stream_diagnostics(stream) for stream in report_data["streams"] - ] - return { - "builder_project_id": project.builder_project_id, - "connector_builder_project_url": project.url, - **report_data, - } - - @mcp_tool(destructive=True, extra_help_text=_manifest_yaml_source_hint) def update_custom_source_definition( # noqa: PLR0913 # Explicit args mirror MCP tool schema. ctx: Context, diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py new file mode 100644 index 000000000..7d07ed94c --- /dev/null +++ b/airbyte/mcp/connector_builder.py @@ -0,0 +1,599 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Airbyte Cloud Connector Builder MCP operations. + +.. include:: ../../docs/mcp-generated/connector_builder.md +""" + +# No public Python API — MCP primitives are registered via decorators and +# documented via the generated Markdown include above. +__all__: list[str] = [] + +from typing import TYPE_CHECKING, Annotated, Any + +from fastmcp import Context, FastMCP +from fastmcp_extensions import TraceArg, register_mcp_tools +from pydantic import Field + +from airbyte.cloud.connector_builder import ( + ManifestTextEditMode, +) +from airbyte.exceptions import ( + AirbyteLibInputError, +) +from airbyte.mcp._arg_resolvers import ( + resolve_connector_config, + resolve_list_of_strings, + resolve_manifest_yaml, +) +from airbyte.mcp._error_handling import redact_agent_text +from airbyte.mcp._guards import is_trusted_execution_enabled +from airbyte.mcp._tool_utils import ( + AIRBYTE_CLOUD_WORKSPACE_ID_IS_SET, + mcp_tool, + register_guid_created_in_session, +) + + +if TYPE_CHECKING: + from airbyte.cloud.workspaces import CloudWorkspace + +from airbyte.mcp.cloud import ( + WORKSPACE_ID_TIP_TEXT, + _add_defaults_for_exclude_args, + _check_builder_project_created_in_session, + _get_cloud_workspace, + _manifest_yaml_source_hint, +) + + +_BUILDER_PROJECT_ID_DESCRIPTION = ( + "The Connector Builder project ID. Provide exactly one of `builder_project_id` or " + "`definition_id`." +) +_BUILDER_DEFINITION_ID_DESCRIPTION = ( + "The custom source definition ID, for a project that has been published. Provide exactly " + "one of `builder_project_id` or `definition_id`." +) +_TEST_READ_RECORDS_PREVIEW_LIMIT = 10 + + +@mcp_tool( + read_only=True, + idempotent=True, +) +def get_connector_builder_draft_manifest( + ctx: Context, + definition_id: Annotated[ + str | None, + Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + ] = None, + *, + builder_project_id: Annotated[ + str | None, + Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], +) -> dict[str, Any]: + """Get the Connector Builder draft manifest of a project or custom source definition. + + Returns the working draft manifest that has been saved but not yet published, both as + a dict and as `draft_manifest_text`. Airbyte Cloud stores the parsed manifest, so the text + is a normalized YAML rendering without comments; text edits with + `update_connector_builder_manifest_text` match against it. + + If no draft exists, 'has_draft' will be False and the draft fields will be None. + The published manifest is included for comparison, or None if never published. + """ + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + return project.get_info().model_dump() + + +@mcp_tool(extra_help_text=_manifest_yaml_source_hint, insiders_only=True) +def create_connector_builder_project( + ctx: Context, + name: Annotated[ + str, + Field(description="The name for the Connector Builder project."), + TraceArg.FINGERPRINT, + ], + *, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], + manifest_yaml_text: Annotated[ + str | None, + Field( + description=( + "Optional initial draft. Full YAML contents of the declarative (low-code) " + "connector manifest: the file's text, not a filename or path." + ), + default=None, + ), + ] = None, + manifest_yaml_file: Annotated[ + str | None, + Field( + description=( + "Optional initial draft. Absolute path to a manifest YAML file on the machine " + "running this MCP server." + ), + default=None, + ), + ] = None, + pre_validate: Annotated[ + bool, + Field( + description="Whether to validate the manifest client-side before saving.", + default=True, + ), + ] = True, + create_test_source: Annotated[ + bool, + Field( + description=( + "Optional. Publish the initial manifest (which needs its spec) and create a " + "test source on it for a later full sync. Ask the user to enter their " + "credentials at both `connector_builder_project_url` (testing values) and " + "`test_source_settings_url` (then test and save) before you start building." + ), + default=False, + ), + ] = False, +) -> dict[str, Any]: + """Create a Connector Builder project to develop a custom YAML source. + + The project holds the draft manifest and the testing values used for test reads. + Nothing appears in the workspace's source list until the project is published with + `publish_custom_source_definition`. + """ + processed_manifest = resolve_manifest_yaml( + manifest_yaml_text=manifest_yaml_text, + manifest_yaml_file=manifest_yaml_file, + ) + if create_test_source and processed_manifest is None: + raise AirbyteLibInputError( + message="`create_test_source` needs an initial manifest to publish.", + guidance="Pass `manifest_yaml_text` or `manifest_yaml_file`.", + ) + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.create_connector_builder_project( + name, + manifest_yaml=processed_manifest, + pre_validate=pre_validate, + ) + register_guid_created_in_session(project.builder_project_id) + result: dict[str, Any] = { + "builder_project_id": project.builder_project_id, + "definition_id": None, + "connector_builder_project_url": project.url, + "name": name, + } + if create_test_source: + definition = project.publish(name, pre_validate=pre_validate) + register_guid_created_in_session(definition.definition_id) + test_source = project.create_test_source() + register_guid_created_in_session(test_source.connector_id) + result |= { + "definition_id": definition.definition_id, + "test_source_id": test_source.connector_id, + "test_source_settings_url": test_source.connector_url, + } + return result + + +@mcp_tool(destructive=True, insiders_only=True) +def update_connector_builder_manifest_text( + ctx: Context, + edit_mode: Annotated[ + ManifestTextEditMode, + Field( + description=( + "`replace_full`: `new_text` is the whole manifest. " + "`replace_text`: replace the single exact match of `old_text`; fails if " + "`old_text` matches zero or several places (add surrounding text to make it " + "unique). `replace_all`: replace every exact match of `old_text`." + ), + ), + ], + new_text: Annotated[ + str, + Field( + description=( + "The whole manifest YAML for `replace_full`, else the text that replaces " + "`old_text`." + ), + ), + ], + *, + old_text: Annotated[ + str | None, + Field( + description=( + "The exact text to replace, copied verbatim from `draft_manifest_text`. " + "Required unless `edit_mode` is `replace_full`." + ), + default=None, + ), + ] = None, + builder_project_id: Annotated[ + str | None, + Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + ] = None, + definition_id: Annotated[ + str | None, + Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], + pre_validate: Annotated[ + bool, + Field( + description="Whether to validate the edited manifest client-side before saving.", + default=True, + ), + ] = True, +) -> str: + """Edit and save a Connector Builder project's draft manifest. Does not publish anything. + + Text edits match against `draft_manifest_text` from `get_connector_builder_draft_manifest`. + Returns a unified diff from the previous text to the saved text; base later edits on the + saved text. + """ + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + _check_builder_project_created_in_session(project) + diff = project.update_draft_manifest_text( + edit_mode=edit_mode, + new_text=new_text, + old_text=old_text, + pre_validate=pre_validate, + ) + return ( + f"Saved the draft manifest of Connector Builder project {project.builder_project_id}." + "\n\n" + (diff or "No changes.") + ) + + +@mcp_tool(destructive=True, idempotent=True, insiders_only=True) +def set_connector_builder_testing_values( + ctx: Context, + *, + builder_project_id: Annotated[ + str | None, + Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + ] = None, + definition_id: Annotated[ + str | None, + Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], + testing_values: Annotated[ + dict | str | None, + Field( + description=( + "Configuration values for test reads, as a JSON object or JSON string. " + "Supports inline secret refs via 'secret_reference::ENV_VAR_NAME' syntax. " + "Replaces all existing testing values." + ), + default=None, + ), + ] = None, + testing_values_secret_name: Annotated[ + str | None, + Field( + description=( + "Optional name of a secret containing testing values in JSON or YAML format. " + "The secret is resolved by the MCP server and merged into testing_values, with " + "secret values taking precedence." + ), + default=None, + ), + TraceArg.FINGERPRINT, + ] = None, +) -> str: + """Replace the testing values (connector config) of a Connector Builder project. + + Test reads use these values. Airbyte Cloud stores fields marked `airbyte_secret` in the + draft manifest's spec in its secret storage. Requires a saved draft manifest. + Don't ask users to paste secrets into the chat: send them the returned project URL to + enter secrets in the Connector Builder's testing values form instead. + """ + if testing_values is None and testing_values_secret_name is None: + raise AirbyteLibInputError( + message="Provide `testing_values` or `testing_values_secret_name`.", + ) + testing_values_dict = resolve_connector_config( + config=testing_values, + config_secret_name=testing_values_secret_name, + ) + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + _check_builder_project_created_in_session(project) + project.set_testing_values(testing_values_dict) + return ( + f"Saved testing values for Connector Builder project {project.builder_project_id}. " + f"Users can review them or enter secrets at: {project.url}" + ) + + +def _redact_stream_diagnostics(stream_data: dict[str, Any]) -> dict[str, Any]: + """Redact credentials from a test read's page URLs, error bodies, logs, and error text.""" + for page in [*stream_data.get("pages", []), *stream_data.get("http_errors", [])]: + for key in ("url", "response_body"): + if page.get(key): + page[key] = redact_agent_text(page[key]) + for log in [*stream_data.get("logs", []), *stream_data.get("problem_logs", [])]: + if isinstance(log.get("message"), str): + log["message"] = redact_agent_text(log["message"]) + if stream_data.get("error"): + stream_data["error"] = redact_agent_text(stream_data["error"]) + return stream_data + + +@mcp_tool(read_only=True, external_access=True, insiders_only=True) +def test_connector_builder_stream( + ctx: Context, + stream_name: Annotated[ + str, + Field(description="The name of the stream to test-read."), + TraceArg.FINGERPRINT, + ], + *, + builder_project_id: Annotated[ + str | None, + Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + ] = None, + definition_id: Annotated[ + str | None, + Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], + record_limit: Annotated[ + int, + Field(description="Maximum records to read (1 to 10,000).", default=100, ge=1, le=10000), + TraceArg.VALUE, + ] = 100, + page_limit: Annotated[ + int, + Field(description="Maximum pages to fetch per slice (1 to 20).", default=5, ge=1, le=20), + TraceArg.VALUE, + ] = 5, + slice_limit: Annotated[ + int, + Field(description="Maximum slices to read (1 to 20).", default=5, ge=1, le=20), + TraceArg.VALUE, + ] = 5, +) -> dict[str, Any]: + """Test-read one stream of a Connector Builder project's draft manifest on Airbyte Cloud. + + Uses the project's saved testing values as the connector config. Returns counts, a + per-page summary of HTTP requests (with response bodies for HTTP errors), connector logs, + the inferred schema, and a preview of the first records (`records` holds at most + 10 records; `record_count` is the total read). + """ + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + result = project.test_read_stream( + stream_name, + record_limit=record_limit, + page_limit=page_limit, + slice_limit=slice_limit, + ) + return { + **_redact_stream_diagnostics(result.model_dump()), + "records": result.records[:_TEST_READ_RECORDS_PREVIEW_LIMIT], + "builder_project_id": project.builder_project_id, + } + + +@mcp_tool(read_only=True, idempotent=True, external_access=True, insiders_only=True) +def resolve_connector_builder_manifest( + ctx: Context, + *, + builder_project_id: Annotated[ + str | None, + Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + ] = None, + definition_id: Annotated[ + str | None, + Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], + stream_limit: Annotated[ + int, + Field( + description="Maximum generated streams per dynamic stream (1 to 100).", + default=5, + ge=1, + le=100, + ), + TraceArg.VALUE, + ] = 5, +) -> dict[str, Any]: + """Resolve a Connector Builder project's draft manifest on Airbyte Cloud. + + Expands `$ref`s, `$parameters`, and dynamic streams (using the project's testing values), + and lists the resulting stream names. + """ + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + resolved = project.resolve_manifest(stream_limit=stream_limit) + return { + "builder_project_id": project.builder_project_id, + "stream_names": resolved.stream_names, + "resolved_manifest": resolved.manifest, + } + + +@mcp_tool(external_access=True, insiders_only=True) +def run_connector_builder_readiness_checks( + ctx: Context, + stream_names: Annotated[ + str | list[str] | None, + Field( + description=( + "Streams to test-read. The report covers only these streams. Required unless " + "`run_full_sync` is set. For dynamic streams, get the generated names from " + "`resolve_connector_builder_manifest`." + ), + default=None, + ), + TraceArg.FINGERPRINT, + ] = None, + *, + builder_project_id: Annotated[ + str | None, + Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + ] = None, + definition_id: Annotated[ + str | None, + Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], + timeout_seconds: Annotated[ + float | None, + Field( + description=( + "Optional. Return after this many seconds with the streams finished so far. " + "Streams still reading are dropped (`pending_streams`); streams not started are " + "listed in `not_attempted_streams` to pass in another call. No limit by default." + ), + default=None, + ), + ] = None, + run_full_sync: Annotated[ + bool, + Field( + description=( + "Optional. If every named stream passed (or none are named), publish the draft " + "and start a full sync of every stream to a no-op destination. Returns the job " + "in `full_sync`; poll it with `get_cloud_sync_status`." + ), + default=False, + ), + ] = False, + test_source_id: Annotated[ + str | None, + Field( + description=( + "Optional. Source on the published definition for the full sync. Defaults to " + "the test source from `create_connector_builder_project(create_test_source=True)`." + ), + default=None, + ), + ] = None, +) -> dict[str, Any]: + """Check a Connector Builder draft's progress and test-read its streams on Airbyte Cloud. + + Returns readiness checks for the steps to a finished custom source, with next steps, plus + per-stream results from parallel test reads using the project's testing values: pass/fail, + `read_to_end`, record and page counts, HTTP errors, warning and error logs, and inferred + schemas. A stream passes when it returns records with no HTTP errors or error logs. + Cloud test reads stop at 20 pages per slice and 20 slices, so a stream that hits a cap + isn't `read_to_end` and the report doesn't pass. The verdict covers only the named + streams; a full sync validates the whole connector. With `run_full_sync`, the tool + publishes the draft and starts that sync once the named streams pass. + """ + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + if run_full_sync: + _check_builder_project_created_in_session(project) + report = project.run_readiness_checks( + resolve_list_of_strings(stream_names) or [], + timeout_seconds=timeout_seconds, + run_full_sync=run_full_sync, + test_source_id=test_source_id, + on_resource_created=register_guid_created_in_session, + ) + report_data = report.model_dump() + report_data["streams"] = [ + _redact_stream_diagnostics(stream) for stream in report_data["streams"] + ] + return { + "builder_project_id": project.builder_project_id, + "connector_builder_project_url": project.url, + **report_data, + } + + +def register_connector_builder_tools(app: FastMCP) -> None: + """Register Connector Builder tools with the FastMCP app. + + Args: + app: FastMCP application instance + """ + exclude_args = ["workspace_id"] if AIRBYTE_CLOUD_WORKSPACE_ID_IS_SET else [] + if not is_trusted_execution_enabled(): + exclude_args.append("manifest_yaml_file") + if exclude_args: + _add_defaults_for_exclude_args(exclude_args) + register_mcp_tools( + app, + mcp_module=__name__, + exclude_args=exclude_args or None, + ) diff --git a/airbyte/mcp/server.py b/airbyte/mcp/server.py index dd9c12d66..7ff5bd9de 100644 --- a/airbyte/mcp/server.py +++ b/airbyte/mcp/server.py @@ -121,6 +121,7 @@ current_airbyte_user_id, ) from airbyte.mcp.cloud import register_cloud_tools +from airbyte.mcp.connector_builder import register_connector_builder_tools from airbyte.mcp.guidance import ( KAPA_API_KEY_CONFIG_ARG, KAPA_RETRIEVAL_API_URL_CONFIG_ARG, @@ -602,6 +603,7 @@ async def _mcp_mode_lifespan( # noqa: RUF029 # Register tools from each module register_cloud_tools(app) +register_connector_builder_tools(app) register_local_tools(app) register_registry_tools(app) register_interactive_tools(app) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 3513c3458..e2260f751 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -164,6 +164,7 @@ PyAirbyte server that is: - `index.md` — server overview (name, version, instructions, totals, module table) - `cloud.md` — tools registered by `airbyte.mcp.cloud` +- `connector_builder.md` — tools registered by `airbyte.mcp.connector_builder` - `local.md` — tools registered by `airbyte.mcp.local` - `registry.md` — tools registered by `airbyte.mcp.registry` - `guidance.md` — tools and prompts registered by `airbyte.mcp.guidance` diff --git a/docs/generate.py b/docs/generate.py index 197c65ce7..c28f879d8 100755 --- a/docs/generate.py +++ b/docs/generate.py @@ -22,7 +22,7 @@ def _regenerate_mcp_markdown() -> None: """Regenerate `docs/mcp-generated/` before pdoc runs. - The `airbyte.mcp.{cloud,local,interactive,registry,guidance}` modules pull the + The `airbyte.mcp.{cloud,connector_builder,local,interactive,registry,guidance}` modules pull the per-module Markdown files from `docs/mcp-generated/` via pdoc's `.. include::` directive. That directory is git-ignored, so on a clean checkout pdoc would fail to resolve the include unless we regenerate it diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index d2dae7002..1069b5679 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -18,8 +18,13 @@ ConnectorBuilderPageSummary, ConnectorBuilderResolvedManifest, ConnectorBuilderStreamReadResult, +) +from airbyte.cloud.connector_builder._util import ( apply_manifest_text_edit, + drop_masked_secrets, + publish_spec, render_manifest_text, + spec_has_secrets, ) from airbyte.cloud.workspaces import CloudWorkspace from airbyte.exceptions import AirbyteDuplicateResourcesError, AirbyteLibInputError @@ -576,7 +581,7 @@ def test_run_readiness_checks_judges_each_stream( by_name["dropped"].error == "Request to Airbyte Cloud failed: ConnectionError" ) assert ( - report.checklist[4].detail + report.checks[4].detail == "failed: empty, http_error, error_log, broken, dropped" ) @@ -618,13 +623,13 @@ def test_readiness_report_passes_only_when_every_stream_is_read_to_end( assert capped.passed_streams == ["users", "orders"] assert "hit a test-read cap" in capped.next_steps[-1] assert [s.read_to_end for s in capped.streams] == [True, False] - assert capped.checklist[5].done is False + assert capped.checks[5].result == "failed" assert full.passed is True assert full.passed_streams == ["users", "orders"] assert full.next_steps[0].startswith("All named streams passed.") -def test_readiness_checklist_requires_testing_values_when_spec_has_inputs( +def test_readiness_checks_require_testing_values_when_spec_has_inputs( monkeypatch: pytest.MonkeyPatch, ) -> None: draft = { @@ -638,15 +643,15 @@ def test_readiness_checklist_requires_testing_values_when_spec_has_inputs( report = project.run_readiness_checks(["users"]) - assert [item.done for item in report.checklist] == [ - True, - True, - True, - False, - True, - True, - True, - None, + assert [check.result for check in report.checks] == [ + "passed", + "passed", + "passed", + "failed", + "passed", + "passed", + "passed", + "unknown", ] assert report.passed is True @@ -660,7 +665,7 @@ def test_publish_spec_uses_api_field_names() -> None: } } - spec = connector_builder._publish_spec(manifest) + spec = publish_spec(manifest) assert spec["connectionSpecification"] == {"type": "object"} assert spec["documentationUrl"] == "https://example.com/docs" @@ -755,7 +760,7 @@ def test_drop_masked_secrets() -> None: "items": [{"secret": "**********", "id": 1}], } - assert connector_builder._drop_masked_secrets(values) == { + assert drop_masked_secrets(values) == { "region": "us", "auth": {"kind": "bearer"}, "items": [{"id": 1}], @@ -783,7 +788,7 @@ def test_drop_masked_secrets() -> None: ], ) def test_spec_has_secrets(spec: dict[str, Any], expected: bool) -> None: - assert connector_builder._spec_has_secrets(spec) is expected + assert spec_has_secrets(spec) is expected def _secret_manifest() -> dict[str, Any]: @@ -1168,10 +1173,10 @@ def test_run_readiness_checks_full_sync_only_without_draft( assert report.full_sync.destination_created is False project.publish.assert_not_called() workspace.deploy_connection.assert_called_once() - draft_step = next( - item for item in report.checklist if item.step == "Save a draft manifest" + draft_check = next( + check for check in report.checks if check.description == "Save a draft manifest" ) - assert draft_step.done is False + assert draft_check.result == "failed" def test_run_full_sync_rejects_non_noop_destination_with_default_name( diff --git a/tests/unit_tests/test_mcp_cloud.py b/tests/unit_tests/test_mcp_cloud.py index caf65edfd..91c47d104 100644 --- a/tests/unit_tests/test_mcp_cloud.py +++ b/tests/unit_tests/test_mcp_cloud.py @@ -39,6 +39,7 @@ ) from airbyte.constants import MCP_DISABLE_DELETE_GUARD_ENV_VAR, MCP_INSIDERS_ENV_VAR from airbyte.mcp import cloud as cloud_mcp +from airbyte.mcp import connector_builder as connector_builder_mcp from airbyte.mcp import guidance as guidance_mcp from airbyte.mcp._arg_resolvers import resolve_list_of_dicts from airbyte.mcp._tool_utils import _TOOL_POLICIES # noqa: PLC2701 @@ -3574,7 +3575,7 @@ def test_request_cloud_search_index_backfill_is_a_write_tool() -> None: def test_redact_stream_diagnostics() -> None: leaky = "GET https://api.example.com/data?api_key=s3cret failed: Bearer s3cret" - data = cloud_mcp._redact_stream_diagnostics({ + data = connector_builder_mcp._redact_stream_diagnostics({ "pages": [{"url": leaky, "response_body": leaky}], "http_errors": [{"url": leaky, "response_body": None}], "logs": [{"level": "WARN", "message": leaky}], diff --git a/tests/unit_tests/test_mcp_docs_includes.py b/tests/unit_tests/test_mcp_docs_includes.py index e8d311baa..d93dc5911 100644 --- a/tests/unit_tests/test_mcp_docs_includes.py +++ b/tests/unit_tests/test_mcp_docs_includes.py @@ -49,6 +49,7 @@ def test_existing_includes_name_generated_mcp_modules() -> None: repo_root = Path(__file__).parents[2] generated_modules = { "cloud", + "connector_builder", "local", "interactive", "registry", diff --git a/tests/unit_tests/test_mcp_trusted_execution.py b/tests/unit_tests/test_mcp_trusted_execution.py index 9ac046437..1f3ea2d63 100644 --- a/tests/unit_tests/test_mcp_trusted_execution.py +++ b/tests/unit_tests/test_mcp_trusted_execution.py @@ -18,7 +18,7 @@ AirbyteLibInputError, AirbyteTrustedExecutionRequiredError, ) -from airbyte.mcp import cloud, local +from airbyte.mcp import cloud, connector_builder, local from airbyte.mcp._arg_resolvers import resolve_connector_config, resolve_manifest_yaml from airbyte.mcp._guards import ( is_trusted_execution_enabled, @@ -261,6 +261,7 @@ def test_manifest_file_schema_depends_on_trusted_execution( _set_trusted(monkeypatch, enabled=enabled) app = FastMCP(f"manifest-schema-{enabled}") cloud.register_cloud_tools(app) + connector_builder.register_connector_builder_tools(app) async def list_tools() -> dict[str, object]: async with Client(app) as client: From b8c793ac321eb0c74eaa9a6a74f980636e466331 Mon Sep 17 00:00:00 2001 From: "Aaron (\"AJ\") Steers" Date: Mon, 19 Oct 2026 12:07:58 -0700 Subject: [PATCH 26/47] fix(mcp): polish Connector Builder base PR with additional fixes and improvements (#1408) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- airbyte/cloud/connector_builder/__init__.py | 6 + airbyte/cloud/connector_builder/_util.py | 55 ++- airbyte/cloud/connector_builder/models.py | 81 +++- airbyte/cloud/connector_builder/project.py | 60 ++- airbyte/mcp/connector_builder.py | 51 ++- .../test_cloud_connector_builder.py | 350 ++++++++++++++++-- tests/unit_tests/test_mcp_cloud.py | 64 ++++ 7 files changed, 571 insertions(+), 96 deletions(-) diff --git a/airbyte/cloud/connector_builder/__init__.py b/airbyte/cloud/connector_builder/__init__.py index e5e60dda0..e88783b2c 100644 --- a/airbyte/cloud/connector_builder/__init__.py +++ b/airbyte/cloud/connector_builder/__init__.py @@ -22,6 +22,9 @@ ) from airbyte.cloud.connector_builder.project import ( CONNECTOR_BUILDER_TEST_DESTINATION_NAME, + TEST_READ_MAX_PAGES, + TEST_READ_MAX_RECORDS, + TEST_READ_MAX_SLICES, CloudConnectorBuilderProject, ) @@ -39,4 +42,7 @@ "ConnectorBuilderStreamReadResult", "ManifestTextEditMode", "ReadinessCheckResult", + "TEST_READ_MAX_PAGES", + "TEST_READ_MAX_RECORDS", + "TEST_READ_MAX_SLICES", ] diff --git a/airbyte/cloud/connector_builder/_util.py b/airbyte/cloud/connector_builder/_util.py index 1d552914b..64948f752 100644 --- a/airbyte/cloud/connector_builder/_util.py +++ b/airbyte/cloud/connector_builder/_util.py @@ -141,8 +141,12 @@ def readiness_next_steps( not_attempted_streams: list[str], ) -> list[str]: steps = [check.description for check in checks[:4] if check.result != "passed"] - failed = [stream.stream_name for stream in streams if not stream.passed] - capped = [stream.stream_name for stream in streams if stream.passed and not stream.read_to_end] + failed = [stream.stream_name for stream in streams if stream.failed] + capped = [ + stream.stream_name + for stream in streams + if stream.error is None and not stream.http_errors and not stream.read_to_end + ] if failed: steps.append(f"Fix the failed streams and test them again: {', '.join(failed)}") if pending_streams: @@ -156,16 +160,14 @@ def readiness_next_steps( "Run the report again with the not-attempted streams, skipping any already " f"validated or unchanged since: {', '.join(not_attempted_streams)}" ) - if not (failed or pending_streams or not_attempted_streams): - steps.append( - "All named streams passed. Once every stream is validated, publish the custom " - "source definition (if not yet) and run a full sync of every stream to a no-op " - "destination." - ) if capped: steps.append( - f"These streams hit a test-read cap; check them with the full sync: {', '.join(capped)}" + "These streams hit a test-read cap, so later pages weren't read; " + f"check them with a full sync: {', '.join(capped)}" ) + warning_streams = [stream.stream_name for stream in streams if stream.warnings] + if warning_streams: + steps.append(f"Report these streams' `warnings` to the user: {', '.join(warning_streams)}") return steps @@ -180,8 +182,15 @@ def readiness_checks( takes_no_inputs = isinstance(spec, dict) and not spec.get("properties") testing_values = project_data.get("testingValues") or {} source_definition_id = project_data["builderProject"].get("sourceDefinitionId") - failed = [stream.stream_name for stream in streams if not stream.passed] - not_read_to_end = [stream.stream_name for stream in streams if not stream.read_to_end] + failed = [stream.stream_name for stream in streams if stream.failed] + capped = [ + stream.stream_name + for stream in streams + if stream.error is None and not stream.http_errors and not stream.read_to_end + ] + not_read = [ + stream.stream_name for stream in streams if stream.error is not None or stream.http_errors + ] return [ ConnectorBuilderReadinessCheck( description="Save a draft manifest", @@ -201,7 +210,7 @@ def readiness_checks( detail=url, ), ConnectorBuilderReadinessCheck( - description="Pass a test read on the named streams", + description="Test-read the named streams without errors", result=_result(not failed and not pending_streams), detail="; ".join( part @@ -215,13 +224,21 @@ def readiness_checks( ), ConnectorBuilderReadinessCheck( description="Read the named streams to the end within the test-read caps", - result=_result(not not_read_to_end and not pending_streams), - detail=( - f"hit a cap: {', '.join(not_read_to_end)}. Check these with a full sync " - "after publishing." - if not_read_to_end - else None - ), + result=_result(not pending_streams and all(stream.read_to_end for stream in streams)), + detail="; ".join( + part + for part in ( + ( + f"hit a cap: {', '.join(capped)}. Check these with a full sync " + "after publishing." + ) + if capped + else "", + f"not read: {', '.join(not_read)} (test read failed)" if not_read else "", + ) + if part + ) + or None, ), ConnectorBuilderReadinessCheck( description="Publish the custom source definition", diff --git a/airbyte/cloud/connector_builder/models.py b/airbyte/cloud/connector_builder/models.py index 28e810a45..df7023165 100644 --- a/airbyte/cloud/connector_builder/models.py +++ b/airbyte/cloud/connector_builder/models.py @@ -33,6 +33,40 @@ class ConnectorBuilderPageSummary(BaseModel): ) +def _record_count_warnings( + record_count: int, + pages: list[ConnectorBuilderPageSummary], + *, + test_read_limit_reached: bool, +) -> list[str]: + """Return informational hints for record counts that may indicate a partial read.""" + if test_read_limit_reached: + return [] + if record_count == 0: + return ["Read 0 records. Check the request, record selector, and testing values."] + + warnings = [] + if record_count % 10 == 0: + warnings.append( + f"Read {record_count:,} records, a multiple of 10. A page size or API limit may " + "have cut the read short; check the paginator." + ) + + record_pages = [page for page in pages if page.record_count > 0] + if ( + record_pages + and record_pages[0].record_count > 1 + and pages[-1].record_count > 0 + and pages[-1].record_count == record_pages[0].record_count + ): + record_count_per_page = record_pages[0].record_count + warnings.append( + f"The last page returned a full page of {record_count_per_page:,} records, the " + "same as the first page, so more pages may exist; check the paginator." + ) + return warnings + + class ConnectorBuilderStreamReadResult(BaseModel): """Result of a Connector Builder test read of one stream.""" @@ -45,6 +79,13 @@ class ConnectorBuilderStreamReadResult(BaseModel): pages: list[ConnectorBuilderPageSummary] logs: list[dict[str, Any]] inferred_schema: dict[str, Any] | None = None + warnings: list[str] = Field( + default_factory=list, + description=( + "Hints that the read may be incomplete, such as round record counts. " + "They don't fail the read." + ), + ) @classmethod def from_api_response( @@ -77,12 +118,13 @@ def from_api_response( ) ) + test_read_limit_reached = bool(response.get("test_read_limit_reached")) return cls( stream_name=stream_name, record_count=len(records), slice_count=len(slices), page_count=len(pages), - test_read_limit_reached=bool(response.get("test_read_limit_reached")), + test_read_limit_reached=test_read_limit_reached, records=records, pages=pages, logs=[ @@ -90,6 +132,11 @@ def from_api_response( for log in response.get("logs") or [] ], inferred_schema=response.get("inferred_schema"), + warnings=_record_count_warnings( + len(records), + pages, + test_read_limit_reached=test_read_limit_reached, + ), ) @@ -129,19 +176,31 @@ class ConnectorBuilderStreamReadiness(BaseModel): """Readiness of one stream, from a Connector Builder test read.""" stream_name: str - passed: bool + failed: bool = Field( + description=( + "True when the test read raised an error, returned HTTP errors, or logged errors." + ), + ) record_count: int = 0 page_count: int = 0 read_to_end: bool = Field( default=False, description=( - "True when the stream ended within the test-read caps. False means the read hit a " - "record, page, or slice cap, so later pages were never read." + "True when the stream ended within the test-read caps without HTTP errors. False " + "means a record, page, or slice cap was hit, an HTTP error occurred, or the read " + "did not run (see `error`)." ), ) http_errors: list[ConnectorBuilderPageSummary] = Field(default_factory=list) problem_logs: list[dict[str, Any]] = Field(default_factory=list) inferred_schema: dict[str, Any] | None = None + warnings: list[str] = Field( + default_factory=list, + description=( + "Hints that the read may be incomplete, such as round record counts. " + "They don't fail the read." + ), + ) error: str | None = Field( default=None, description="The error raised by the test read itself, if it failed to run.", @@ -151,7 +210,7 @@ class ConnectorBuilderStreamReadiness(BaseModel): def from_stream_read( cls, result: ConnectorBuilderStreamReadResult ) -> ConnectorBuilderStreamReadiness: - """Judge a stream from its test read: records returned, no HTTP or error logs.""" + """Summarize errors and results from a stream test read.""" http_errors = [ page for page in result.pages @@ -163,13 +222,14 @@ def from_stream_read( ) return cls( stream_name=result.stream_name, - passed=result.record_count > 0 and not has_errors, + failed=has_errors, record_count=result.record_count, page_count=result.page_count, - read_to_end=not result.test_read_limit_reached, + read_to_end=not result.test_read_limit_reached and not http_errors, http_errors=http_errors, problem_logs=problem_logs, inferred_schema=result.inferred_schema, + warnings=result.warnings, ) @@ -200,13 +260,6 @@ class ConnectorBuilderFullSync(BaseModel): class ConnectorBuilderReadinessReport(BaseModel): """Readiness checks and per-stream test reads for a Connector Builder draft.""" - passed: bool = Field( - description=( - "True when every named stream passed and was read to the end. Covers only the " - "named streams; a full sync validates the whole connector." - ), - ) - passed_streams: list[str] = Field(default_factory=list) failed_streams: list[str] = Field(default_factory=list) pending_streams: list[str] = Field( default_factory=list, diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index bf409544c..e379530f0 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -51,6 +51,33 @@ """Shortest HTTP timeout for a test read started just before the readiness deadline.""" CONNECTOR_BUILDER_TEST_DESTINATION_NAME = "Connector Builder Test (No-op)" """Name of the no-op destination that full syncs find or create by default.""" +TEST_READ_MAX_RECORDS = 5_000 +"""Most records read per stream by `run_readiness_checks()`.""" +TEST_READ_MAX_PAGES = 20 +TEST_READ_MAX_SLICES = 20 + + +def _check_test_read_limits( + record_limit: int, + page_limit: int, + slice_limit: int, +) -> None: + """Reject test-read limits outside Airbyte Cloud's supported range.""" + limits = ( + ("record_limit", record_limit, TEST_READ_MAX_RECORDS), + ("page_limit", page_limit, TEST_READ_MAX_PAGES), + ("slice_limit", slice_limit, TEST_READ_MAX_SLICES), + ) + if any(not 1 <= value <= maximum for _, value, maximum in limits): + raise exc.AirbyteLibInputError( + message="Test-read limits are out of range.", + guidance=( + f"Airbyte Cloud accepts record_limit 1 to {TEST_READ_MAX_RECORDS:,}, " + f"page_limit 1 to {TEST_READ_MAX_PAGES}, and " + f"slice_limit 1 to {TEST_READ_MAX_SLICES}." + ), + context={name: value for name, value, _ in limits}, + ) class CloudConnectorBuilderProject: # noqa: PLR0904 # Builder project API facade. @@ -271,10 +298,11 @@ def test_read_stream( Args: stream_name: The stream to read. - record_limit: Maximum records to return (1 to 10,000). + record_limit: Maximum records to return (1 to 5,000). page_limit: Maximum pages to fetch per slice (1 to 20). slice_limit: Maximum slices to read (1 to 20). """ + _check_test_read_limits(record_limit, page_limit, slice_limit) return self._test_read_stream( self._require_draft_manifest(), stream_name, @@ -339,9 +367,9 @@ def run_readiness_checks( self, stream_names: list[str], *, - record_limit: int = 10_000, - page_limit: int = 20, - slice_limit: int = 20, + record_limit: int = TEST_READ_MAX_RECORDS, + page_limit: int = TEST_READ_MAX_PAGES, + slice_limit: int = TEST_READ_MAX_SLICES, timeout_seconds: float | None = None, run_full_sync: bool = False, test_source_id: str | None = None, @@ -349,26 +377,27 @@ def run_readiness_checks( ) -> ConnectorBuilderReadinessReport: """Check the draft's progress and test-read the given streams on Airbyte Cloud. - Reads run in parallel. A stream passes when its test read returns records with no - HTTP errors or error logs, and is `read_to_end` when it ended within the test-read - caps (which default to Cloud's maximums). The readiness checks are derived from the project, - so nothing is stored. `passed` covers only the named streams: each must pass and be - read to the end. A full sync after publishing validates the whole connector. + Reads run in parallel. A stream is `failed` when its read errored, returned HTTP + errors, or logged errors. The report lists failures and warnings without judging + readiness; `read_to_end` indicates whether the read ended within the test-read caps. + The readiness checks are derived from the project, so nothing is stored. A full sync + after publishing validates the whole connector. Args: stream_names: Streams to test-read (at least 1). For dynamic streams, get the generated names from `resolve_manifest()`. - record_limit: Maximum records to read per stream (1 to 10,000). + record_limit: Maximum records to read per stream (1 to 5,000). page_limit: Maximum pages to fetch per slice (1 to 20). slice_limit: Maximum slices to read (1 to 20). timeout_seconds: Return after this many seconds with the streams finished so far; the rest are listed in `pending_streams` and `not_attempted_streams`. No limit by default. - run_full_sync: If every named stream finished and passed (or none are named), + run_full_sync: If no named stream failed or is still pending (or none are named), publish the draft and start a full sync with `run_full_sync()`. test_source_id: The source for the full sync. See `run_full_sync()`. on_resource_created: Passed to `run_full_sync()`. """ + _check_test_read_limits(record_limit, page_limit, slice_limit) stream_names = list(dict.fromkeys(stream_names)) if not stream_names and not run_full_sync: raise exc.AirbyteLibInputError( @@ -414,12 +443,12 @@ def read(stream_name: str) -> ConnectorBuilderStreamReadiness: if ex.guidance: error += f" {ex.guidance}" return ConnectorBuilderStreamReadiness( - stream_name=stream_name, passed=False, error=error + stream_name=stream_name, failed=True, error=error ) except requests.RequestException as ex: return ConnectorBuilderStreamReadiness( stream_name=stream_name, - passed=False, + failed=True, error=f"Request to Airbyte Cloud failed: {type(ex).__name__}", ) return ConnectorBuilderStreamReadiness.from_stream_read(result) @@ -461,10 +490,7 @@ def _readiness_report( remaining = pending_streams + not_attempted_streams checks = readiness_checks(project_data, draft, streams, remaining, self.url) return ConnectorBuilderReadinessReport( - passed=not remaining - and all(stream.passed and stream.read_to_end for stream in streams), - passed_streams=[stream.stream_name for stream in streams if stream.passed], - failed_streams=[stream.stream_name for stream in streams if not stream.passed], + failed_streams=[stream.stream_name for stream in streams if stream.failed], pending_streams=pending_streams, not_attempted_streams=not_attempted_streams, checks=checks, diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index 7d07ed94c..632e82496 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -15,6 +15,9 @@ from pydantic import Field from airbyte.cloud.connector_builder import ( + TEST_READ_MAX_PAGES, + TEST_READ_MAX_RECORDS, + TEST_READ_MAX_SLICES, ManifestTextEditMode, ) from airbyte.exceptions import ( @@ -392,17 +395,32 @@ def test_connector_builder_stream( ], record_limit: Annotated[ int, - Field(description="Maximum records to read (1 to 10,000).", default=100, ge=1, le=10000), + Field( + description=f"Maximum records to read (1 to {TEST_READ_MAX_RECORDS:,}).", + default=100, + ge=1, + le=TEST_READ_MAX_RECORDS, + ), TraceArg.VALUE, ] = 100, page_limit: Annotated[ int, - Field(description="Maximum pages to fetch per slice (1 to 20).", default=5, ge=1, le=20), + Field( + description=f"Maximum pages to fetch per slice (1 to {TEST_READ_MAX_PAGES}).", + default=5, + ge=1, + le=TEST_READ_MAX_PAGES, + ), TraceArg.VALUE, ] = 5, slice_limit: Annotated[ int, - Field(description="Maximum slices to read (1 to 20).", default=5, ge=1, le=20), + Field( + description=f"Maximum slices to read (1 to {TEST_READ_MAX_SLICES}).", + default=5, + ge=1, + le=TEST_READ_MAX_SLICES, + ), TraceArg.VALUE, ] = 5, ) -> dict[str, Any]: @@ -411,7 +429,11 @@ def test_connector_builder_stream( Uses the project's saved testing values as the connector config. Returns counts, a per-page summary of HTTP requests (with response bodies for HTTP errors), connector logs, the inferred schema, and a preview of the first records (`records` holds at most - 10 records; `record_count` is the total read). + 10 records; `record_count` is the total read). `test_read_limit_reached` true means later + records weren't read. Cloud stops at page boundaries, so `record_count` can exceed + `record_limit`. + `warnings` flags reads that may be incomplete (round record counts, a full last page); + check them, but they don't fail the read. """ workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) project = workspace.get_connector_builder_project( @@ -527,9 +549,9 @@ def run_connector_builder_readiness_checks( bool, Field( description=( - "Optional. If every named stream passed (or none are named), publish the draft " - "and start a full sync of every stream to a no-op destination. Returns the job " - "in `full_sync`; poll it with `get_cloud_sync_status`." + "Optional. If no named stream failed or is still pending (or none are named), " + "publish the draft and start a full sync of every stream to a no-op destination. " + "Returns the job in `full_sync`; poll it with `get_cloud_sync_status`." ), default=False, ), @@ -548,13 +570,16 @@ def run_connector_builder_readiness_checks( """Check a Connector Builder draft's progress and test-read its streams on Airbyte Cloud. Returns readiness checks for the steps to a finished custom source, with next steps, plus - per-stream results from parallel test reads using the project's testing values: pass/fail, + per-stream results from parallel test reads using the project's testing values: `failed`, `read_to_end`, record and page counts, HTTP errors, warning and error logs, and inferred - schemas. A stream passes when it returns records with no HTTP errors or error logs. - Cloud test reads stop at 20 pages per slice and 20 slices, so a stream that hits a cap - isn't `read_to_end` and the report doesn't pass. The verdict covers only the named - streams; a full sync validates the whole connector. With `run_full_sync`, the tool - publishes the draft and starts that sync once the named streams pass. + schemas. A stream is `failed` when its read errored, returned HTTP errors, or logged + errors. Cloud test reads stop at 5,000 records, 20 pages per slice, and 20 slices, so a + stream that hits a cap isn't `read_to_end`. The report calls out failures and warnings + without judging readiness; report every failure and warning to the user. A full sync + validates the whole connector. With `run_full_sync`, the tool publishes the draft and + starts that sync once no named stream failed or is pending. + `warnings` flags reads that may be incomplete (round record counts, a full last page); + check them, but they don't fail the read. """ workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) project = workspace.get_connector_builder_project( diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 1069b5679..a4af43a07 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -17,8 +17,10 @@ CloudConnectorBuilderProject, ConnectorBuilderPageSummary, ConnectorBuilderResolvedManifest, + ConnectorBuilderStreamReadiness, ConnectorBuilderStreamReadResult, ) +from airbyte.cloud.connector_builder.models import _record_count_warnings from airbyte.cloud.connector_builder._util import ( apply_manifest_text_edit, drop_masked_secrets, @@ -27,7 +29,11 @@ spec_has_secrets, ) from airbyte.cloud.workspaces import CloudWorkspace -from airbyte.exceptions import AirbyteDuplicateResourcesError, AirbyteLibInputError +from airbyte.exceptions import ( + AirbyteCloudError, + AirbyteDuplicateResourcesError, + AirbyteLibInputError, +) from airbyte.secrets.base import SecretString MANIFEST: dict[str, Any] = { @@ -140,6 +146,103 @@ def test_stream_read_result_from_api_response() -> None: assert result.inferred_schema == {"type": "object"} +@pytest.mark.parametrize( + ("record_count", "page_counts", "limit_reached", "expected"), + [ + pytest.param( + 100, + [100], + False, + [ + "Read 100 records, a multiple of 10. A page size or API limit may have cut " + "the read short; check the paginator.", + "The last page returned a full page of 100 records, the same as the first " + "page, so more pages may exist; check the paginator.", + ], + id="round-single-page", + ), + pytest.param(7, [5, 2], False, [], id="non-round-partial-last-page"), + pytest.param(5, [5, 0], False, [], id="empty-final-page"), + pytest.param( + 10, + [5, 5], + False, + [ + "Read 10 records, a multiple of 10. A page size or API limit may have cut " + "the read short; check the paginator.", + "The last page returned a full page of 5 records, the same as the first " + "page, so more pages may exist; check the paginator.", + ], + id="full-final-page", + ), + pytest.param( + 0, + [0], + False, + ["Read 0 records. Check the request, record selector, and testing values."], + id="zero-records", + ), + pytest.param(1, [1], False, [], id="one-record"), + pytest.param( + 150, + [50, 50, 50], + False, + [ + "Read 150 records, a multiple of 10. A page size or API limit may have cut " + "the read short; check the paginator.", + "The last page returned a full page of 50 records, the same as the first " + "page, so more pages may exist; check the paginator.", + ], + id="multiple-full-pages", + ), + pytest.param(100, [100], True, [], id="reached-test-read-limit"), + ], +) +def test_record_count_warnings( + record_count: int, + page_counts: list[int], + limit_reached: bool, + expected: list[str], +) -> None: + pages = [ConnectorBuilderPageSummary(record_count=count) for count in page_counts] + + assert ( + _record_count_warnings( + record_count, + pages, + test_read_limit_reached=limit_reached, + ) + == expected + ) + + +@pytest.mark.parametrize("limit_reached", [False, True]) +def test_stream_read_warnings_propagate_to_readiness_without_failing( + limit_reached: bool, +) -> None: + response = { + "slices": [ + { + "pages": [ + {"records": [{"id": index} for index in range(100)]}, + ] + } + ], + "test_read_limit_reached": limit_reached, + } + + result = ConnectorBuilderStreamReadResult.from_api_response("users", response) + readiness = ConnectorBuilderStreamReadiness.from_stream_read(result) + + if limit_reached: + assert result.warnings == [] + else: + assert len(result.warnings) == 2 + assert readiness.warnings == result.warnings + assert readiness.failed is False + assert readiness.read_to_end is not limit_reached + + def test_resolved_manifest_lists_stream_names() -> None: resolved = ConnectorBuilderResolvedManifest.from_manifest(MANIFEST) assert resolved.stream_names == ["users", "orders"] @@ -526,7 +629,7 @@ def fake_read( monkeypatch.setattr(project, "_test_read_stream", fake_read) -def test_run_readiness_checks_judges_each_stream( +def test_run_readiness_checks_summarizes_each_stream( monkeypatch: pytest.MonkeyPatch, ) -> None: names = ["ok", "empty", "http_error", "error_log", "warn_log", "broken", "dropped"] @@ -539,8 +642,11 @@ def test_run_readiness_checks_judges_each_stream( project, { "ok": _stream_read("ok", record_count=3), - "empty": _stream_read("empty", record_count=0), - "http_error": _stream_read("http_error", record_count=1, status=401), + "empty": ConnectorBuilderStreamReadResult.from_api_response( + "empty", + {"slices": [{"pages": [{"records": []}]}]}, + ), + "http_error": _stream_read("http_error", record_count=1, status=422), "error_log": _stream_read( "error_log", record_count=1, @@ -555,35 +661,43 @@ def test_run_readiness_checks_judges_each_stream( report = project.run_readiness_checks(names) assert [stream.stream_name for stream in report.streams] == names - assert {stream.stream_name: stream.passed for stream in report.streams} == { - "ok": True, + assert {stream.stream_name: stream.failed for stream in report.streams} == { + "ok": False, "empty": False, - "http_error": False, - "error_log": False, - "warn_log": True, - "broken": False, - "dropped": False, + "http_error": True, + "error_log": True, + "warn_log": False, + "broken": True, + "dropped": True, } - assert report.passed is False - assert report.passed_streams == ["ok", "warn_log"] assert report.failed_streams == [ - "empty", "http_error", "error_log", "broken", "dropped", ] by_name = {stream.stream_name: stream for stream in report.streams} - assert by_name["http_error"].http_errors[0].status == 401 + assert by_name["empty"].warnings == [ + "Read 0 records. Check the request, record selector, and testing values." + ] + assert "empty" not in report.failed_streams + assert by_name["http_error"].http_errors[0].status == 422 + assert by_name["http_error"].read_to_end is False assert by_name["warn_log"].problem_logs == [{"level": "WARN", "message": "hmm"}] assert by_name["broken"].error == "read failed Fix the stream." assert ( by_name["dropped"].error == "Request to Airbyte Cloud failed: ConnectionError" ) + assert report.checks[4].detail == "failed: http_error, error_log, broken, dropped" + assert report.checks[5].result == "failed" + assert "not read: http_error, broken, dropped (test read failed)" in ( + report.checks[5].detail or "" + ) assert ( - report.checks[4].detail - == "failed: empty, http_error, error_log, broken, dropped" + report.next_steps[-1] == "Report these streams' `warnings` to the user: empty" ) + assert not any("All named streams passed" in step for step in report.next_steps) + assert not any("ready" in step.lower() for step in report.next_steps) def test_run_readiness_checks_requires_stream_names() -> None: @@ -592,6 +706,127 @@ def test_run_readiness_checks_requires_stream_names() -> None: project.run_readiness_checks([]) +@pytest.mark.parametrize("method", ["test_read_stream", "run_readiness_checks"]) +@pytest.mark.parametrize( + ("limit_name", "limit_value"), + [ + ("record_limit", 5_001), + ("page_limit", 21), + ("slice_limit", 21), + ], +) +def test_test_read_limits_reject_out_of_range_without_api_calls( + monkeypatch: pytest.MonkeyPatch, + method: str, + limit_name: str, + limit_value: int, +) -> None: + def unexpected_api_call(*_: Any, **__: Any) -> Any: + pytest.fail("A test-read limit was rejected only after an API call.") + + monkeypatch.setattr(api_util, "get_connector_builder_project", unexpected_api_call) + monkeypatch.setattr( + api_util, "read_connector_builder_project_stream", unexpected_api_call + ) + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + expected: dict[str, int] = ( + {"record_limit": 100, "page_limit": 5, "slice_limit": 5} + if method == "test_read_stream" + else { + "record_limit": connector_builder.TEST_READ_MAX_RECORDS, + "page_limit": connector_builder.TEST_READ_MAX_PAGES, + "slice_limit": connector_builder.TEST_READ_MAX_SLICES, + } + ) + + with pytest.raises(AirbyteLibInputError) as error: + if method == "test_read_stream": + project.test_read_stream("users", **{limit_name: limit_value}) + else: + project.run_readiness_checks(["users"], **{limit_name: limit_value}) + + expected[limit_name] = limit_value + assert error.value.get_message() == "Test-read limits are out of range." + assert error.value.context == expected + + +def test_readiness_report_defaults_forward_cloud_test_read_limits( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store(monkeypatch, draft={"streams": [{"name": "users"}]}) + received: list[dict[str, Any]] = [] + + def fake_read(**kwargs: Any) -> dict[str, Any]: + received.append(kwargs) + return {"slices": [{"pages": [{"records": [{"id": 1}]}]}]} + + monkeypatch.setattr(api_util, "read_connector_builder_project_stream", fake_read) + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + + project.run_readiness_checks(["users"]) + + assert len(received) == 1 + assert received[0]["record_limit"] == connector_builder.TEST_READ_MAX_RECORDS + assert received[0]["page_limit"] == connector_builder.TEST_READ_MAX_PAGES + assert received[0]["slice_limit"] == connector_builder.TEST_READ_MAX_SLICES + + +def test_readiness_checklist_does_not_call_failed_reads_capped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store(monkeypatch, draft={"streams": [{"name": "users"}]}) + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + + def fail_read(*_: Any, **__: Any) -> ConnectorBuilderStreamReadResult: + raise AirbyteCloudError(message="Cloud read failed.") + + monkeypatch.setattr(project, "_test_read_stream", fail_read) + + report = project.run_readiness_checks(["users"]) + + assert report.streams[0].read_to_end is False + assert report.checks[5].result == "failed" + assert report.checks[5].detail == "not read: users (test read failed)" + assert "hit a cap" not in (report.checks[5].detail or "") + + +def test_readiness_checklist_marks_failed_capped_stream_incomplete( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store(monkeypatch, draft={"streams": [{"name": "users"}]}) + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + _patch_reads( + monkeypatch, + project, + { + "users": _stream_read( + "users", + record_count=1, + logs=[{"level": "ERROR", "message": "read failed"}], + limit_reached=True, + ) + }, + ) + + report = project.run_readiness_checks(["users"]) + + assert report.streams[0].failed is True + assert report.streams[0].error is None + assert report.streams[0].http_errors == [] + assert report.failed_streams == ["users"] + assert report.checks[5].result == "failed" + assert report.checks[5].detail == ( + "hit a cap: users. Check these with a full sync after publishing." + ) + assert any( + "Fix the failed streams and test them again: users" in step + for step in report.next_steps + ) + assert any( + "hit a test-read cap" in step and "users" in step for step in report.next_steps + ) + + def test_run_readiness_checks_without_draft_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -602,7 +837,7 @@ def test_run_readiness_checks_without_draft_fails( ]) -def test_readiness_report_passes_only_when_every_stream_is_read_to_end( +def test_readiness_report_lists_caps_without_a_success_verdict( monkeypatch: pytest.MonkeyPatch, ) -> None: _patch_project_store(monkeypatch, draft=MANIFEST) @@ -618,15 +853,22 @@ def test_readiness_report_passes_only_when_every_stream_is_read_to_end( reads["orders"] = _stream_read("orders", record_count=1) full = project.run_readiness_checks(["users", "orders", "users"]) - assert partial.passed is True - assert capped.passed is False - assert capped.passed_streams == ["users", "orders"] - assert "hit a test-read cap" in capped.next_steps[-1] + assert partial.failed_streams == [] + assert capped.failed_streams == [] + assert "passed" not in partial.model_dump() + assert any( + "These streams hit a test-read cap, so later pages weren't read; " + "check them with a full sync: orders" == step + for step in capped.next_steps + ) + assert not any("Not ready yet" in step for step in capped.next_steps) + assert not any("All named streams passed" in step for step in capped.next_steps) + assert not any("ready" in step.lower() for step in capped.next_steps) assert [s.read_to_end for s in capped.streams] == [True, False] assert capped.checks[5].result == "failed" - assert full.passed is True - assert full.passed_streams == ["users", "orders"] - assert full.next_steps[0].startswith("All named streams passed.") + assert full.failed_streams == [] + assert not any("All named streams passed" in step for step in full.next_steps) + assert not any("ready" in step.lower() for step in full.next_steps) def test_readiness_checks_require_testing_values_when_spec_has_inputs( @@ -653,7 +895,7 @@ def test_readiness_checks_require_testing_values_when_spec_has_inputs( "passed", "unknown", ] - assert report.passed is True + assert report.failed_streams == [] def test_publish_spec_uses_api_field_names() -> None: @@ -719,10 +961,9 @@ def fake_read( report = project.run_readiness_checks(names, timeout_seconds=0.5) finally: release.set() - assert report.passed_streams == ["fast"] + assert report.failed_streams == [] assert report.pending_streams == sorted(slow) assert report.not_attempted_streams == ["queued"] - assert report.passed is False assert any( "not-attempted streams" in step and "queued" in step for step in report.next_steps @@ -730,6 +971,35 @@ def fake_read( assert any("still reading at the timeout" in step for step in report.next_steps) +def test_readiness_checklist_is_incomplete_when_a_stream_is_pending( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store(monkeypatch, draft=MANIFEST) + project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + release = threading.Event() + + def fake_read( + _manifest: Any, stream_name: str, **_: Any + ) -> ConnectorBuilderStreamReadResult: + if stream_name == "pending": + release.wait(5) + return _stream_read(stream_name, record_count=1) + + monkeypatch.setattr(project, "_test_read_stream", fake_read) + try: + report = project.run_readiness_checks( + ["ready", "pending"], + timeout_seconds=0.5, + ) + finally: + release.set() + + assert report.pending_streams == ["pending"] + assert [stream.stream_name for stream in report.streams] == ["ready"] + assert all(stream.read_to_end for stream in report.streams) + assert report.checks[5].result == "failed" + + def test_readiness_bounds_read_requests_by_timeout( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1000,17 +1270,19 @@ def test_run_full_sync_without_test_source_fails( @pytest.mark.parametrize( - ("stream_names", "record_count", "expect_sync"), + ("stream_names", "record_count", "status", "expect_sync"), [ - pytest.param([], 0, True, id="no-streams"), - pytest.param(["users"], 1, True, id="all-passed"), - pytest.param(["users"], 0, False, id="failed-skips-sync"), + pytest.param([], 0, 200, True, id="no-streams"), + pytest.param(["users"], 1, 200, True, id="no-failed-streams"), + pytest.param(["users"], 0, 200, True, id="zero-records-no-error"), + pytest.param(["users"], 1, 422, False, id="http-error-skips-sync"), ], ) def test_readiness_full_sync_gating( monkeypatch: pytest.MonkeyPatch, stream_names: list[str], record_count: int, + status: int, expect_sync: bool, ) -> None: _patch_project_store(monkeypatch, draft=MANIFEST) @@ -1026,10 +1298,14 @@ def test_readiness_full_sync_gating( ) run_full_sync = MagicMock(return_value=full_sync) monkeypatch.setattr(project, "run_full_sync", run_full_sync) + publish = MagicMock() + monkeypatch.setattr(project, "publish", publish) monkeypatch.setattr( project, "_test_read_stream", - lambda _m, name, **_: _stream_read(name, record_count=record_count), + lambda _m, name, **_: _stream_read( + name, record_count=record_count, status=status + ), ) report = project.run_readiness_checks( @@ -1041,6 +1317,11 @@ def test_readiness_full_sync_gating( run_full_sync.assert_called_once_with( test_source_id="src", on_resource_created=None ) + else: + assert report.full_sync is None + assert report.failed_streams == ["users"] + run_full_sync.assert_not_called() + publish.assert_not_called() def test_readiness_requires_streams_without_full_sync( @@ -1170,6 +1451,9 @@ def test_run_readiness_checks_full_sync_only_without_draft( report = project.run_readiness_checks([], run_full_sync=True, test_source_id="src") assert report.full_sync is not None + report_data = report.model_dump() + assert "passed" not in report_data + assert report_data["full_sync"] is not None assert report.full_sync.destination_created is False project.publish.assert_not_called() workspace.deploy_connection.assert_called_once() diff --git a/tests/unit_tests/test_mcp_cloud.py b/tests/unit_tests/test_mcp_cloud.py index 91c47d104..50e7298ac 100644 --- a/tests/unit_tests/test_mcp_cloud.py +++ b/tests/unit_tests/test_mcp_cloud.py @@ -3573,6 +3573,70 @@ def test_request_cloud_search_index_backfill_is_a_write_tool() -> None: assert policy.pipeline_change is False +def test_connector_builder_tools_return_read_warnings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + warnings = ["Read 100 records, a multiple of 10. Check the paginator."] + read_result = SimpleNamespace( + model_dump=lambda: { + "stream_name": "users", + "warnings": warnings, + "pages": [], + "http_errors": [], + "logs": [], + "problem_logs": [], + "error": None, + }, + records=[], + ) + readiness_report = SimpleNamespace( + full_sync=None, + model_dump=lambda: { + "full_sync": None, + "streams": [ + { + "stream_name": "users", + "warnings": warnings, + "pages": [], + "http_errors": [], + "problem_logs": [], + "error": None, + } + ], + }, + ) + project = SimpleNamespace( + builder_project_id="builder-project", + url="https://cloud.airbyte.com/workspaces/ws/connector-builder/edit/bp", + test_read_stream=lambda *_args, **_kwargs: read_result, + run_readiness_checks=lambda *_args, **_kwargs: readiness_report, + ) + workspace = SimpleNamespace( + get_connector_builder_project=lambda *_args, **_kwargs: project + ) + monkeypatch.setattr( + connector_builder_mcp, "_get_cloud_workspace", lambda *_args: workspace + ) + ctx = cast(Context, object()) + + stream_result = connector_builder_mcp.test_connector_builder_stream( + ctx, + "users", + builder_project_id="builder-project", + workspace_id="ws", + ) + readiness_result = connector_builder_mcp.run_connector_builder_readiness_checks( + ctx, + ["users"], + builder_project_id="builder-project", + workspace_id="ws", + ) + + assert stream_result["warnings"] == warnings + assert readiness_result["streams"][0]["warnings"] == warnings + assert "passed" not in readiness_result + + def test_redact_stream_diagnostics() -> None: leaky = "GET https://api.example.com/data?api_key=s3cret failed: Bearer s3cret" data = connector_builder_mcp._redact_stream_diagnostics({ From 78ae8551e3caa756fa57613e54381f2a24312a16 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:55:22 +0000 Subject: [PATCH 27/47] refactor(cloud): rename CloudConnectorBuilderProject.url to connector_builder_project_url Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/project.py | 8 +++++--- airbyte/mcp/connector_builder.py | 6 +++--- tests/unit_tests/test_cloud_connector_builder.py | 6 ++++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index e379530f0..85f5c0083 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -103,7 +103,7 @@ def __init__( self.owner_workspace_id = owner_workspace_id or workspace.workspace_id @property - def url(self) -> str: + def connector_builder_project_url(self) -> str: """The project's Connector Builder URL, where users enter testing values and secrets.""" return ( f"{get_web_url_root(self.workspace.api_root)}/workspaces/" @@ -153,7 +153,7 @@ def get_info(self) -> ConnectorBuilderProjectInfo: return ConnectorBuilderProjectInfo( builder_project_id=self.builder_project_id, definition_id=source_definition_id, - connector_builder_project_url=self.url, + connector_builder_project_url=self.connector_builder_project_url, name=project_data["builderProject"]["name"], has_draft=draft_manifest is not None, draft_manifest=draft_manifest, @@ -488,7 +488,9 @@ def _readiness_report( not_attempted_streams: list[str], ) -> ConnectorBuilderReadinessReport: remaining = pending_streams + not_attempted_streams - checks = readiness_checks(project_data, draft, streams, remaining, self.url) + checks = readiness_checks( + project_data, draft, streams, remaining, self.connector_builder_project_url + ) return ConnectorBuilderReadinessReport( failed_streams=[stream.stream_name for stream in streams if stream.failed], pending_streams=pending_streams, diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index 632e82496..818eccb1d 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -184,7 +184,7 @@ def create_connector_builder_project( result: dict[str, Any] = { "builder_project_id": project.builder_project_id, "definition_id": None, - "connector_builder_project_url": project.url, + "connector_builder_project_url": project.connector_builder_project_url, "name": name, } if create_test_source: @@ -350,7 +350,7 @@ def set_connector_builder_testing_values( project.set_testing_values(testing_values_dict) return ( f"Saved testing values for Connector Builder project {project.builder_project_id}. " - f"Users can review them or enter secrets at: {project.url}" + f"Users can review them or enter secrets at: {project.connector_builder_project_url}" ) @@ -601,7 +601,7 @@ def run_connector_builder_readiness_checks( ] return { "builder_project_id": project.builder_project_id, - "connector_builder_project_url": project.url, + "connector_builder_project_url": project.connector_builder_project_url, **report_data, } diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index a4af43a07..827c8170e 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -411,7 +411,7 @@ def fake_update(**kwargs: Any) -> None: def test_project_url() -> None: project = CloudConnectorBuilderProject(_fake_workspace(), "bp") assert ( - project.url + project.connector_builder_project_url == "https://cloud.airbyte.com/workspaces/ws/connector-builder/edit/bp" ) @@ -549,7 +549,9 @@ def test_project_uses_owner_workspace() -> None: project = CloudConnectorBuilderProject( _fake_workspace(), "bp", owner_workspace_id="owner" ) - assert project.url.endswith("/workspaces/owner/connector-builder/edit/bp") + assert project.connector_builder_project_url.endswith( + "/workspaces/owner/connector-builder/edit/bp" + ) def test_get_info_reads_project_once(monkeypatch: pytest.MonkeyPatch) -> None: From 9acf64a8af26e55e3592571c453ce5ca556dd47c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:56:32 +0000 Subject: [PATCH 28/47] test: update fake project for connector_builder_project_url Co-Authored-By: AJ Steers --- tests/unit_tests/test_mcp_cloud.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/test_mcp_cloud.py b/tests/unit_tests/test_mcp_cloud.py index 50e7298ac..95852e7c9 100644 --- a/tests/unit_tests/test_mcp_cloud.py +++ b/tests/unit_tests/test_mcp_cloud.py @@ -3607,7 +3607,7 @@ def test_connector_builder_tools_return_read_warnings( ) project = SimpleNamespace( builder_project_id="builder-project", - url="https://cloud.airbyte.com/workspaces/ws/connector-builder/edit/bp", + connector_builder_project_url="https://cloud.airbyte.com/workspaces/ws/connector-builder/edit/bp", test_read_stream=lambda *_args, **_kwargs: read_result, run_readiness_checks=lambda *_args, **_kwargs: readiness_report, ) From 1f8ceefbc13eaf6faf192f65841815f8e484799e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:01:11 +0000 Subject: [PATCH 29/47] refactor(cloud): make test source name a private helper Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/project.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index 85f5c0083..1f5dc1bd6 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -80,7 +80,7 @@ def _check_test_read_limits( ) -class CloudConnectorBuilderProject: # noqa: PLR0904 # Builder project API facade. +class CloudConnectorBuilderProject: """A Connector Builder project in an Airbyte Cloud workspace.""" def __init__( @@ -542,8 +542,7 @@ def publish( definition_type="yaml", ) - @property - def test_source_name(self) -> str: + def _get_default_test_source_name(self) -> str: """Name of the source that `create_test_source()` creates for full syncs.""" return f"{self.name} (Connector Builder test)" @@ -555,7 +554,7 @@ def find_test_source(self) -> CloudSource | None: return next( ( source - for source in self.workspace.list_sources(name=self.test_source_name) + for source in self.workspace.list_sources(name=self._get_default_test_source_name()) if source.definition_id == source_definition_id ), None, @@ -579,7 +578,7 @@ def create_test_source(self) -> CloudSource: spec = ((manifest or {}).get("spec") or {}).get("connection_specification") config = drop_masked_secrets(project_data.get("testingValues") or {}) return self.workspace.deploy_source( - name=self.test_source_name, + name=self._get_default_test_source_name(), source=config, definition_id=source_definition_id, defer_credentials=spec_has_secrets(spec) or spec_missing_required(spec, config), From ef0bc59c6895427e4694f1f5c05a4f41aadebf12 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:06:54 +0000 Subject: [PATCH 30/47] refactor(cloud): combine find/create test source into get_or_create_default_test_source Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/project.py | 75 +++++++++++++------ airbyte/mcp/connector_builder.py | 2 +- .../test_cloud_connector_builder.py | 7 +- 3 files changed, 57 insertions(+), 27 deletions(-) diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index 1f5dc1bd6..873b3121e 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -6,7 +6,7 @@ import difflib import time from concurrent.futures import ThreadPoolExecutor, wait -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal, overload import requests @@ -543,42 +543,70 @@ def publish( ) def _get_default_test_source_name(self) -> str: - """Name of the source that `create_test_source()` creates for full syncs.""" + """Default name of the source `get_or_create_default_test_source()` finds or creates.""" return f"{self.name} (Connector Builder test)" - def find_test_source(self) -> CloudSource | None: - """Return the source made by `create_test_source()` for this project, if it exists.""" - source_definition_id = self.source_definition_id - if source_definition_id is None: - return None - return next( - ( - source - for source in self.workspace.list_sources(name=self._get_default_test_source_name()) - if source.definition_id == source_definition_id - ), - None, - ) + @overload + def get_or_create_default_test_source( + self, + *, + allow_create: Literal[True] = True, + default_source_name: str | Callable[[CloudConnectorBuilderProject], str] | None = None, + ) -> CloudSource: ... + + @overload + def get_or_create_default_test_source( + self, + *, + allow_create: bool, + default_source_name: str | Callable[[CloudConnectorBuilderProject], str] | None = None, + ) -> CloudSource | None: ... - def create_test_source(self) -> CloudSource: - """Create a source on the published definition for full syncs. + def get_or_create_default_test_source( + self, + *, + allow_create: bool = True, + default_source_name: str | Callable[[CloudConnectorBuilderProject], str] | None = None, + ) -> CloudSource | None: + """Return the project's test source on the published definition, creating it if needed. - The source gets the project's non-secret testing values. When the spec has secret + A new source gets the project's non-secret testing values. When the spec has secret fields, the source is a draft (deferred credentials): a person enters the secrets at its `connector_url`, then tests and saves. Publish the project first. + + Args: + allow_create: Whether to create the source when it doesn't exist. When False, + returns `None` instead. + default_source_name: The source name, or a callable that takes this project and + returns it. Defaults to " (Connector Builder test)". """ + source_name = ( + default_source_name(self) if callable(default_source_name) else default_source_name + ) or self._get_default_test_source_name() project_data = self.get_project_data() source_definition_id = project_data["builderProject"].get("sourceDefinitionId") if source_definition_id is None: + if not allow_create: + return None raise exc.AirbyteLibInputError( message="Publish the Connector Builder project before creating a test source.", ) + existing = next( + ( + source + for source in self.workspace.list_sources(name=source_name) + if source.definition_id == source_definition_id + ), + None, + ) + if existing is not None or not allow_create: + return existing # The source runs the published version, so its spec decides the setup. manifest = self._published_manifest(source_definition_id) spec = ((manifest or {}).get("spec") or {}).get("connection_specification") config = drop_masked_secrets(project_data.get("testingValues") or {}) return self.workspace.deploy_source( - name=self._get_default_test_source_name(), + name=source_name, source=config, definition_id=source_definition_id, defer_credentials=spec_has_secrets(spec) or spec_missing_required(spec, config), @@ -599,7 +627,7 @@ def run_full_sync( Args: test_source_id: Source on the published definition to sync from. Defaults to the - source from `create_test_source()`. + source from `get_or_create_default_test_source()`. pre_validate: Whether to validate the manifest client-side before publishing. on_resource_created: Called with the ID of each destination or connection this call creates, as soon as it exists. @@ -611,13 +639,14 @@ def run_full_sync( self._require_draft_manifest(project_data) # Check the source and destination first: the first publish clears the draft. if test_source_id is None: - test_source = self.find_test_source() + test_source = self.get_or_create_default_test_source(allow_create=False) if test_source is None: raise exc.AirbyteLibInputError( message="No test source found for this Connector Builder project.", guidance=( - "Pass `test_source_id`, or create one with `create_test_source()` " - "and have the user complete its settings." + "Pass `test_source_id`, or create one with " + "`get_or_create_default_test_source()` and have the user complete its " + "settings." ), ) test_source_id = test_source.connector_id diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index 818eccb1d..788626dfe 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -190,7 +190,7 @@ def create_connector_builder_project( if create_test_source: definition = project.publish(name, pre_validate=pre_validate) register_guid_created_in_session(definition.definition_id) - test_source = project.create_test_source() + test_source = project.get_or_create_default_test_source() register_guid_created_in_session(test_source.connector_id) result |= { "definition_id": definition.definition_id, diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 827c8170e..9169f1e23 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -374,6 +374,7 @@ def _fake_workspace() -> Any: client_secret=None, bearer_token=SecretString("token"), get_custom_source_definition=MagicMock(), + list_sources=MagicMock(return_value=[]), ) @@ -1117,7 +1118,7 @@ def test_create_test_source_uses_non_secret_testing_values( # The source runs the published version, so its spec wins over the draft's. monkeypatch.setattr(project, "_published_manifest", lambda _id: manifest) - project.create_test_source() + project.get_or_create_default_test_source() workspace.deploy_source.assert_called_once_with( name="My API (Connector Builder test)", @@ -1132,7 +1133,7 @@ def test_create_test_source_requires_publish(monkeypatch: pytest.MonkeyPatch) -> project = CloudConnectorBuilderProject(_fake_workspace(), "bp") with pytest.raises(AirbyteLibInputError, match="Publish"): - project.create_test_source() + project.get_or_create_default_test_source() def _full_sync_project( @@ -1430,7 +1431,7 @@ def test_create_test_source_after_publish_cleared_draft( project = CloudConnectorBuilderProject(workspace, "bp") monkeypatch.setattr(project, "_published_manifest", lambda _id: _secret_manifest()) - project.create_test_source() + project.get_or_create_default_test_source() assert workspace.deploy_source.call_args.kwargs["defer_credentials"] is True From 58066ae1d9730f78a725fc493c6ef600cc866cbf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:09:33 +0000 Subject: [PATCH 31/47] refactor(cloud): make CloudConnectorBuilderProject constructor keyword-only Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/project.py | 2 +- airbyte/cloud/connectors.py | 4 +- airbyte/cloud/workspaces.py | 6 +- .../test_cloud_connector_builder.py | 108 ++++++++++++------ 4 files changed, 83 insertions(+), 37 deletions(-) diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index 873b3121e..d28d8c9d0 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -85,9 +85,9 @@ class CloudConnectorBuilderProject: def __init__( self, + *, workspace: CloudWorkspace, builder_project_id: str, - *, owner_workspace_id: str | None = None, ) -> None: """Initialize a Connector Builder project object. Does not call the API. diff --git a/airbyte/cloud/connectors.py b/airbyte/cloud/connectors.py index 8efb166ae..72f5acf1f 100644 --- a/airbyte/cloud/connectors.py +++ b/airbyte/cloud/connectors.py @@ -1459,8 +1459,8 @@ def connector_builder_project(self) -> CloudConnectorBuilderProject | None: return None return CloudConnectorBuilderProject( - self.workspace, - project_id, + workspace=self.workspace, + builder_project_id=project_id, owner_workspace_id=self._builder_project_workspace_id, ) diff --git a/airbyte/cloud/workspaces.py b/airbyte/cloud/workspaces.py index 1ef796fe6..5badd90ea 100644 --- a/airbyte/cloud/workspaces.py +++ b/airbyte/cloud/workspaces.py @@ -1427,7 +1427,7 @@ def create_connector_builder_project( bearer_token=self.bearer_token, config_api_root=self.config_api_root, ) - return CloudConnectorBuilderProject(self, builder_project_id) + return CloudConnectorBuilderProject(workspace=self, builder_project_id=builder_project_id) def get_connector_builder_project( self, @@ -1450,7 +1450,9 @@ def get_connector_builder_project( message="Provide exactly one of `builder_project_id` or `definition_id`.", ) if builder_project_id is not None: - return CloudConnectorBuilderProject(self, builder_project_id) + return CloudConnectorBuilderProject( + workspace=self, builder_project_id=builder_project_id + ) assert definition_id is not None project = self.get_custom_source_definition( diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 9169f1e23..40ed690e1 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -410,7 +410,9 @@ def fake_update(**kwargs: Any) -> None: def test_project_url() -> None: - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) assert ( project.connector_builder_project_url == "https://cloud.airbyte.com/workspaces/ws/connector-builder/edit/bp" @@ -421,7 +423,9 @@ def test_update_draft_manifest_text_saves_and_returns_diff( monkeypatch: pytest.MonkeyPatch, ) -> None: store = _patch_project_store(monkeypatch, draft={"version": "6.0.0", "streams": []}) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) diff = project.update_draft_manifest_text( edit_mode="replace_text", @@ -439,7 +443,9 @@ def test_update_draft_manifest_text_without_draft_requires_replace_full( monkeypatch: pytest.MonkeyPatch, ) -> None: store = _patch_project_store(monkeypatch, draft=None) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) with pytest.raises(AirbyteLibInputError, match="no draft manifest"): project.update_draft_manifest_text( @@ -461,9 +467,9 @@ def test_set_testing_values_uses_draft_spec(monkeypatch: pytest.MonkeyPatch) -> update_testing_values, ) - CloudConnectorBuilderProject(_fake_workspace(), "bp").set_testing_values({ - "api_key": "k" - }) + CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ).set_testing_values({"api_key": "k"}) kwargs = update_testing_values.call_args.kwargs assert kwargs["testing_values"] == {"api_key": "k"} @@ -484,7 +490,9 @@ def test_set_testing_values_without_draft_uses_published_spec( "update_connector_builder_project_testing_values", update_testing_values, ) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) monkeypatch.setattr(project, "_published_manifest", lambda _id: _secret_manifest()) project.set_testing_values({"api_key": "k"}) @@ -499,7 +507,9 @@ def test_publish_unpublished_project(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(api_util, "publish_connector_builder_project", publish) workspace = _fake_workspace() - CloudConnectorBuilderProject(workspace, "bp").publish("My API") + CloudConnectorBuilderProject(workspace=workspace, builder_project_id="bp").publish( + "My API" + ) spec = publish.call_args.kwargs["spec"] assert ( @@ -518,7 +528,9 @@ def test_publish_already_published_project_adds_version( monkeypatch.setattr(api_util, "publish_connector_builder_project", publish) workspace = _fake_workspace() - CloudConnectorBuilderProject(workspace, "bp").publish("My API") + CloudConnectorBuilderProject(workspace=workspace, builder_project_id="bp").publish( + "My API" + ) publish.assert_not_called() definition = workspace.get_custom_source_definition.return_value @@ -548,7 +560,7 @@ def test_get_connector_builder_project_requires_exactly_one_id( def test_project_uses_owner_workspace() -> None: project = CloudConnectorBuilderProject( - _fake_workspace(), "bp", owner_workspace_id="owner" + workspace=_fake_workspace(), builder_project_id="bp", owner_workspace_id="owner" ) assert project.connector_builder_project_url.endswith( "/workspaces/owner/connector-builder/edit/bp" @@ -566,7 +578,9 @@ def counting_get(**kwargs: Any) -> dict[str, Any]: monkeypatch.setattr(api_util, "get_connector_builder_project", counting_get) - info = CloudConnectorBuilderProject(_fake_workspace(), "bp").get_info() + info = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ).get_info() assert len(calls) == 1 assert info.has_draft is True @@ -584,7 +598,9 @@ def test_publish_validates_unpublished_draft_unless_disabled( monkeypatch.setattr( api_util, "publish_connector_builder_project", MagicMock(return_value="d") ) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) project.publish("My API", pre_validate=False) validate.assert_not_called() @@ -639,7 +655,9 @@ def test_run_readiness_checks_summarizes_each_stream( _patch_project_store( monkeypatch, draft={**MANIFEST, "streams": [{"name": name} for name in names]} ) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) _patch_reads( monkeypatch, project, @@ -704,7 +722,9 @@ def test_run_readiness_checks_summarizes_each_stream( def test_run_readiness_checks_requires_stream_names() -> None: - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) with pytest.raises(AirbyteLibInputError): project.run_readiness_checks([]) @@ -731,7 +751,9 @@ def unexpected_api_call(*_: Any, **__: Any) -> Any: monkeypatch.setattr( api_util, "read_connector_builder_project_stream", unexpected_api_call ) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) expected: dict[str, int] = ( {"record_limit": 100, "page_limit": 5, "slice_limit": 5} if method == "test_read_stream" @@ -764,7 +786,9 @@ def fake_read(**kwargs: Any) -> dict[str, Any]: return {"slices": [{"pages": [{"records": [{"id": 1}]}]}]} monkeypatch.setattr(api_util, "read_connector_builder_project_stream", fake_read) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) project.run_readiness_checks(["users"]) @@ -778,7 +802,9 @@ def test_readiness_checklist_does_not_call_failed_reads_capped( monkeypatch: pytest.MonkeyPatch, ) -> None: _patch_project_store(monkeypatch, draft={"streams": [{"name": "users"}]}) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) def fail_read(*_: Any, **__: Any) -> ConnectorBuilderStreamReadResult: raise AirbyteCloudError(message="Cloud read failed.") @@ -797,7 +823,9 @@ def test_readiness_checklist_marks_failed_capped_stream_incomplete( monkeypatch: pytest.MonkeyPatch, ) -> None: _patch_project_store(monkeypatch, draft={"streams": [{"name": "users"}]}) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) _patch_reads( monkeypatch, project, @@ -835,16 +863,18 @@ def test_run_readiness_checks_without_draft_fails( ) -> None: _patch_project_store(monkeypatch, draft=None) with pytest.raises(AirbyteLibInputError): - CloudConnectorBuilderProject(_fake_workspace(), "bp").run_readiness_checks([ - "users" - ]) + CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ).run_readiness_checks(["users"]) def test_readiness_report_lists_caps_without_a_success_verdict( monkeypatch: pytest.MonkeyPatch, ) -> None: _patch_project_store(monkeypatch, draft=MANIFEST) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) reads = { "users": _stream_read("users", record_count=1), "orders": _stream_read("orders", record_count=1, limit_reached=True), @@ -883,7 +913,9 @@ def test_readiness_checks_require_testing_values_when_spec_has_inputs( "streams": [{"name": "users"}], } _patch_project_store(monkeypatch, draft=draft, source_definition_id="def") - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) _patch_reads(monkeypatch, project, {"users": _stream_read("users", record_count=1)}) report = project.run_readiness_checks(["users"]) @@ -947,7 +979,9 @@ def test_readiness_timeout_splits_pending_and_not_attempted( monkeypatch: pytest.MonkeyPatch, ) -> None: _patch_project_store(monkeypatch, draft=MANIFEST) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) release = threading.Event() slow = {f"slow{i}" for i in range(5)} @@ -978,7 +1012,9 @@ def test_readiness_checklist_is_incomplete_when_a_stream_is_pending( monkeypatch: pytest.MonkeyPatch, ) -> None: _patch_project_store(monkeypatch, draft=MANIFEST) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) release = threading.Event() def fake_read( @@ -1007,7 +1043,9 @@ def test_readiness_bounds_read_requests_by_timeout( monkeypatch: pytest.MonkeyPatch, ) -> None: _patch_project_store(monkeypatch, draft=MANIFEST) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) timeouts: list[float | None] = [] def fake_read( @@ -1114,7 +1152,7 @@ def test_create_test_source_uses_non_secret_testing_values( ) workspace = _fake_workspace() workspace.deploy_source = MagicMock() - project = CloudConnectorBuilderProject(workspace, "bp") + project = CloudConnectorBuilderProject(workspace=workspace, builder_project_id="bp") # The source runs the published version, so its spec wins over the draft's. monkeypatch.setattr(project, "_published_manifest", lambda _id: manifest) @@ -1130,7 +1168,9 @@ def test_create_test_source_uses_non_secret_testing_values( def test_create_test_source_requires_publish(monkeypatch: pytest.MonkeyPatch) -> None: _patch_project_store(monkeypatch, draft=MANIFEST) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) with pytest.raises(AirbyteLibInputError, match="Publish"): project.get_or_create_default_test_source() @@ -1159,7 +1199,7 @@ def _full_sync_project( job_id=7, job_url="https://job" ) workspace.deploy_connection = MagicMock(return_value=new_connection) - project = CloudConnectorBuilderProject(workspace, "bp") + project = CloudConnectorBuilderProject(workspace=workspace, builder_project_id="bp") monkeypatch.setattr(project, "publish", MagicMock()) monkeypatch.setattr( api_util, @@ -1289,7 +1329,9 @@ def test_readiness_full_sync_gating( expect_sync: bool, ) -> None: _patch_project_store(monkeypatch, draft=MANIFEST) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) full_sync = connector_builder.ConnectorBuilderFullSync( source_id="src", destination_id="dest", @@ -1331,7 +1373,9 @@ def test_readiness_requires_streams_without_full_sync( monkeypatch: pytest.MonkeyPatch, ) -> None: _patch_project_store(monkeypatch, draft=MANIFEST) - project = CloudConnectorBuilderProject(_fake_workspace(), "bp") + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) with pytest.raises(AirbyteLibInputError, match="run_full_sync"): project.run_readiness_checks([]) @@ -1428,7 +1472,7 @@ def test_create_test_source_after_publish_cleared_draft( ) workspace = _fake_workspace() workspace.deploy_source = MagicMock() - project = CloudConnectorBuilderProject(workspace, "bp") + project = CloudConnectorBuilderProject(workspace=workspace, builder_project_id="bp") monkeypatch.setattr(project, "_published_manifest", lambda _id: _secret_manifest()) project.get_or_create_default_test_source() From 971be977a0f082cb58fdd418b6c5b0bf8c246951 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:13:57 +0000 Subject: [PATCH 32/47] refactor(cloud): move Connector Builder constants to private _util module Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/__init__.py | 8 -------- airbyte/cloud/connector_builder/_util.py | 6 ++++++ airbyte/cloud/connector_builder/project.py | 10 ++++------ airbyte/mcp/connector_builder.py | 4 ++-- tests/unit_tests/test_cloud_connector_builder.py | 14 +++++++------- 5 files changed, 19 insertions(+), 23 deletions(-) diff --git a/airbyte/cloud/connector_builder/__init__.py b/airbyte/cloud/connector_builder/__init__.py index e88783b2c..8c4bd92cb 100644 --- a/airbyte/cloud/connector_builder/__init__.py +++ b/airbyte/cloud/connector_builder/__init__.py @@ -21,16 +21,11 @@ ReadinessCheckResult, ) from airbyte.cloud.connector_builder.project import ( - CONNECTOR_BUILDER_TEST_DESTINATION_NAME, - TEST_READ_MAX_PAGES, - TEST_READ_MAX_RECORDS, - TEST_READ_MAX_SLICES, CloudConnectorBuilderProject, ) __all__ = [ - "CONNECTOR_BUILDER_TEST_DESTINATION_NAME", "CloudConnectorBuilderProject", "ConnectorBuilderFullSync", "ConnectorBuilderPageSummary", @@ -42,7 +37,4 @@ "ConnectorBuilderStreamReadResult", "ManifestTextEditMode", "ReadinessCheckResult", - "TEST_READ_MAX_PAGES", - "TEST_READ_MAX_RECORDS", - "TEST_READ_MAX_SLICES", ] diff --git a/airbyte/cloud/connector_builder/_util.py b/airbyte/cloud/connector_builder/_util.py index 64948f752..727188dd9 100644 --- a/airbyte/cloud/connector_builder/_util.py +++ b/airbyte/cloud/connector_builder/_util.py @@ -21,6 +21,12 @@ _YAML_LINE_WIDTH = 2**31 - 1 _SECRETS_MASK = "**********" """Placeholder Cloud returns instead of a stored secret value.""" +CONNECTOR_BUILDER_TEST_DESTINATION_NAME = "Connector Builder Test (No-op)" +"""Name of the no-op destination that full syncs find or create by default.""" +TEST_READ_MAX_RECORDS = 5_000 +"""Most records read per stream by `run_readiness_checks()`.""" +TEST_READ_MAX_PAGES = 20 +TEST_READ_MAX_SLICES = 20 def unwrap_builder_manifest(value: object) -> dict[str, Any] | None: diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index d28d8c9d0..59a5d6cfa 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -14,6 +14,10 @@ from airbyte._util import api_util from airbyte._util.api_util import get_web_url_root from airbyte.cloud.connector_builder._util import ( + CONNECTOR_BUILDER_TEST_DESTINATION_NAME, + TEST_READ_MAX_PAGES, + TEST_READ_MAX_RECORDS, + TEST_READ_MAX_SLICES, apply_manifest_text_edit, builder_testing_values_spec, draft_from_project_data, @@ -49,12 +53,6 @@ """Most streams `run_readiness_checks()` reads at once.""" _MIN_READ_TIMEOUT = 1.0 """Shortest HTTP timeout for a test read started just before the readiness deadline.""" -CONNECTOR_BUILDER_TEST_DESTINATION_NAME = "Connector Builder Test (No-op)" -"""Name of the no-op destination that full syncs find or create by default.""" -TEST_READ_MAX_RECORDS = 5_000 -"""Most records read per stream by `run_readiness_checks()`.""" -TEST_READ_MAX_PAGES = 20 -TEST_READ_MAX_SLICES = 20 def _check_test_read_limits( diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index 788626dfe..23e9e06c8 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -14,11 +14,11 @@ from fastmcp_extensions import TraceArg, register_mcp_tools from pydantic import Field -from airbyte.cloud.connector_builder import ( +from airbyte.cloud.connector_builder import ManifestTextEditMode +from airbyte.cloud.connector_builder._util import ( TEST_READ_MAX_PAGES, TEST_READ_MAX_RECORDS, TEST_READ_MAX_SLICES, - ManifestTextEditMode, ) from airbyte.exceptions import ( AirbyteLibInputError, diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 40ed690e1..3fbad2ff6 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -758,9 +758,9 @@ def unexpected_api_call(*_: Any, **__: Any) -> Any: {"record_limit": 100, "page_limit": 5, "slice_limit": 5} if method == "test_read_stream" else { - "record_limit": connector_builder.TEST_READ_MAX_RECORDS, - "page_limit": connector_builder.TEST_READ_MAX_PAGES, - "slice_limit": connector_builder.TEST_READ_MAX_SLICES, + "record_limit": connector_builder._util.TEST_READ_MAX_RECORDS, + "page_limit": connector_builder._util.TEST_READ_MAX_PAGES, + "slice_limit": connector_builder._util.TEST_READ_MAX_SLICES, } ) @@ -793,9 +793,9 @@ def fake_read(**kwargs: Any) -> dict[str, Any]: project.run_readiness_checks(["users"]) assert len(received) == 1 - assert received[0]["record_limit"] == connector_builder.TEST_READ_MAX_RECORDS - assert received[0]["page_limit"] == connector_builder.TEST_READ_MAX_PAGES - assert received[0]["slice_limit"] == connector_builder.TEST_READ_MAX_SLICES + assert received[0]["record_limit"] == connector_builder._util.TEST_READ_MAX_RECORDS + assert received[0]["page_limit"] == connector_builder._util.TEST_READ_MAX_PAGES + assert received[0]["slice_limit"] == connector_builder._util.TEST_READ_MAX_SLICES def test_readiness_checklist_does_not_call_failed_reads_capped( @@ -1234,7 +1234,7 @@ def test_run_full_sync_creates_noop_destination_and_connection( name="My API (Connector Builder test)" ) assert workspace.deploy_destination.call_args.kwargs["name"] == ( - connector_builder.CONNECTOR_BUILDER_TEST_DESTINATION_NAME + connector_builder._util.CONNECTOR_BUILDER_TEST_DESTINATION_NAME ) workspace.deploy_connection.assert_called_once_with( "My API (Connector Builder full sync)", From f755c9414b3c647fda9c4f6a10e024b48ed62033 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:16:33 +0000 Subject: [PATCH 33/47] refactor(cloud): make CloudConnectorBuilderProject.get_project_data private Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/project.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index 59a5d6cfa..590c00659 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -108,7 +108,7 @@ def connector_builder_project_url(self) -> str: f"{self.owner_workspace_id}/connector-builder/edit/{self.builder_project_id}" ) - def get_project_data(self) -> dict[str, Any]: + def _get_project_data(self) -> dict[str, Any]: """Fetch the project metadata and draft manifest from the API.""" return api_util.get_connector_builder_project( workspace_id=self.owner_workspace_id, @@ -123,12 +123,12 @@ def get_project_data(self) -> dict[str, Any]: @property def name(self) -> str: """The project name.""" - return self.get_project_data()["builderProject"]["name"] + return self._get_project_data()["builderProject"]["name"] @property def source_definition_id(self) -> str | None: """The ID of the custom source definition, or `None` if never published.""" - return self.get_project_data()["builderProject"].get("sourceDefinitionId") + return self._get_project_data()["builderProject"].get("sourceDefinitionId") def _published_manifest(self, source_definition_id: str | None) -> dict[str, Any] | None: if source_definition_id is None: @@ -145,7 +145,7 @@ def published_manifest(self) -> dict[str, Any] | None: def get_info(self) -> ConnectorBuilderProjectInfo: """Read the project once and return its IDs, URL, draft, and published manifest.""" - project_data = self.get_project_data() + project_data = self._get_project_data() draft_manifest = draft_from_project_data(project_data) source_definition_id = project_data["builderProject"].get("sourceDefinitionId") return ConnectorBuilderProjectInfo( @@ -164,10 +164,10 @@ def get_info(self) -> ConnectorBuilderProjectInfo: @property def draft_manifest(self) -> dict[str, Any] | None: """The unpublished draft manifest, or `None` if the project has no draft.""" - return draft_from_project_data(self.get_project_data()) + return draft_from_project_data(self._get_project_data()) def _require_draft_manifest(self, project_data: dict[str, Any] | None = None) -> dict[str, Any]: - manifest = draft_from_project_data(project_data or self.get_project_data()) + manifest = draft_from_project_data(project_data or self._get_project_data()) if manifest is None: raise exc.AirbyteLibInputError( message="This Connector Builder project has no draft manifest.", @@ -264,7 +264,7 @@ def set_testing_values( The spec comes from the draft manifest, or the published one when there's no draft. Fields marked `airbyte_secret` in the spec are stored as secrets. """ - project_data = self.get_project_data() + project_data = self._get_project_data() manifest = draft_from_project_data(project_data) or self._published_manifest( project_data["builderProject"].get("sourceDefinitionId") ) @@ -401,7 +401,7 @@ def run_readiness_checks( raise exc.AirbyteLibInputError( message="Pass at least 1 stream name, or set `run_full_sync`.", ) - project_data = self.get_project_data() + project_data = self._get_project_data() if not stream_names: # A full sync alone can run on the published manifest; the first publish clears # the draft. @@ -581,7 +581,7 @@ def get_or_create_default_test_source( source_name = ( default_source_name(self) if callable(default_source_name) else default_source_name ) or self._get_default_test_source_name() - project_data = self.get_project_data() + project_data = self._get_project_data() source_definition_id = project_data["builderProject"].get("sourceDefinitionId") if source_definition_id is None: if not allow_create: @@ -630,7 +630,7 @@ def run_full_sync( on_resource_created: Called with the ID of each destination or connection this call creates, as soon as it exists. """ - project_data = self.get_project_data() + project_data = self._get_project_data() has_draft = draft_from_project_data(project_data) is not None source_definition_id = project_data["builderProject"].get("sourceDefinitionId") if not has_draft and source_definition_id is None: From 7bc98534c2155a5f71cf50adc5d232abcb61cb8e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:17:21 +0000 Subject: [PATCH 34/47] refactor(cloud): rename set_draft_manifest to replace_draft_manifest Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/project.py | 4 ++-- airbyte/cloud/workspaces.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index 590c00659..de373af14 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -176,7 +176,7 @@ def _require_draft_manifest(self, project_data: dict[str, Any] | None = None) -> ) return manifest - def set_draft_manifest( + def replace_draft_manifest( self, manifest_yaml: dict[str, Any] | Path | str, *, @@ -243,7 +243,7 @@ def update_draft_manifest_text( new_text=new_text, old_text=old_text, ) - self.set_draft_manifest(edited_text, pre_validate=pre_validate) + self.replace_draft_manifest(edited_text, pre_validate=pre_validate) saved_text = self.draft_manifest_text or "" return "".join( diff --git a/airbyte/cloud/workspaces.py b/airbyte/cloud/workspaces.py index 5badd90ea..52928cc9d 100644 --- a/airbyte/cloud/workspaces.py +++ b/airbyte/cloud/workspaces.py @@ -1388,7 +1388,7 @@ def _publish_connector_builder_project( ) if manifest_yaml is not None: - project.set_draft_manifest(manifest_yaml, pre_validate=pre_validate) + project.replace_draft_manifest(manifest_yaml, pre_validate=pre_validate) definition = project.publish(name, pre_validate=pre_validate) if testing_values is not None: From d536cbbebd2a4131e8ba36b59ce08a66ec7cd50a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:24:11 +0000 Subject: [PATCH 35/47] refactor(cloud): name dict-returning Connector Builder manifests *_manifest_dict Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/models.py | 8 ++++---- airbyte/cloud/connector_builder/project.py | 10 +++++----- airbyte/mcp/connector_builder.py | 4 ++-- tests/unit_tests/test_cloud_connector_builder.py | 5 ++++- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/airbyte/cloud/connector_builder/models.py b/airbyte/cloud/connector_builder/models.py index df7023165..4686be595 100644 --- a/airbyte/cloud/connector_builder/models.py +++ b/airbyte/cloud/connector_builder/models.py @@ -143,14 +143,14 @@ def from_api_response( class ConnectorBuilderResolvedManifest(BaseModel): """A manifest resolved by Airbyte Cloud, with dynamic streams expanded.""" - manifest: dict[str, Any] + manifest_dict: dict[str, Any] stream_names: list[str] @classmethod def from_manifest(cls, manifest: dict[str, Any]) -> ConnectorBuilderResolvedManifest: """Build a result from a resolved manifest.""" return cls( - manifest=manifest, + manifest_dict=manifest, stream_names=[ stream["name"] for stream in manifest.get("streams") or [] @@ -167,9 +167,9 @@ class ConnectorBuilderProjectInfo(BaseModel): connector_builder_project_url: str name: str has_draft: bool - draft_manifest: dict[str, Any] | None + draft_manifest_dict: dict[str, Any] | None = Field(serialization_alias="draft_manifest") draft_manifest_text: str | None - published_manifest: dict[str, Any] | None + published_manifest_dict: dict[str, Any] | None = Field(serialization_alias="published_manifest") class ConnectorBuilderStreamReadiness(BaseModel): diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index de373af14..d89100cde 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -139,7 +139,7 @@ def _published_manifest(self, source_definition_id: str | None) -> dict[str, Any ).manifest @property - def published_manifest(self) -> dict[str, Any] | None: + def published_manifest_dict(self) -> dict[str, Any] | None: """The active manifest of the published definition, or `None` if never published.""" return self._published_manifest(self.source_definition_id) @@ -154,15 +154,15 @@ def get_info(self) -> ConnectorBuilderProjectInfo: connector_builder_project_url=self.connector_builder_project_url, name=project_data["builderProject"]["name"], has_draft=draft_manifest is not None, - draft_manifest=draft_manifest, + draft_manifest_dict=draft_manifest, draft_manifest_text=( render_manifest_text(draft_manifest) if draft_manifest is not None else None ), - published_manifest=self._published_manifest(source_definition_id), + published_manifest_dict=self._published_manifest(source_definition_id), ) @property - def draft_manifest(self) -> dict[str, Any] | None: + def draft_manifest_dict(self) -> dict[str, Any] | None: """The unpublished draft manifest, or `None` if the project has no draft.""" return draft_from_project_data(self._get_project_data()) @@ -208,7 +208,7 @@ def replace_draft_manifest( @property def draft_manifest_text(self) -> str | None: """The draft manifest rendered by `render_manifest_text()`, or `None` if no draft.""" - manifest = self.draft_manifest + manifest = self.draft_manifest_dict return render_manifest_text(manifest) if manifest is not None else None def update_draft_manifest_text( diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index 23e9e06c8..c4ede2a63 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -99,7 +99,7 @@ def get_connector_builder_draft_manifest( builder_project_id, definition_id=definition_id, ) - return project.get_info().model_dump() + return project.get_info().model_dump(by_alias=True) @mcp_tool(extra_help_text=_manifest_yaml_source_hint, insiders_only=True) @@ -498,7 +498,7 @@ def resolve_connector_builder_manifest( return { "builder_project_id": project.builder_project_id, "stream_names": resolved.stream_names, - "resolved_manifest": resolved.manifest, + "resolved_manifest": resolved.manifest_dict, } diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 3fbad2ff6..4532b2a30 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -586,7 +586,10 @@ def counting_get(**kwargs: Any) -> dict[str, Any]: assert info.has_draft is True assert info.draft_manifest_text == "version: 6.0.0\n" assert info.definition_id is None - assert info.published_manifest is None + assert info.published_manifest_dict is None + dumped = info.model_dump(by_alias=True) + assert "draft_manifest" in dumped + assert dumped["published_manifest"] is None def test_publish_validates_unpublished_draft_unless_disabled( From 58eae0a858e0cf6989909fae79c1bd170c824a4f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:25:56 +0000 Subject: [PATCH 36/47] refactor(cloud): rename test_read_stream to run_stream_test_read Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/project.py | 8 ++++---- airbyte/mcp/connector_builder.py | 4 ++-- tests/unit_tests/fixtures/arg_trace_plan.json | 2 +- .../test_cloud_connector_builder.py | 20 +++++++++---------- tests/unit_tests/test_mcp_cloud.py | 4 ++-- tests/unit_tests/test_mcp_tool_utils.py | 2 +- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index d89100cde..4471e9a00 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -282,7 +282,7 @@ def set_testing_values( config_api_root=self.workspace.config_api_root, ) - def test_read_stream( + def run_stream_test_read( self, stream_name: str, *, @@ -301,7 +301,7 @@ def test_read_stream( slice_limit: Maximum slices to read (1 to 20). """ _check_test_read_limits(record_limit, page_limit, slice_limit) - return self._test_read_stream( + return self._run_stream_test_read( self._require_draft_manifest(), stream_name, record_limit=record_limit, @@ -309,7 +309,7 @@ def test_read_stream( slice_limit=slice_limit, ) - def _test_read_stream( + def _run_stream_test_read( self, manifest: dict[str, Any], stream_name: str, @@ -428,7 +428,7 @@ def read(stream_name: str) -> ConnectorBuilderStreamReadiness: None if deadline is None else max(deadline - time.monotonic(), _MIN_READ_TIMEOUT) ) try: - result = self._test_read_stream( + result = self._run_stream_test_read( draft, stream_name, record_limit=record_limit, diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index c4ede2a63..26431fc91 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -369,7 +369,7 @@ def _redact_stream_diagnostics(stream_data: dict[str, Any]) -> dict[str, Any]: @mcp_tool(read_only=True, external_access=True, insiders_only=True) -def test_connector_builder_stream( +def run_connector_builder_stream_test_read( ctx: Context, stream_name: Annotated[ str, @@ -440,7 +440,7 @@ def test_connector_builder_stream( builder_project_id, definition_id=definition_id, ) - result = project.test_read_stream( + result = project.run_stream_test_read( stream_name, record_limit=record_limit, page_limit=page_limit, diff --git a/tests/unit_tests/fixtures/arg_trace_plan.json b/tests/unit_tests/fixtures/arg_trace_plan.json index 7a18a8403..8100fb246 100644 --- a/tests/unit_tests/fixtures/arg_trace_plan.json +++ b/tests/unit_tests/fixtures/arg_trace_plan.json @@ -379,7 +379,7 @@ "source_connector_name": "fingerprint", "streams": "fingerprint list" }, - "test_connector_builder_stream": { + "run_connector_builder_stream_test_read": { "builder_project_id": "hash", "definition_id": "hash", "page_limit": "value int", diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 4532b2a30..561530976 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -648,7 +648,7 @@ def fake_read( ) return reads[stream_name] - monkeypatch.setattr(project, "_test_read_stream", fake_read) + monkeypatch.setattr(project, "_run_stream_test_read", fake_read) def test_run_readiness_checks_summarizes_each_stream( @@ -732,7 +732,7 @@ def test_run_readiness_checks_requires_stream_names() -> None: project.run_readiness_checks([]) -@pytest.mark.parametrize("method", ["test_read_stream", "run_readiness_checks"]) +@pytest.mark.parametrize("method", ["run_stream_test_read", "run_readiness_checks"]) @pytest.mark.parametrize( ("limit_name", "limit_value"), [ @@ -759,7 +759,7 @@ def unexpected_api_call(*_: Any, **__: Any) -> Any: ) expected: dict[str, int] = ( {"record_limit": 100, "page_limit": 5, "slice_limit": 5} - if method == "test_read_stream" + if method == "run_stream_test_read" else { "record_limit": connector_builder._util.TEST_READ_MAX_RECORDS, "page_limit": connector_builder._util.TEST_READ_MAX_PAGES, @@ -768,8 +768,8 @@ def unexpected_api_call(*_: Any, **__: Any) -> Any: ) with pytest.raises(AirbyteLibInputError) as error: - if method == "test_read_stream": - project.test_read_stream("users", **{limit_name: limit_value}) + if method == "run_stream_test_read": + project.run_stream_test_read("users", **{limit_name: limit_value}) else: project.run_readiness_checks(["users"], **{limit_name: limit_value}) @@ -812,7 +812,7 @@ def test_readiness_checklist_does_not_call_failed_reads_capped( def fail_read(*_: Any, **__: Any) -> ConnectorBuilderStreamReadResult: raise AirbyteCloudError(message="Cloud read failed.") - monkeypatch.setattr(project, "_test_read_stream", fail_read) + monkeypatch.setattr(project, "_run_stream_test_read", fail_read) report = project.run_readiness_checks(["users"]) @@ -995,7 +995,7 @@ def fake_read( release.wait(5) return _stream_read(stream_name, record_count=1) - monkeypatch.setattr(project, "_test_read_stream", fake_read) + monkeypatch.setattr(project, "_run_stream_test_read", fake_read) names = ["fast", *sorted(slow), "queued"] try: report = project.run_readiness_checks(names, timeout_seconds=0.5) @@ -1027,7 +1027,7 @@ def fake_read( release.wait(5) return _stream_read(stream_name, record_count=1) - monkeypatch.setattr(project, "_test_read_stream", fake_read) + monkeypatch.setattr(project, "_run_stream_test_read", fake_read) try: report = project.run_readiness_checks( ["ready", "pending"], @@ -1057,7 +1057,7 @@ def fake_read( timeouts.append(kwargs["timeout_seconds"]) return _stream_read(stream_name, record_count=1) - monkeypatch.setattr(project, "_test_read_stream", fake_read) + monkeypatch.setattr(project, "_run_stream_test_read", fake_read) project.run_readiness_checks(["users"], timeout_seconds=30) project.run_readiness_checks(["users"]) @@ -1350,7 +1350,7 @@ def test_readiness_full_sync_gating( monkeypatch.setattr(project, "publish", publish) monkeypatch.setattr( project, - "_test_read_stream", + "_run_stream_test_read", lambda _m, name, **_: _stream_read( name, record_count=record_count, status=status ), diff --git a/tests/unit_tests/test_mcp_cloud.py b/tests/unit_tests/test_mcp_cloud.py index 95852e7c9..b929c9b0f 100644 --- a/tests/unit_tests/test_mcp_cloud.py +++ b/tests/unit_tests/test_mcp_cloud.py @@ -3608,7 +3608,7 @@ def test_connector_builder_tools_return_read_warnings( project = SimpleNamespace( builder_project_id="builder-project", connector_builder_project_url="https://cloud.airbyte.com/workspaces/ws/connector-builder/edit/bp", - test_read_stream=lambda *_args, **_kwargs: read_result, + run_stream_test_read=lambda *_args, **_kwargs: read_result, run_readiness_checks=lambda *_args, **_kwargs: readiness_report, ) workspace = SimpleNamespace( @@ -3619,7 +3619,7 @@ def test_connector_builder_tools_return_read_warnings( ) ctx = cast(Context, object()) - stream_result = connector_builder_mcp.test_connector_builder_stream( + stream_result = connector_builder_mcp.run_connector_builder_stream_test_read( ctx, "users", builder_project_id="builder-project", diff --git a/tests/unit_tests/test_mcp_tool_utils.py b/tests/unit_tests/test_mcp_tool_utils.py index dc2eca715..4ee7cd61a 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -299,7 +299,7 @@ def test_server_tools_register_expected_policies() -> None: "create_connector_builder_project", "update_connector_builder_manifest_text", "set_connector_builder_testing_values", - "test_connector_builder_stream", + "run_connector_builder_stream_test_read", "resolve_connector_builder_manifest", "run_connector_builder_readiness_checks", } From d84cf522f4ea817ab370b54a81c963dc9c82fb1c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:32:06 +0000 Subject: [PATCH 37/47] refactor(cloud): make Connector Builder model factories private Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/models.py | 6 +++--- airbyte/cloud/connector_builder/project.py | 6 +++--- tests/unit_tests/test_cloud_connector_builder.py | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/airbyte/cloud/connector_builder/models.py b/airbyte/cloud/connector_builder/models.py index 4686be595..0bc2d3c0e 100644 --- a/airbyte/cloud/connector_builder/models.py +++ b/airbyte/cloud/connector_builder/models.py @@ -88,7 +88,7 @@ class ConnectorBuilderStreamReadResult(BaseModel): ) @classmethod - def from_api_response( + def _from_api_response( cls, stream_name: str, response: dict[str, Any], @@ -147,7 +147,7 @@ class ConnectorBuilderResolvedManifest(BaseModel): stream_names: list[str] @classmethod - def from_manifest(cls, manifest: dict[str, Any]) -> ConnectorBuilderResolvedManifest: + def _from_manifest(cls, manifest: dict[str, Any]) -> ConnectorBuilderResolvedManifest: """Build a result from a resolved manifest.""" return cls( manifest_dict=manifest, @@ -207,7 +207,7 @@ class ConnectorBuilderStreamReadiness(BaseModel): ) @classmethod - def from_stream_read( + def _from_stream_read( cls, result: ConnectorBuilderStreamReadResult ) -> ConnectorBuilderStreamReadiness: """Summarize errors and results from a stream test read.""" diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index 4471e9a00..919ba7db7 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -334,7 +334,7 @@ def _run_stream_test_read( config_api_root=self.workspace.config_api_root, timeout_seconds=timeout_seconds, ) - return ConnectorBuilderStreamReadResult.from_api_response(stream_name, response) + return ConnectorBuilderStreamReadResult._from_api_response(stream_name, response) # noqa: SLF001 def resolve_manifest( self, @@ -359,7 +359,7 @@ def resolve_manifest( bearer_token=self.workspace.bearer_token, config_api_root=self.workspace.config_api_root, ) - return ConnectorBuilderResolvedManifest.from_manifest(resolved_manifest) + return ConnectorBuilderResolvedManifest._from_manifest(resolved_manifest) # noqa: SLF001 def run_readiness_checks( self, @@ -449,7 +449,7 @@ def read(stream_name: str) -> ConnectorBuilderStreamReadiness: failed=True, error=f"Request to Airbyte Cloud failed: {type(ex).__name__}", ) - return ConnectorBuilderStreamReadiness.from_stream_read(result) + return ConnectorBuilderStreamReadiness._from_stream_read(result) # noqa: SLF001 pool = ThreadPoolExecutor(max_workers=min(_READINESS_MAX_WORKERS, len(stream_names))) try: diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 561530976..1c29ae4ba 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -107,7 +107,7 @@ def test_render_manifest_text_keeps_order_and_does_not_wrap() -> None: assert len(text.splitlines()) == 2 -def test_stream_read_result_from_api_response() -> None: +def test_stream_read_result__from_api_response() -> None: response = { "logs": [{"level": "ERROR", "message": "boom", "stacktrace": "..."}], "slices": [ @@ -133,7 +133,7 @@ def test_stream_read_result_from_api_response() -> None: "inferred_schema": {"type": "object"}, } - result = ConnectorBuilderStreamReadResult.from_api_response("users", response) + result = ConnectorBuilderStreamReadResult._from_api_response("users", response) assert result.record_count == 2 assert result.slice_count == 1 @@ -231,8 +231,8 @@ def test_stream_read_warnings_propagate_to_readiness_without_failing( "test_read_limit_reached": limit_reached, } - result = ConnectorBuilderStreamReadResult.from_api_response("users", response) - readiness = ConnectorBuilderStreamReadiness.from_stream_read(result) + result = ConnectorBuilderStreamReadResult._from_api_response("users", response) + readiness = ConnectorBuilderStreamReadiness._from_stream_read(result) if limit_reached: assert result.warnings == [] @@ -244,7 +244,7 @@ def test_stream_read_warnings_propagate_to_readiness_without_failing( def test_resolved_manifest_lists_stream_names() -> None: - resolved = ConnectorBuilderResolvedManifest.from_manifest(MANIFEST) + resolved = ConnectorBuilderResolvedManifest._from_manifest(MANIFEST) assert resolved.stream_names == ["users", "orders"] @@ -666,7 +666,7 @@ def test_run_readiness_checks_summarizes_each_stream( project, { "ok": _stream_read("ok", record_count=3), - "empty": ConnectorBuilderStreamReadResult.from_api_response( + "empty": ConnectorBuilderStreamReadResult._from_api_response( "empty", {"slices": [{"pages": [{"records": []}]}]}, ), From 55153dfef1e377a4796ea99e992fdadd7f853dd5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:47:32 +0000 Subject: [PATCH 38/47] refactor(cloud): make Connector Builder internals private, rename to draft_manifest_yaml and replace_testing_values, one arg per line in MCP tool decorators Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/__init__.py | 16 +-- .../{models.py => _models.py} | 4 +- airbyte/cloud/connector_builder/_util.py | 4 +- airbyte/cloud/connector_builder/project.py | 67 ++++++----- airbyte/cloud/connectors.py | 2 +- airbyte/cloud/workspaces.py | 4 +- airbyte/mcp/connector_builder.py | 112 +++++++++++++----- .../test_cloud_connector_builder.py | 101 ++++++++-------- tests/unit_tests/test_mcp_cloud.py | 2 +- 9 files changed, 180 insertions(+), 132 deletions(-) rename airbyte/cloud/connector_builder/{models.py => _models.py} (98%) diff --git a/airbyte/cloud/connector_builder/__init__.py b/airbyte/cloud/connector_builder/__init__.py index 8c4bd92cb..84e6e7175 100644 --- a/airbyte/cloud/connector_builder/__init__.py +++ b/airbyte/cloud/connector_builder/__init__.py @@ -8,17 +8,10 @@ from __future__ import annotations -from airbyte.cloud.connector_builder.models import ( - ConnectorBuilderFullSync, +from airbyte.cloud.connector_builder._models import ( ConnectorBuilderPageSummary, - ConnectorBuilderProjectInfo, - ConnectorBuilderReadinessCheck, - ConnectorBuilderReadinessReport, ConnectorBuilderResolvedManifest, - ConnectorBuilderStreamReadiness, ConnectorBuilderStreamReadResult, - ManifestTextEditMode, - ReadinessCheckResult, ) from airbyte.cloud.connector_builder.project import ( CloudConnectorBuilderProject, @@ -27,14 +20,7 @@ __all__ = [ "CloudConnectorBuilderProject", - "ConnectorBuilderFullSync", "ConnectorBuilderPageSummary", - "ConnectorBuilderProjectInfo", - "ConnectorBuilderReadinessCheck", - "ConnectorBuilderReadinessReport", "ConnectorBuilderResolvedManifest", - "ConnectorBuilderStreamReadiness", "ConnectorBuilderStreamReadResult", - "ManifestTextEditMode", - "ReadinessCheckResult", ] diff --git a/airbyte/cloud/connector_builder/models.py b/airbyte/cloud/connector_builder/_models.py similarity index 98% rename from airbyte/cloud/connector_builder/models.py rename to airbyte/cloud/connector_builder/_models.py index 0bc2d3c0e..5a1e16581 100644 --- a/airbyte/cloud/connector_builder/models.py +++ b/airbyte/cloud/connector_builder/_models.py @@ -12,7 +12,7 @@ _PROBLEM_LOG_LEVELS = frozenset({"WARN", "ERROR", "FATAL"}) _FAILING_LOG_LEVELS = frozenset({"ERROR", "FATAL"}) ManifestTextEditMode = Literal["replace_full", "replace_text", "replace_all"] -"""How `update_draft_manifest_text()` edits the manifest text. +"""How `_update_draft_manifest_yaml()` edits the manifest text. - `replace_full`: `new_text` is the whole manifest. - `replace_text`: replace the single exact match of `old_text`; fails on zero or several matches. @@ -168,7 +168,7 @@ class ConnectorBuilderProjectInfo(BaseModel): name: str has_draft: bool draft_manifest_dict: dict[str, Any] | None = Field(serialization_alias="draft_manifest") - draft_manifest_text: str | None + draft_manifest_yaml: str | None published_manifest_dict: dict[str, Any] | None = Field(serialization_alias="published_manifest") diff --git a/airbyte/cloud/connector_builder/_util.py b/airbyte/cloud/connector_builder/_util.py index 727188dd9..fa199eab7 100644 --- a/airbyte/cloud/connector_builder/_util.py +++ b/airbyte/cloud/connector_builder/_util.py @@ -9,7 +9,7 @@ import yaml from airbyte import exceptions as exc -from airbyte.cloud.connector_builder.models import ( +from airbyte.cloud.connector_builder._models import ( ConnectorBuilderReadinessCheck, ConnectorBuilderStreamReadiness, ManifestTextEditMode, @@ -24,7 +24,7 @@ CONNECTOR_BUILDER_TEST_DESTINATION_NAME = "Connector Builder Test (No-op)" """Name of the no-op destination that full syncs find or create by default.""" TEST_READ_MAX_RECORDS = 5_000 -"""Most records read per stream by `run_readiness_checks()`.""" +"""Most records read per stream by `_run_readiness_checks()`.""" TEST_READ_MAX_PAGES = 20 TEST_READ_MAX_SLICES = 20 diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index 919ba7db7..e235dd637 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -13,6 +13,15 @@ from airbyte import exceptions as exc from airbyte._util import api_util from airbyte._util.api_util import get_web_url_root +from airbyte.cloud.connector_builder._models import ( + ConnectorBuilderFullSync, + ConnectorBuilderProjectInfo, + ConnectorBuilderReadinessReport, + ConnectorBuilderResolvedManifest, + ConnectorBuilderStreamReadiness, + ConnectorBuilderStreamReadResult, + ManifestTextEditMode, +) from airbyte.cloud.connector_builder._util import ( CONNECTOR_BUILDER_TEST_DESTINATION_NAME, TEST_READ_MAX_PAGES, @@ -30,15 +39,6 @@ spec_has_secrets, spec_missing_required, ) -from airbyte.cloud.connector_builder.models import ( - ConnectorBuilderFullSync, - ConnectorBuilderProjectInfo, - ConnectorBuilderReadinessReport, - ConnectorBuilderResolvedManifest, - ConnectorBuilderStreamReadiness, - ConnectorBuilderStreamReadResult, - ManifestTextEditMode, -) if TYPE_CHECKING: @@ -50,7 +50,7 @@ _READINESS_MAX_WORKERS = 5 -"""Most streams `run_readiness_checks()` reads at once.""" +"""Most streams `_run_readiness_checks()` reads at once.""" _MIN_READ_TIMEOUT = 1.0 """Shortest HTTP timeout for a test read started just before the readiness deadline.""" @@ -143,7 +143,7 @@ def published_manifest_dict(self) -> dict[str, Any] | None: """The active manifest of the published definition, or `None` if never published.""" return self._published_manifest(self.source_definition_id) - def get_info(self) -> ConnectorBuilderProjectInfo: + def _get_info(self) -> ConnectorBuilderProjectInfo: """Read the project once and return its IDs, URL, draft, and published manifest.""" project_data = self._get_project_data() draft_manifest = draft_from_project_data(project_data) @@ -155,7 +155,7 @@ def get_info(self) -> ConnectorBuilderProjectInfo: name=project_data["builderProject"]["name"], has_draft=draft_manifest is not None, draft_manifest_dict=draft_manifest, - draft_manifest_text=( + draft_manifest_yaml=( render_manifest_text(draft_manifest) if draft_manifest is not None else None ), published_manifest_dict=self._published_manifest(source_definition_id), @@ -206,12 +206,12 @@ def replace_draft_manifest( ) @property - def draft_manifest_text(self) -> str | None: + def draft_manifest_yaml(self) -> str | None: """The draft manifest rendered by `render_manifest_text()`, or `None` if no draft.""" manifest = self.draft_manifest_dict return render_manifest_text(manifest) if manifest is not None else None - def update_draft_manifest_text( + def _update_draft_manifest_yaml( self, *, edit_mode: ManifestTextEditMode, @@ -221,7 +221,7 @@ def update_draft_manifest_text( ) -> str: """Edit the draft manifest as text and save it. Does not publish anything. - Edits match against `draft_manifest_text`. Returns a unified diff from the previous + Edits match against `draft_manifest_yaml`. Returns a unified diff from the previous rendering to the rendering of the saved draft. Args: @@ -230,7 +230,7 @@ def update_draft_manifest_text( old_text: The exact text to replace. Required unless `edit_mode` is `replace_full`. pre_validate: Whether to validate the edited manifest client-side before saving. """ - current_text = self.draft_manifest_text + current_text = self.draft_manifest_yaml if current_text is None and edit_mode != "replace_full": raise exc.AirbyteLibInputError( message="This Connector Builder project has no draft manifest to edit.", @@ -245,7 +245,7 @@ def update_draft_manifest_text( ) self.replace_draft_manifest(edited_text, pre_validate=pre_validate) - saved_text = self.draft_manifest_text or "" + saved_text = self.draft_manifest_yaml or "" return "".join( difflib.unified_diff( (current_text or "").splitlines(keepends=True), @@ -255,7 +255,7 @@ def update_draft_manifest_text( ) ) - def set_testing_values( + def replace_testing_values( self, testing_values: dict[str, Any], ) -> None: @@ -361,7 +361,7 @@ def resolve_manifest( ) return ConnectorBuilderResolvedManifest._from_manifest(resolved_manifest) # noqa: SLF001 - def run_readiness_checks( + def _run_readiness_checks( self, stream_names: list[str], *, @@ -391,9 +391,9 @@ def run_readiness_checks( the rest are listed in `pending_streams` and `not_attempted_streams`. No limit by default. run_full_sync: If no named stream failed or is still pending (or none are named), - publish the draft and start a full sync with `run_full_sync()`. - test_source_id: The source for the full sync. See `run_full_sync()`. - on_resource_created: Passed to `run_full_sync()`. + publish the draft and start a full sync with `_run_full_sync()`. + test_source_id: The source for the full sync. See `_run_full_sync()`. + on_resource_created: Passed to `_run_full_sync()`. """ _check_test_read_limits(record_limit, page_limit, slice_limit) stream_names = list(dict.fromkeys(stream_names)) @@ -412,7 +412,7 @@ def run_readiness_checks( manifest = self._require_draft_manifest(project_data) return self._readiness_report(project_data, manifest, [], [], []).model_copy( update={ - "full_sync": self.run_full_sync( + "full_sync": self._run_full_sync( test_source_id=test_source_id, on_resource_created=on_resource_created, ), @@ -471,7 +471,7 @@ def read(stream_name: str) -> ConnectorBuilderStreamReadiness: if run_full_sync and not ( report.failed_streams or report.pending_streams or report.not_attempted_streams ): - report.full_sync = self.run_full_sync( + report.full_sync = self._run_full_sync( test_source_id=test_source_id, on_resource_created=on_resource_created, ) @@ -541,11 +541,11 @@ def publish( ) def _get_default_test_source_name(self) -> str: - """Default name of the source `get_or_create_default_test_source()` finds or creates.""" + """Default name of the source `_get_or_create_default_test_source()` finds or creates.""" return f"{self.name} (Connector Builder test)" @overload - def get_or_create_default_test_source( + def _get_or_create_default_test_source( self, *, allow_create: Literal[True] = True, @@ -553,14 +553,14 @@ def get_or_create_default_test_source( ) -> CloudSource: ... @overload - def get_or_create_default_test_source( + def _get_or_create_default_test_source( self, *, allow_create: bool, default_source_name: str | Callable[[CloudConnectorBuilderProject], str] | None = None, ) -> CloudSource | None: ... - def get_or_create_default_test_source( + def _get_or_create_default_test_source( self, *, allow_create: bool = True, @@ -610,7 +610,7 @@ def get_or_create_default_test_source( defer_credentials=spec_has_secrets(spec) or spec_missing_required(spec, config), ) - def run_full_sync( + def _run_full_sync( self, *, test_source_id: str | None = None, @@ -625,7 +625,7 @@ def run_full_sync( Args: test_source_id: Source on the published definition to sync from. Defaults to the - source from `get_or_create_default_test_source()`. + source from `_get_or_create_default_test_source()`. pre_validate: Whether to validate the manifest client-side before publishing. on_resource_created: Called with the ID of each destination or connection this call creates, as soon as it exists. @@ -637,14 +637,13 @@ def run_full_sync( self._require_draft_manifest(project_data) # Check the source and destination first: the first publish clears the draft. if test_source_id is None: - test_source = self.get_or_create_default_test_source(allow_create=False) + test_source = self._get_or_create_default_test_source(allow_create=False) if test_source is None: raise exc.AirbyteLibInputError( message="No test source found for this Connector Builder project.", guidance=( - "Pass `test_source_id`, or create one with " - "`get_or_create_default_test_source()` and have the user complete its " - "settings." + "Pass `test_source_id`, or create a source on this project's published " + "definition and have the user complete its settings." ), ) test_source_id = test_source.connector_id diff --git a/airbyte/cloud/connectors.py b/airbyte/cloud/connectors.py index 72f5acf1f..8db297888 100644 --- a/airbyte/cloud/connectors.py +++ b/airbyte/cloud/connectors.py @@ -1449,7 +1449,7 @@ def connector_builder_project_id(self) -> str | None: return self._connector_builder_project_id @property - def connector_builder_project(self) -> CloudConnectorBuilderProject | None: + def _connector_builder_project(self) -> CloudConnectorBuilderProject | None: """The Connector Builder project behind this definition. YAML connectors only.""" if self.definition_type != "yaml": return None diff --git a/airbyte/cloud/workspaces.py b/airbyte/cloud/workspaces.py index 52928cc9d..de241ca6e 100644 --- a/airbyte/cloud/workspaces.py +++ b/airbyte/cloud/workspaces.py @@ -1455,10 +1455,10 @@ def get_connector_builder_project( ) assert definition_id is not None - project = self.get_custom_source_definition( + project = self.get_custom_source_definition( # noqa: SLF001 definition_id, definition_type="yaml", - ).connector_builder_project + )._connector_builder_project if project is None: raise exc.AirbyteLibInputError( message="No Connector Builder project was found for this definition.", diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index 26431fc91..6cb14238a 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -14,7 +14,7 @@ from fastmcp_extensions import TraceArg, register_mcp_tools from pydantic import Field -from airbyte.cloud.connector_builder import ManifestTextEditMode +from airbyte.cloud.connector_builder._models import ManifestTextEditMode from airbyte.cloud.connector_builder._util import ( TEST_READ_MAX_PAGES, TEST_READ_MAX_RECORDS, @@ -68,12 +68,18 @@ def get_connector_builder_draft_manifest( ctx: Context, definition_id: Annotated[ str | None, - Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + Field( + description=_BUILDER_DEFINITION_ID_DESCRIPTION, + default=None, + ), ] = None, *, builder_project_id: Annotated[ str | None, - Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + Field( + description=_BUILDER_PROJECT_ID_DESCRIPTION, + default=None, + ), ] = None, workspace_id: Annotated[ str | None, @@ -87,7 +93,7 @@ def get_connector_builder_draft_manifest( """Get the Connector Builder draft manifest of a project or custom source definition. Returns the working draft manifest that has been saved but not yet published, both as - a dict and as `draft_manifest_text`. Airbyte Cloud stores the parsed manifest, so the text + a dict and as `draft_manifest_yaml`. Airbyte Cloud stores the parsed manifest, so the text is a normalized YAML rendering without comments; text edits with `update_connector_builder_manifest_text` match against it. @@ -99,10 +105,13 @@ def get_connector_builder_draft_manifest( builder_project_id, definition_id=definition_id, ) - return project.get_info().model_dump(by_alias=True) + return project._get_info().model_dump(by_alias=True) # noqa: SLF001 -@mcp_tool(extra_help_text=_manifest_yaml_source_hint, insiders_only=True) +@mcp_tool( + extra_help_text=_manifest_yaml_source_hint, + insiders_only=True, +) def create_connector_builder_project( ctx: Context, name: Annotated[ @@ -190,7 +199,7 @@ def create_connector_builder_project( if create_test_source: definition = project.publish(name, pre_validate=pre_validate) register_guid_created_in_session(definition.definition_id) - test_source = project.get_or_create_default_test_source() + test_source = project._get_or_create_default_test_source() # noqa: SLF001 register_guid_created_in_session(test_source.connector_id) result |= { "definition_id": definition.definition_id, @@ -200,7 +209,10 @@ def create_connector_builder_project( return result -@mcp_tool(destructive=True, insiders_only=True) +@mcp_tool( + destructive=True, + insiders_only=True, +) def update_connector_builder_manifest_text( ctx: Context, edit_mode: Annotated[ @@ -228,7 +240,7 @@ def update_connector_builder_manifest_text( str | None, Field( description=( - "The exact text to replace, copied verbatim from `draft_manifest_text`. " + "The exact text to replace, copied verbatim from `draft_manifest_yaml`. " "Required unless `edit_mode` is `replace_full`." ), default=None, @@ -236,11 +248,17 @@ def update_connector_builder_manifest_text( ] = None, builder_project_id: Annotated[ str | None, - Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + Field( + description=_BUILDER_PROJECT_ID_DESCRIPTION, + default=None, + ), ] = None, definition_id: Annotated[ str | None, - Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + Field( + description=_BUILDER_DEFINITION_ID_DESCRIPTION, + default=None, + ), ] = None, workspace_id: Annotated[ str | None, @@ -260,7 +278,7 @@ def update_connector_builder_manifest_text( ) -> str: """Edit and save a Connector Builder project's draft manifest. Does not publish anything. - Text edits match against `draft_manifest_text` from `get_connector_builder_draft_manifest`. + Text edits match against `draft_manifest_yaml` from `get_connector_builder_draft_manifest`. Returns a unified diff from the previous text to the saved text; base later edits on the saved text. """ @@ -270,7 +288,7 @@ def update_connector_builder_manifest_text( definition_id=definition_id, ) _check_builder_project_created_in_session(project) - diff = project.update_draft_manifest_text( + diff = project._update_draft_manifest_yaml( # noqa: SLF001 edit_mode=edit_mode, new_text=new_text, old_text=old_text, @@ -282,17 +300,27 @@ def update_connector_builder_manifest_text( ) -@mcp_tool(destructive=True, idempotent=True, insiders_only=True) +@mcp_tool( + destructive=True, + idempotent=True, + insiders_only=True, +) def set_connector_builder_testing_values( ctx: Context, *, builder_project_id: Annotated[ str | None, - Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + Field( + description=_BUILDER_PROJECT_ID_DESCRIPTION, + default=None, + ), ] = None, definition_id: Annotated[ str | None, - Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + Field( + description=_BUILDER_DEFINITION_ID_DESCRIPTION, + default=None, + ), ] = None, workspace_id: Annotated[ str | None, @@ -347,7 +375,7 @@ def set_connector_builder_testing_values( definition_id=definition_id, ) _check_builder_project_created_in_session(project) - project.set_testing_values(testing_values_dict) + project.replace_testing_values(testing_values_dict) return ( f"Saved testing values for Connector Builder project {project.builder_project_id}. " f"Users can review them or enter secrets at: {project.connector_builder_project_url}" @@ -368,7 +396,11 @@ def _redact_stream_diagnostics(stream_data: dict[str, Any]) -> dict[str, Any]: return stream_data -@mcp_tool(read_only=True, external_access=True, insiders_only=True) +@mcp_tool( + read_only=True, + external_access=True, + insiders_only=True, +) def run_connector_builder_stream_test_read( ctx: Context, stream_name: Annotated[ @@ -379,11 +411,17 @@ def run_connector_builder_stream_test_read( *, builder_project_id: Annotated[ str | None, - Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + Field( + description=_BUILDER_PROJECT_ID_DESCRIPTION, + default=None, + ), ] = None, definition_id: Annotated[ str | None, - Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + Field( + description=_BUILDER_DEFINITION_ID_DESCRIPTION, + default=None, + ), ] = None, workspace_id: Annotated[ str | None, @@ -453,17 +491,28 @@ def run_connector_builder_stream_test_read( } -@mcp_tool(read_only=True, idempotent=True, external_access=True, insiders_only=True) +@mcp_tool( + read_only=True, + idempotent=True, + external_access=True, + insiders_only=True, +) def resolve_connector_builder_manifest( ctx: Context, *, builder_project_id: Annotated[ str | None, - Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + Field( + description=_BUILDER_PROJECT_ID_DESCRIPTION, + default=None, + ), ] = None, definition_id: Annotated[ str | None, - Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + Field( + description=_BUILDER_DEFINITION_ID_DESCRIPTION, + default=None, + ), ] = None, workspace_id: Annotated[ str | None, @@ -502,7 +551,10 @@ def resolve_connector_builder_manifest( } -@mcp_tool(external_access=True, insiders_only=True) +@mcp_tool( + external_access=True, + insiders_only=True, +) def run_connector_builder_readiness_checks( ctx: Context, stream_names: Annotated[ @@ -520,11 +572,17 @@ def run_connector_builder_readiness_checks( *, builder_project_id: Annotated[ str | None, - Field(description=_BUILDER_PROJECT_ID_DESCRIPTION, default=None), + Field( + description=_BUILDER_PROJECT_ID_DESCRIPTION, + default=None, + ), ] = None, definition_id: Annotated[ str | None, - Field(description=_BUILDER_DEFINITION_ID_DESCRIPTION, default=None), + Field( + description=_BUILDER_DEFINITION_ID_DESCRIPTION, + default=None, + ), ] = None, workspace_id: Annotated[ str | None, @@ -588,7 +646,7 @@ def run_connector_builder_readiness_checks( ) if run_full_sync: _check_builder_project_created_in_session(project) - report = project.run_readiness_checks( + report = project._run_readiness_checks( # noqa: SLF001 resolve_list_of_strings(stream_names) or [], timeout_seconds=timeout_seconds, run_full_sync=run_full_sync, diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 1c29ae4ba..def99125c 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -13,14 +13,17 @@ from airbyte._util import api_util from airbyte.cloud import connector_builder +from airbyte.cloud.connector_builder import _models from airbyte.cloud.connector_builder import ( CloudConnectorBuilderProject, ConnectorBuilderPageSummary, ConnectorBuilderResolvedManifest, - ConnectorBuilderStreamReadiness, ConnectorBuilderStreamReadResult, ) -from airbyte.cloud.connector_builder.models import _record_count_warnings +from airbyte.cloud.connector_builder._models import ( + ConnectorBuilderStreamReadiness, + _record_count_warnings, +) from airbyte.cloud.connector_builder._util import ( apply_manifest_text_edit, drop_masked_secrets, @@ -57,7 +60,7 @@ ], ) def test_apply_manifest_text_edit( - edit_mode: connector_builder.ManifestTextEditMode, + edit_mode: _models.ManifestTextEditMode, new_text: str, old_text: str | None, expected: str, @@ -90,7 +93,7 @@ def test_apply_manifest_text_edit_replace_all_replaces_every_match() -> None: ], ) def test_apply_manifest_text_edit_errors( - edit_mode: connector_builder.ManifestTextEditMode, + edit_mode: _models.ManifestTextEditMode, old_text: str | None, match: str, ) -> None: @@ -419,7 +422,7 @@ def test_project_url() -> None: ) -def test_update_draft_manifest_text_saves_and_returns_diff( +def test_update_draft_manifest_yaml_saves_and_returns_diff( monkeypatch: pytest.MonkeyPatch, ) -> None: store = _patch_project_store(monkeypatch, draft={"version": "6.0.0", "streams": []}) @@ -427,7 +430,7 @@ def test_update_draft_manifest_text_saves_and_returns_diff( workspace=_fake_workspace(), builder_project_id="bp" ) - diff = project.update_draft_manifest_text( + diff = project._update_draft_manifest_yaml( edit_mode="replace_text", old_text="version: 6.0.0", new_text="version: 6.1.0", @@ -439,7 +442,7 @@ def test_update_draft_manifest_text_saves_and_returns_diff( assert "+version: 6.1.0" in diff -def test_update_draft_manifest_text_without_draft_requires_replace_full( +def test_update_draft_manifest_yaml_without_draft_requires_replace_full( monkeypatch: pytest.MonkeyPatch, ) -> None: store = _patch_project_store(monkeypatch, draft=None) @@ -448,17 +451,19 @@ def test_update_draft_manifest_text_without_draft_requires_replace_full( ) with pytest.raises(AirbyteLibInputError, match="no draft manifest"): - project.update_draft_manifest_text( + project._update_draft_manifest_yaml( edit_mode="replace_all", old_text="a", new_text="b" ) - project.update_draft_manifest_text( + project._update_draft_manifest_yaml( edit_mode="replace_full", new_text="version: 6.0.0\n" ) assert store["draft"] == {"version": "6.0.0"} -def test_set_testing_values_uses_draft_spec(monkeypatch: pytest.MonkeyPatch) -> None: +def test_replace_testing_values_uses_draft_spec( + monkeypatch: pytest.MonkeyPatch, +) -> None: _patch_project_store(monkeypatch, draft=MANIFEST) update_testing_values = MagicMock() monkeypatch.setattr( @@ -469,7 +474,7 @@ def test_set_testing_values_uses_draft_spec(monkeypatch: pytest.MonkeyPatch) -> CloudConnectorBuilderProject( workspace=_fake_workspace(), builder_project_id="bp" - ).set_testing_values({"api_key": "k"}) + ).replace_testing_values({"api_key": "k"}) kwargs = update_testing_values.call_args.kwargs assert kwargs["testing_values"] == {"api_key": "k"} @@ -480,7 +485,7 @@ def test_set_testing_values_uses_draft_spec(monkeypatch: pytest.MonkeyPatch) -> } -def test_set_testing_values_without_draft_uses_published_spec( +def test_replace_testing_values_without_draft_uses_published_spec( monkeypatch: pytest.MonkeyPatch, ) -> None: _patch_project_store(monkeypatch, draft=None, source_definition_id="def") @@ -495,7 +500,7 @@ def test_set_testing_values_without_draft_uses_published_spec( ) monkeypatch.setattr(project, "_published_manifest", lambda _id: _secret_manifest()) - project.set_testing_values({"api_key": "k"}) + project.replace_testing_values({"api_key": "k"}) spec = update_testing_values.call_args.kwargs["spec"] assert spec["connectionSpecification"]["properties"]["api_key"]["airbyte_secret"] @@ -580,11 +585,11 @@ def counting_get(**kwargs: Any) -> dict[str, Any]: info = CloudConnectorBuilderProject( workspace=_fake_workspace(), builder_project_id="bp" - ).get_info() + )._get_info() assert len(calls) == 1 assert info.has_draft is True - assert info.draft_manifest_text == "version: 6.0.0\n" + assert info.draft_manifest_yaml == "version: 6.0.0\n" assert info.definition_id is None assert info.published_manifest_dict is None dumped = info.model_dump(by_alias=True) @@ -682,7 +687,7 @@ def test_run_readiness_checks_summarizes_each_stream( }, ) - report = project.run_readiness_checks(names) + report = project._run_readiness_checks(names) assert [stream.stream_name for stream in report.streams] == names assert {stream.stream_name: stream.failed for stream in report.streams} == { @@ -729,10 +734,10 @@ def test_run_readiness_checks_requires_stream_names() -> None: workspace=_fake_workspace(), builder_project_id="bp" ) with pytest.raises(AirbyteLibInputError): - project.run_readiness_checks([]) + project._run_readiness_checks([]) -@pytest.mark.parametrize("method", ["run_stream_test_read", "run_readiness_checks"]) +@pytest.mark.parametrize("method", ["run_stream_test_read", "_run_readiness_checks"]) @pytest.mark.parametrize( ("limit_name", "limit_value"), [ @@ -771,7 +776,7 @@ def unexpected_api_call(*_: Any, **__: Any) -> Any: if method == "run_stream_test_read": project.run_stream_test_read("users", **{limit_name: limit_value}) else: - project.run_readiness_checks(["users"], **{limit_name: limit_value}) + project._run_readiness_checks(["users"], **{limit_name: limit_value}) expected[limit_name] = limit_value assert error.value.get_message() == "Test-read limits are out of range." @@ -793,7 +798,7 @@ def fake_read(**kwargs: Any) -> dict[str, Any]: workspace=_fake_workspace(), builder_project_id="bp" ) - project.run_readiness_checks(["users"]) + project._run_readiness_checks(["users"]) assert len(received) == 1 assert received[0]["record_limit"] == connector_builder._util.TEST_READ_MAX_RECORDS @@ -814,7 +819,7 @@ def fail_read(*_: Any, **__: Any) -> ConnectorBuilderStreamReadResult: monkeypatch.setattr(project, "_run_stream_test_read", fail_read) - report = project.run_readiness_checks(["users"]) + report = project._run_readiness_checks(["users"]) assert report.streams[0].read_to_end is False assert report.checks[5].result == "failed" @@ -842,7 +847,7 @@ def test_readiness_checklist_marks_failed_capped_stream_incomplete( }, ) - report = project.run_readiness_checks(["users"]) + report = project._run_readiness_checks(["users"]) assert report.streams[0].failed is True assert report.streams[0].error is None @@ -868,7 +873,7 @@ def test_run_readiness_checks_without_draft_fails( with pytest.raises(AirbyteLibInputError): CloudConnectorBuilderProject( workspace=_fake_workspace(), builder_project_id="bp" - ).run_readiness_checks(["users"]) + )._run_readiness_checks(["users"]) def test_readiness_report_lists_caps_without_a_success_verdict( @@ -884,10 +889,10 @@ def test_readiness_report_lists_caps_without_a_success_verdict( } _patch_reads(monkeypatch, project, reads) - partial = project.run_readiness_checks(["users"]) - capped = project.run_readiness_checks(["users", "orders"]) + partial = project._run_readiness_checks(["users"]) + capped = project._run_readiness_checks(["users", "orders"]) reads["orders"] = _stream_read("orders", record_count=1) - full = project.run_readiness_checks(["users", "orders", "users"]) + full = project._run_readiness_checks(["users", "orders", "users"]) assert partial.failed_streams == [] assert capped.failed_streams == [] @@ -921,7 +926,7 @@ def test_readiness_checks_require_testing_values_when_spec_has_inputs( ) _patch_reads(monkeypatch, project, {"users": _stream_read("users", record_count=1)}) - report = project.run_readiness_checks(["users"]) + report = project._run_readiness_checks(["users"]) assert [check.result for check in report.checks] == [ "passed", @@ -998,7 +1003,7 @@ def fake_read( monkeypatch.setattr(project, "_run_stream_test_read", fake_read) names = ["fast", *sorted(slow), "queued"] try: - report = project.run_readiness_checks(names, timeout_seconds=0.5) + report = project._run_readiness_checks(names, timeout_seconds=0.5) finally: release.set() assert report.failed_streams == [] @@ -1029,7 +1034,7 @@ def fake_read( monkeypatch.setattr(project, "_run_stream_test_read", fake_read) try: - report = project.run_readiness_checks( + report = project._run_readiness_checks( ["ready", "pending"], timeout_seconds=0.5, ) @@ -1058,8 +1063,8 @@ def fake_read( return _stream_read(stream_name, record_count=1) monkeypatch.setattr(project, "_run_stream_test_read", fake_read) - project.run_readiness_checks(["users"], timeout_seconds=30) - project.run_readiness_checks(["users"]) + project._run_readiness_checks(["users"], timeout_seconds=30) + project._run_readiness_checks(["users"]) # Windows clock granularity can make the remaining time a hair over the timeout. assert timeouts[0] is not None and 0 < timeouts[0] <= 30 + 1e-3 @@ -1159,7 +1164,7 @@ def test_create_test_source_uses_non_secret_testing_values( # The source runs the published version, so its spec wins over the draft's. monkeypatch.setattr(project, "_published_manifest", lambda _id: manifest) - project.get_or_create_default_test_source() + project._get_or_create_default_test_source() workspace.deploy_source.assert_called_once_with( name="My API (Connector Builder test)", @@ -1176,7 +1181,7 @@ def test_create_test_source_requires_publish(monkeypatch: pytest.MonkeyPatch) -> ) with pytest.raises(AirbyteLibInputError, match="Publish"): - project.get_or_create_default_test_source() + project._get_or_create_default_test_source() def _full_sync_project( @@ -1230,7 +1235,7 @@ def test_run_full_sync_creates_noop_destination_and_connection( ], ) - result = project.run_full_sync() + result = project._run_full_sync() project.publish.assert_called_once_with("My API", pre_validate=True) workspace.list_sources.assert_called_once_with( @@ -1266,7 +1271,7 @@ def test_run_full_sync_reports_created_resources_before_sync_fails( created: list[str] = [] with pytest.raises(RuntimeError, match="boom"): - project.run_full_sync(test_source_id="src", on_resource_created=created.append) + project._run_full_sync(test_source_id="src", on_resource_created=created.append) assert created == ["new-dest", "conn"] @@ -1287,7 +1292,7 @@ def test_run_full_sync_reuses_destination_and_connection( connections=[existing], ) - result = project.run_full_sync(test_source_id="src") + result = project._run_full_sync(test_source_id="src") workspace.deploy_destination.assert_not_called() workspace.deploy_connection.assert_not_called() @@ -1309,7 +1314,7 @@ def test_run_full_sync_without_test_source_fails( ) with pytest.raises(AirbyteLibInputError, match="No test source"): - project.run_full_sync() + project._run_full_sync() project.publish.assert_not_called() workspace.deploy_destination.assert_not_called() @@ -1335,7 +1340,7 @@ def test_readiness_full_sync_gating( project = CloudConnectorBuilderProject( workspace=_fake_workspace(), builder_project_id="bp" ) - full_sync = connector_builder.ConnectorBuilderFullSync( + full_sync = _models.ConnectorBuilderFullSync( source_id="src", destination_id="dest", connection_id="conn", @@ -1345,7 +1350,7 @@ def test_readiness_full_sync_gating( job_url="u", ) run_full_sync = MagicMock(return_value=full_sync) - monkeypatch.setattr(project, "run_full_sync", run_full_sync) + monkeypatch.setattr(project, "_run_full_sync", run_full_sync) publish = MagicMock() monkeypatch.setattr(project, "publish", publish) monkeypatch.setattr( @@ -1356,7 +1361,7 @@ def test_readiness_full_sync_gating( ), ) - report = project.run_readiness_checks( + report = project._run_readiness_checks( stream_names, run_full_sync=True, test_source_id="src" ) @@ -1381,7 +1386,7 @@ def test_readiness_requires_streams_without_full_sync( ) with pytest.raises(AirbyteLibInputError, match="run_full_sync"): - project.run_readiness_checks([]) + project._run_readiness_checks([]) def test_run_full_sync_selects_streams_discovered_after_publish( @@ -1405,7 +1410,7 @@ def test_run_full_sync_selects_streams_discovered_after_publish( ["users", "tables_a", "tables_b"], )[1] - project.run_full_sync(test_source_id="src") + project._run_full_sync(test_source_id="src") assert calls == ["publish", "discover"] assert api_util.get_source_stream_names.call_args.args == ("src",) @@ -1424,7 +1429,7 @@ def test_run_full_sync_rejects_source_without_streams( ) with pytest.raises(AirbyteLibInputError, match="no streams"): - project.run_full_sync(test_source_id="src") + project._run_full_sync(test_source_id="src") workspace.deploy_connection.assert_not_called() @@ -1444,7 +1449,7 @@ def test_run_full_sync_without_draft_syncs_published_version( draft=None, ) - project.run_full_sync(test_source_id="src") + project._run_full_sync(test_source_id="src") project.publish.assert_not_called() workspace.deploy_connection.assert_called_once() @@ -1459,7 +1464,7 @@ def test_run_full_sync_rejects_source_of_other_definition( workspace.get_source.return_value = SimpleNamespace(definition_id="other-def") with pytest.raises(AirbyteLibInputError, match="published definition"): - project.run_full_sync(test_source_id="src") + project._run_full_sync(test_source_id="src") workspace.deploy_connection.assert_not_called() @@ -1478,7 +1483,7 @@ def test_create_test_source_after_publish_cleared_draft( project = CloudConnectorBuilderProject(workspace=workspace, builder_project_id="bp") monkeypatch.setattr(project, "_published_manifest", lambda _id: _secret_manifest()) - project.get_or_create_default_test_source() + project._get_or_create_default_test_source() assert workspace.deploy_source.call_args.kwargs["defer_credentials"] is True @@ -1498,7 +1503,7 @@ def test_run_readiness_checks_full_sync_only_without_draft( draft=None, ) - report = project.run_readiness_checks([], run_full_sync=True, test_source_id="src") + report = project._run_readiness_checks([], run_full_sync=True, test_source_id="src") assert report.full_sync is not None report_data = report.model_dump() @@ -1523,7 +1528,7 @@ def test_run_full_sync_rejects_non_noop_destination_with_default_name( ) with pytest.raises(AirbyteLibInputError, match="isn't a no-op"): - project.run_full_sync(test_source_id="src") + project._run_full_sync(test_source_id="src") workspace.deploy_destination.assert_not_called() workspace.deploy_connection.assert_not_called() diff --git a/tests/unit_tests/test_mcp_cloud.py b/tests/unit_tests/test_mcp_cloud.py index b929c9b0f..72b3d957a 100644 --- a/tests/unit_tests/test_mcp_cloud.py +++ b/tests/unit_tests/test_mcp_cloud.py @@ -3609,7 +3609,7 @@ def test_connector_builder_tools_return_read_warnings( builder_project_id="builder-project", connector_builder_project_url="https://cloud.airbyte.com/workspaces/ws/connector-builder/edit/bp", run_stream_test_read=lambda *_args, **_kwargs: read_result, - run_readiness_checks=lambda *_args, **_kwargs: readiness_report, + _run_readiness_checks=lambda *_args, **_kwargs: readiness_report, ) workspace = SimpleNamespace( get_connector_builder_project=lambda *_args, **_kwargs: project From e68f1ba9f70d84ad66ff17d3504898a09c286463 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:53:07 +0000 Subject: [PATCH 39/47] refactor(cloud): rename resolve_manifest to get_resolved_manifest Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/project.py | 4 ++-- airbyte/mcp/connector_builder.py | 6 +++--- tests/unit_tests/fixtures/arg_trace_plan.json | 2 +- tests/unit_tests/test_mcp_tool_utils.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index e235dd637..c243b4810 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -336,7 +336,7 @@ def _run_stream_test_read( ) return ConnectorBuilderStreamReadResult._from_api_response(stream_name, response) # noqa: SLF001 - def resolve_manifest( + def get_resolved_manifest( self, *, stream_limit: int = 5, @@ -383,7 +383,7 @@ def _run_readiness_checks( Args: stream_names: Streams to test-read (at least 1). For - dynamic streams, get the generated names from `resolve_manifest()`. + dynamic streams, get the generated names from `get_resolved_manifest()`. record_limit: Maximum records to read per stream (1 to 5,000). page_limit: Maximum pages to fetch per slice (1 to 20). slice_limit: Maximum slices to read (1 to 20). diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index 6cb14238a..6bf5bbc36 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -497,7 +497,7 @@ def run_connector_builder_stream_test_read( external_access=True, insiders_only=True, ) -def resolve_connector_builder_manifest( +def get_connector_builder_resolved_manifest( ctx: Context, *, builder_project_id: Annotated[ @@ -543,7 +543,7 @@ def resolve_connector_builder_manifest( builder_project_id, definition_id=definition_id, ) - resolved = project.resolve_manifest(stream_limit=stream_limit) + resolved = project.get_resolved_manifest(stream_limit=stream_limit) return { "builder_project_id": project.builder_project_id, "stream_names": resolved.stream_names, @@ -563,7 +563,7 @@ def run_connector_builder_readiness_checks( description=( "Streams to test-read. The report covers only these streams. Required unless " "`run_full_sync` is set. For dynamic streams, get the generated names from " - "`resolve_connector_builder_manifest`." + "`get_connector_builder_resolved_manifest`." ), default=None, ), diff --git a/tests/unit_tests/fixtures/arg_trace_plan.json b/tests/unit_tests/fixtures/arg_trace_plan.json index 8100fb246..6063f1d8b 100644 --- a/tests/unit_tests/fixtures/arg_trace_plan.json +++ b/tests/unit_tests/fixtures/arg_trace_plan.json @@ -298,7 +298,7 @@ "streams": "hash list", "workspace_id": "omit" }, - "resolve_connector_builder_manifest": { + "get_connector_builder_resolved_manifest": { "builder_project_id": "hash", "definition_id": "hash", "stream_limit": "value int", diff --git a/tests/unit_tests/test_mcp_tool_utils.py b/tests/unit_tests/test_mcp_tool_utils.py index 4ee7cd61a..416e08360 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -300,7 +300,7 @@ def test_server_tools_register_expected_policies() -> None: "update_connector_builder_manifest_text", "set_connector_builder_testing_values", "run_connector_builder_stream_test_read", - "resolve_connector_builder_manifest", + "get_connector_builder_resolved_manifest", "run_connector_builder_readiness_checks", } From 13972d2eef630fc18c3495bb3d0bae34beebe559 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:03:02 +0000 Subject: [PATCH 40/47] fix(mcp): only advertise secret refs and testing_values_secret_name with trusted execution Co-Authored-By: AJ Steers --- airbyte/mcp/cloud.py | 27 ++++++++++++++----- airbyte/mcp/connector_builder.py | 11 +++++--- tests/unit_tests/test_mcp_arg_trace_parity.py | 7 +++++ .../unit_tests/test_mcp_trusted_execution.py | 17 ++++++++++++ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 5e0c16934..1723055f1 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -127,10 +127,25 @@ ) +TESTING_VALUES_SECRET_HINT = ( + "`testing_values` supports inline secret refs via `secret_reference::ENV_VAR_NAME`." +) + + def _manifest_yaml_source_hint() -> str | None: return MANIFEST_YAML_SOURCE_HINT if is_trusted_execution_enabled() else None +def _testing_values_secret_hint() -> str | None: + return TESTING_VALUES_SECRET_HINT if is_trusted_execution_enabled() else None + + +def _manifest_and_testing_values_hint() -> str | None: + if not is_trusted_execution_enabled(): + return None + return f"{MANIFEST_YAML_SOURCE_HINT} {TESTING_VALUES_SECRET_HINT}" + + SKILL_DOCS_SECTION_HINT = """\ Skill doc sections for direct API queries and actions are named with the pattern `actions..`, matching the `entity_type` and `action` arguments of the @@ -3236,7 +3251,7 @@ def _get_custom_source_definition_description( ) -@mcp_tool(extra_help_text=_manifest_yaml_source_hint) +@mcp_tool(extra_help_text=_manifest_and_testing_values_hint) def publish_custom_source_definition( # noqa: PLR0913 # Explicit args mirror MCP tool schema. ctx: Context, name: Annotated[ @@ -3292,7 +3307,6 @@ def publish_custom_source_definition( # noqa: PLR0913 # Explicit args mirror M description=( "Optional testing configuration values for the Builder UI. " "Can be provided as a JSON object or JSON string. " - "Supports inline secret refs via 'secret_reference::ENV_VAR_NAME' syntax. " "If provided, these values replace any existing testing values " "for the connector builder project, allowing immediate test read operations." ), @@ -3479,7 +3493,7 @@ def get_custom_source_definition( return result -@mcp_tool(destructive=True, extra_help_text=_manifest_yaml_source_hint) +@mcp_tool(destructive=True, extra_help_text=_manifest_and_testing_values_hint) def update_custom_source_definition( # noqa: PLR0913 # Explicit args mirror MCP tool schema. ctx: Context, definition_id: Annotated[ @@ -3539,7 +3553,6 @@ def update_custom_source_definition( # noqa: PLR0913 # Explicit args mirror MC description=( "Optional testing configuration values for the Builder UI. " "Can be provided as a JSON object or JSON string. " - "Supports inline secret refs via 'secret_reference::ENV_VAR_NAME' syntax. " "If provided, these values replace any existing testing values " "for the connector builder project. The entire testing values object " "is overwritten, so pass the full set of values you want to persist." @@ -3582,7 +3595,9 @@ def update_custom_source_definition( # noqa: PLR0913 # Explicit args mirror MC required_args = ["name", "manifest_yaml_text"] if is_trusted_execution_enabled(): required_args.append("manifest_yaml_file") - required_args.extend(("testing_values", "testing_values_secret_name")) + required_args.append("testing_values") + if is_trusted_execution_enabled(): + required_args.append("testing_values_secret_name") raise AirbyteLibInputError( message=( f"At least one of {', '.join(required_args)} must be provided to update " @@ -4312,7 +4327,7 @@ def register_cloud_tools(app: FastMCP) -> None: """ exclude_args = ["workspace_id"] if AIRBYTE_CLOUD_WORKSPACE_ID_IS_SET else [] if not is_trusted_execution_enabled(): - exclude_args.append("manifest_yaml_file") + exclude_args.extend(("manifest_yaml_file", "testing_values_secret_name")) if exclude_args: _add_defaults_for_exclude_args(exclude_args) register_mcp_tools( diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index 6bf5bbc36..cf9963ac2 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -46,6 +46,7 @@ _check_builder_project_created_in_session, _get_cloud_workspace, _manifest_yaml_source_hint, + _testing_values_secret_hint, ) @@ -303,6 +304,7 @@ def update_connector_builder_manifest_text( @mcp_tool( destructive=True, idempotent=True, + extra_help_text=_testing_values_secret_hint, insiders_only=True, ) def set_connector_builder_testing_values( @@ -335,7 +337,6 @@ def set_connector_builder_testing_values( Field( description=( "Configuration values for test reads, as a JSON object or JSON string. " - "Supports inline secret refs via 'secret_reference::ENV_VAR_NAME' syntax. " "Replaces all existing testing values." ), default=None, @@ -363,7 +364,11 @@ def set_connector_builder_testing_values( """ if testing_values is None and testing_values_secret_name is None: raise AirbyteLibInputError( - message="Provide `testing_values` or `testing_values_secret_name`.", + message=( + "Provide `testing_values` or `testing_values_secret_name`." + if is_trusted_execution_enabled() + else "Provide `testing_values`." + ), ) testing_values_dict = resolve_connector_config( config=testing_values, @@ -672,7 +677,7 @@ def register_connector_builder_tools(app: FastMCP) -> None: """ exclude_args = ["workspace_id"] if AIRBYTE_CLOUD_WORKSPACE_ID_IS_SET else [] if not is_trusted_execution_enabled(): - exclude_args.append("manifest_yaml_file") + exclude_args.extend(("manifest_yaml_file", "testing_values_secret_name")) if exclude_args: _add_defaults_for_exclude_args(exclude_args) register_mcp_tools( diff --git a/tests/unit_tests/test_mcp_arg_trace_parity.py b/tests/unit_tests/test_mcp_arg_trace_parity.py index bcc469da4..196901baa 100644 --- a/tests/unit_tests/test_mcp_arg_trace_parity.py +++ b/tests/unit_tests/test_mcp_arg_trace_parity.py @@ -85,6 +85,13 @@ def test_inventory_matches_fixture() -> None: ): if "manifest_yaml_file" not in plan[name]: expected[name].pop("manifest_yaml_file", None) + for name in ( + "publish_custom_source_definition", + "set_connector_builder_testing_values", + "update_custom_source_definition", + ): + if "testing_values_secret_name" not in plan[name]: + expected[name].pop("testing_values_secret_name", None) assert plan == expected diff --git a/tests/unit_tests/test_mcp_trusted_execution.py b/tests/unit_tests/test_mcp_trusted_execution.py index 1f3ea2d63..416dcf638 100644 --- a/tests/unit_tests/test_mcp_trusted_execution.py +++ b/tests/unit_tests/test_mcp_trusted_execution.py @@ -289,6 +289,23 @@ async def list_tools() -> dict[str, object]: not in properties["manifest_yaml_file"]["description"] ) + for tool_name in ( + "publish_custom_source_definition", + "set_connector_builder_testing_values", + "update_custom_source_definition", + ): + tool = tools[tool_name] + properties = tool.input_schema["properties"] # type: ignore[attr-defined] + assert "testing_values" in properties + assert ("testing_values_secret_name" in properties) is enabled + assert ( + "secret_reference::" not in properties["testing_values"]["description"] + ) + assert ( + cloud.TESTING_VALUES_SECRET_HINT + in (getattr(tool, "description", None) or "") + ) is enabled + @pytest.mark.parametrize("enabled", [False, True], ids=["untrusted", "trusted"]) def test_publish_missing_manifest_error_depends_on_trusted_execution( From cae0e2ceef535ce6459aea3d71b17f40b3ec019d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:07:27 +0000 Subject: [PATCH 41/47] refactor(mcp): return typed result models from Connector Builder tools Co-Authored-By: AJ Steers --- airbyte/mcp/connector_builder.py | 141 ++++++++++++++++++++++------- tests/unit_tests/test_mcp_cloud.py | 71 +++++++++------ 2 files changed, 150 insertions(+), 62 deletions(-) diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index cf9963ac2..786f372b2 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -12,9 +12,13 @@ from fastmcp import Context, FastMCP from fastmcp_extensions import TraceArg, register_mcp_tools -from pydantic import Field +from pydantic import BaseModel, Field -from airbyte.cloud.connector_builder._models import ManifestTextEditMode +from airbyte.cloud.connector_builder._models import ( + ConnectorBuilderReadinessReport, + ConnectorBuilderStreamReadResult, + ManifestTextEditMode, +) from airbyte.cloud.connector_builder._util import ( TEST_READ_MAX_PAGES, TEST_READ_MAX_RECORDS, @@ -61,6 +65,71 @@ _TEST_READ_RECORDS_PREVIEW_LIMIT = 10 +class ConnectorBuilderDraftManifestResult(BaseModel): + """Draft and published manifests of a Connector Builder project.""" + + builder_project_id: str + """The Connector Builder project ID.""" + definition_id: str | None + """The published custom source definition ID, or `None` if never published.""" + connector_builder_project_url: str + """The project's Connector Builder URL.""" + name: str + """The project name.""" + has_draft: bool + """Whether the project has a saved draft manifest.""" + draft_manifest: dict[str, Any] | None + """The draft manifest, or `None` without a draft.""" + draft_manifest_yaml: str | None + """The draft manifest as YAML text, or `None` without a draft.""" + published_manifest: dict[str, Any] | None + """The published manifest, or `None` if never published.""" + + +class ConnectorBuilderProjectCreateResult(BaseModel): + """Result of creating a Connector Builder project.""" + + builder_project_id: str + """The new Connector Builder project ID.""" + definition_id: str | None + """The published custom source definition ID, set only with `create_test_source`.""" + connector_builder_project_url: str + """The project's Connector Builder URL.""" + name: str + """The project name.""" + test_source_id: str | None = None + """The test source ID, set only with `create_test_source`.""" + test_source_settings_url: str | None = None + """Cloud settings page of the test source, set only with `create_test_source`.""" + + +class ConnectorBuilderStreamTestReadToolResult(ConnectorBuilderStreamReadResult): + """Test read of one stream, with credentials redacted and a capped record preview.""" + + builder_project_id: str + """The Connector Builder project ID.""" + + +class ConnectorBuilderResolvedManifestResult(BaseModel): + """Resolved draft manifest of a Connector Builder project.""" + + builder_project_id: str + """The Connector Builder project ID.""" + stream_names: list[str] + """Stream names in the resolved manifest, including generated dynamic streams.""" + resolved_manifest: dict[str, Any] + """The resolved manifest.""" + + +class ConnectorBuilderReadinessReportResult(ConnectorBuilderReadinessReport): + """Readiness report of a Connector Builder project, with credentials redacted.""" + + builder_project_id: str + """The Connector Builder project ID.""" + connector_builder_project_url: str + """The project's Connector Builder URL.""" + + @mcp_tool( read_only=True, idempotent=True, @@ -90,7 +159,7 @@ def get_connector_builder_draft_manifest( ), TraceArg.OMIT, ], -) -> dict[str, Any]: +) -> ConnectorBuilderDraftManifestResult: """Get the Connector Builder draft manifest of a project or custom source definition. Returns the working draft manifest that has been saved but not yet published, both as @@ -106,7 +175,9 @@ def get_connector_builder_draft_manifest( builder_project_id, definition_id=definition_id, ) - return project._get_info().model_dump(by_alias=True) # noqa: SLF001 + return ConnectorBuilderDraftManifestResult.model_validate( + project._get_info().model_dump(by_alias=True), # noqa: SLF001 + ) @mcp_tool( @@ -168,7 +239,7 @@ def create_connector_builder_project( default=False, ), ] = False, -) -> dict[str, Any]: +) -> ConnectorBuilderProjectCreateResult: """Create a Connector Builder project to develop a custom YAML source. The project holds the draft manifest and the testing values used for test reads. @@ -191,22 +262,20 @@ def create_connector_builder_project( pre_validate=pre_validate, ) register_guid_created_in_session(project.builder_project_id) - result: dict[str, Any] = { - "builder_project_id": project.builder_project_id, - "definition_id": None, - "connector_builder_project_url": project.connector_builder_project_url, - "name": name, - } + result = ConnectorBuilderProjectCreateResult( + builder_project_id=project.builder_project_id, + definition_id=None, + connector_builder_project_url=project.connector_builder_project_url, + name=name, + ) if create_test_source: definition = project.publish(name, pre_validate=pre_validate) register_guid_created_in_session(definition.definition_id) test_source = project._get_or_create_default_test_source() # noqa: SLF001 register_guid_created_in_session(test_source.connector_id) - result |= { - "definition_id": definition.definition_id, - "test_source_id": test_source.connector_id, - "test_source_settings_url": test_source.connector_url, - } + result.definition_id = definition.definition_id + result.test_source_id = test_source.connector_id + result.test_source_settings_url = test_source.connector_url return result @@ -466,7 +535,7 @@ def run_connector_builder_stream_test_read( ), TraceArg.VALUE, ] = 5, -) -> dict[str, Any]: +) -> ConnectorBuilderStreamTestReadToolResult: """Test-read one stream of a Connector Builder project's draft manifest on Airbyte Cloud. Uses the project's saved testing values as the connector config. Returns counts, a @@ -489,11 +558,13 @@ def run_connector_builder_stream_test_read( page_limit=page_limit, slice_limit=slice_limit, ) - return { - **_redact_stream_diagnostics(result.model_dump()), - "records": result.records[:_TEST_READ_RECORDS_PREVIEW_LIMIT], - "builder_project_id": project.builder_project_id, - } + return ConnectorBuilderStreamTestReadToolResult.model_validate( + { + **_redact_stream_diagnostics(result.model_dump()), + "records": result.records[:_TEST_READ_RECORDS_PREVIEW_LIMIT], + "builder_project_id": project.builder_project_id, + } + ) @mcp_tool( @@ -537,7 +608,7 @@ def get_connector_builder_resolved_manifest( ), TraceArg.VALUE, ] = 5, -) -> dict[str, Any]: +) -> ConnectorBuilderResolvedManifestResult: """Resolve a Connector Builder project's draft manifest on Airbyte Cloud. Expands `$ref`s, `$parameters`, and dynamic streams (using the project's testing values), @@ -549,11 +620,11 @@ def get_connector_builder_resolved_manifest( definition_id=definition_id, ) resolved = project.get_resolved_manifest(stream_limit=stream_limit) - return { - "builder_project_id": project.builder_project_id, - "stream_names": resolved.stream_names, - "resolved_manifest": resolved.manifest_dict, - } + return ConnectorBuilderResolvedManifestResult( + builder_project_id=project.builder_project_id, + stream_names=resolved.stream_names, + resolved_manifest=resolved.manifest_dict, + ) @mcp_tool( @@ -629,7 +700,7 @@ def run_connector_builder_readiness_checks( default=None, ), ] = None, -) -> dict[str, Any]: +) -> ConnectorBuilderReadinessReportResult: """Check a Connector Builder draft's progress and test-read its streams on Airbyte Cloud. Returns readiness checks for the steps to a finished custom source, with next steps, plus @@ -662,11 +733,13 @@ def run_connector_builder_readiness_checks( report_data["streams"] = [ _redact_stream_diagnostics(stream) for stream in report_data["streams"] ] - return { - "builder_project_id": project.builder_project_id, - "connector_builder_project_url": project.connector_builder_project_url, - **report_data, - } + return ConnectorBuilderReadinessReportResult.model_validate( + { + "builder_project_id": project.builder_project_id, + "connector_builder_project_url": project.connector_builder_project_url, + **report_data, + } + ) def register_connector_builder_tools(app: FastMCP) -> None: diff --git a/tests/unit_tests/test_mcp_cloud.py b/tests/unit_tests/test_mcp_cloud.py index 72b3d957a..68efb00d1 100644 --- a/tests/unit_tests/test_mcp_cloud.py +++ b/tests/unit_tests/test_mcp_cloud.py @@ -38,6 +38,7 @@ JobStatusEnum, ) from airbyte.constants import MCP_DISABLE_DELETE_GUARD_ENV_VAR, MCP_INSIDERS_ENV_VAR +from airbyte.cloud.connector_builder import _models as _builder_models from airbyte.mcp import cloud as cloud_mcp from airbyte.mcp import connector_builder as connector_builder_mcp from airbyte.mcp import guidance as guidance_mcp @@ -3577,39 +3578,44 @@ def test_connector_builder_tools_return_read_warnings( monkeypatch: pytest.MonkeyPatch, ) -> None: warnings = ["Read 100 records, a multiple of 10. Check the paginator."] - read_result = SimpleNamespace( - model_dump=lambda: { - "stream_name": "users", - "warnings": warnings, - "pages": [], - "http_errors": [], - "logs": [], - "problem_logs": [], - "error": None, - }, + read_result = _builder_models.ConnectorBuilderStreamReadResult( + stream_name="users", + record_count=0, + slice_count=0, + page_count=0, + test_read_limit_reached=False, records=[], + pages=[], + logs=[], + warnings=warnings, + ) + readiness_report = _builder_models.ConnectorBuilderReadinessReport( + checks=[], + next_steps=[], + streams=[ + _builder_models.ConnectorBuilderStreamReadiness( + stream_name="users", + failed=False, + warnings=warnings, + ) + ], ) - readiness_report = SimpleNamespace( - full_sync=None, - model_dump=lambda: { - "full_sync": None, - "streams": [ - { - "stream_name": "users", - "warnings": warnings, - "pages": [], - "http_errors": [], - "problem_logs": [], - "error": None, - } - ], - }, + project_info = _builder_models.ConnectorBuilderProjectInfo( + builder_project_id="builder-project", + definition_id=None, + connector_builder_project_url="https://cloud.airbyte.com/workspaces/ws/connector-builder/edit/bp", + name="Users", + has_draft=True, + draft_manifest_dict={"version": "6.0.0"}, + draft_manifest_yaml="version: 6.0.0\n", + published_manifest_dict=None, ) project = SimpleNamespace( builder_project_id="builder-project", connector_builder_project_url="https://cloud.airbyte.com/workspaces/ws/connector-builder/edit/bp", run_stream_test_read=lambda *_args, **_kwargs: read_result, _run_readiness_checks=lambda *_args, **_kwargs: readiness_report, + _get_info=lambda: project_info, ) workspace = SimpleNamespace( get_connector_builder_project=lambda *_args, **_kwargs: project @@ -3632,9 +3638,18 @@ def test_connector_builder_tools_return_read_warnings( workspace_id="ws", ) - assert stream_result["warnings"] == warnings - assert readiness_result["streams"][0]["warnings"] == warnings - assert "passed" not in readiness_result + draft_result = connector_builder_mcp.get_connector_builder_draft_manifest( + ctx, + builder_project_id="builder-project", + workspace_id="ws", + ) + + assert stream_result.warnings == warnings + assert stream_result.builder_project_id == "builder-project" + assert readiness_result.streams[0].warnings == warnings + assert "passed" not in readiness_result.model_dump() + assert draft_result.draft_manifest == {"version": "6.0.0"} + assert draft_result.published_manifest is None def test_redact_stream_diagnostics() -> None: From 607bc98fb16cb94bae47d323e425bd5f7e996cc2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:06:35 +0000 Subject: [PATCH 42/47] feat(cloud): list, fetch and revert published Connector Builder manifest versions Co-Authored-By: AJ Steers --- airbyte/_util/api_util.py | 38 +++- airbyte/cloud/connector_builder/__init__.py | 2 + airbyte/cloud/connector_builder/_models.py | 8 + airbyte/cloud/connector_builder/project.py | 112 +++++++++ airbyte/mcp/connector_builder.py | 215 ++++++++++++++++++ tests/unit_tests/fixtures/arg_trace_plan.json | 40 +++- .../test_cloud_connector_builder.py | 114 ++++++++++ tests/unit_tests/test_mcp_tool_utils.py | 3 + 8 files changed, 520 insertions(+), 12 deletions(-) diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index 70153f907..26068bc13 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -3077,6 +3077,7 @@ def get_connector_builder_project( client_secret: SecretString | None, bearer_token: SecretString | None, config_api_root: str | None = None, + version: int | None = None, ) -> dict[str, Any]: """Get a connector builder project, including the draft manifest if one exists. @@ -3093,6 +3094,8 @@ def get_connector_builder_project( client_secret: OAuth client secret bearer_token: Bearer token for authentication (alternative to client credentials). config_api_root: Optional explicit Config API root URL. + version: Optional published manifest version. When set, `declarativeManifest` + holds that published version instead of the draft. Returns: A dictionary containing the builder project details. Key fields include: @@ -3100,11 +3103,43 @@ def get_connector_builder_project( - declarativeManifest: The draft manifest data (if hasDraft is True), which contains a 'manifest' field with the actual YAML manifest dict. """ + request_body: dict[str, Any] = { + "workspaceId": workspace_id, + "builderProjectId": builder_project_id, + } + if version is not None: + request_body["version"] = version return _make_config_api_request( path="/connector_builder_projects/get_with_manifest", + json=request_body, + api_root=api_root, + config_api_root=config_api_root, + client_id=client_id, + client_secret=client_secret, + bearer_token=bearer_token, + ) + + +def list_declarative_manifest_versions( + *, + workspace_id: str, + source_definition_id: str, + api_root: str, + client_id: SecretString | None, + client_secret: SecretString | None, + bearer_token: SecretString | None, + config_api_root: str | None = None, +) -> list[dict[str, Any]]: + """List the published manifest versions of a declarative custom source definition. + + Calls `POST /v1/declarative_source_definitions/list_manifests`. Each item has `version`, + `isActive` and `description`, but not the manifest. + """ + response = _make_config_api_request( + path="/declarative_source_definitions/list_manifests", json={ "workspaceId": workspace_id, - "builderProjectId": builder_project_id, + "sourceDefinitionId": source_definition_id, }, api_root=api_root, config_api_root=config_api_root, @@ -3112,6 +3147,7 @@ def get_connector_builder_project( client_secret=client_secret, bearer_token=bearer_token, ) + return response.get("manifestVersions") or [] def update_connector_builder_project( # noqa: PLR0913 diff --git a/airbyte/cloud/connector_builder/__init__.py b/airbyte/cloud/connector_builder/__init__.py index 84e6e7175..edee1145c 100644 --- a/airbyte/cloud/connector_builder/__init__.py +++ b/airbyte/cloud/connector_builder/__init__.py @@ -10,6 +10,7 @@ from airbyte.cloud.connector_builder._models import ( ConnectorBuilderPageSummary, + ConnectorBuilderPublishedVersion, ConnectorBuilderResolvedManifest, ConnectorBuilderStreamReadResult, ) @@ -21,6 +22,7 @@ __all__ = [ "CloudConnectorBuilderProject", "ConnectorBuilderPageSummary", + "ConnectorBuilderPublishedVersion", "ConnectorBuilderResolvedManifest", "ConnectorBuilderStreamReadResult", ] diff --git a/airbyte/cloud/connector_builder/_models.py b/airbyte/cloud/connector_builder/_models.py index 5a1e16581..d7dd62f8d 100644 --- a/airbyte/cloud/connector_builder/_models.py +++ b/airbyte/cloud/connector_builder/_models.py @@ -159,6 +159,14 @@ def _from_manifest(cls, manifest: dict[str, Any]) -> ConnectorBuilderResolvedMan ) +class ConnectorBuilderPublishedVersion(BaseModel): + """One published manifest version of a Connector Builder project's definition.""" + + version: int + is_active: bool + description: str + + class ConnectorBuilderProjectInfo(BaseModel): """A snapshot of a Connector Builder project, read with a single project request.""" diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index c243b4810..24db37714 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -16,6 +16,7 @@ from airbyte.cloud.connector_builder._models import ( ConnectorBuilderFullSync, ConnectorBuilderProjectInfo, + ConnectorBuilderPublishedVersion, ConnectorBuilderReadinessReport, ConnectorBuilderResolvedManifest, ConnectorBuilderStreamReadiness, @@ -38,6 +39,7 @@ render_manifest_text, spec_has_secrets, spec_missing_required, + unwrap_builder_manifest, ) @@ -143,6 +145,116 @@ def published_manifest_dict(self) -> dict[str, Any] | None: """The active manifest of the published definition, or `None` if never published.""" return self._published_manifest(self.source_definition_id) + def _require_source_definition_id(self) -> str: + source_definition_id = self.source_definition_id + if source_definition_id is None: + raise exc.AirbyteLibInputError( + message="This Connector Builder project has never been published.", + guidance="Publish the project first.", + context={"builder_project_id": self.builder_project_id}, + ) + return source_definition_id + + def list_published_versions(self) -> list[ConnectorBuilderPublishedVersion]: + """List the published manifest versions, oldest first. Empty if never published.""" + source_definition_id = self.source_definition_id + if source_definition_id is None: + return [] + versions = api_util.list_declarative_manifest_versions( + workspace_id=self.owner_workspace_id, + source_definition_id=source_definition_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, + config_api_root=self.workspace.config_api_root, + ) + return sorted( + ( + ConnectorBuilderPublishedVersion( + version=item["version"], + is_active=item["isActive"], + description=item.get("description") or "", + ) + for item in versions + ), + key=lambda item: item.version, + ) + + def get_published_manifest_dict(self, version: int | None = None) -> dict[str, Any] | None: + """Return a published manifest version, or `None` if never published. + + Args: + version: The published version to return. Defaults to the active version. + """ + if version is None: + return self.published_manifest_dict + self._require_source_definition_id() + project_data = api_util.get_connector_builder_project( + workspace_id=self.owner_workspace_id, + builder_project_id=self.builder_project_id, + version=version, + api_root=self.workspace.api_root, + client_id=self.workspace.client_id, + client_secret=self.workspace.client_secret, + bearer_token=self.workspace.bearer_token, + config_api_root=self.workspace.config_api_root, + ) + declarative_manifest = project_data.get("declarativeManifest") or {} + return unwrap_builder_manifest(declarative_manifest.get("manifest")) + + def get_published_manifest_yaml(self, version: int | None = None) -> str | None: + """Return a published manifest version as YAML text, or `None` if never published. + + Args: + version: The published version to return. Defaults to the active version. + """ + manifest = self.get_published_manifest_dict(version) + return render_manifest_text(manifest) if manifest is not None else None + + def revert_to_published_version( + self, + version: int, + *, + as_draft: bool = True, + ) -> None: + """Revert the project to a published manifest version. + + With `as_draft=True`, the version's manifest replaces the draft (overwriting any + unpublished edits) and the active published version doesn't change. With + `as_draft=False`, the version's manifest is published as a new active version. That + fails if the draft has unpublished edits, because publishing deletes the draft. + + Args: + version: The published version to revert to. + as_draft: Whether to save the version as the draft instead of publishing it. + """ + source_definition_id = self._require_source_definition_id() + manifest = self.get_published_manifest_dict(version) + if manifest is None: + raise exc.AirbyteLibInputError( + message="The published version has no manifest.", + context={"builder_project_id": self.builder_project_id, "version": version}, + ) + if as_draft: + self.replace_draft_manifest(manifest, pre_validate=False) + return + + draft_manifest = self.draft_manifest_dict + if draft_manifest is not None and draft_manifest != self.published_manifest_dict: + raise exc.AirbyteLibInputError( + message="This Connector Builder project has unpublished draft edits.", + guidance=( + "Revert with `as_draft=True` to review the version as the draft, " + "or publish the draft first." + ), + context={"builder_project_id": self.builder_project_id, "version": version}, + ) + self.workspace.get_custom_source_definition( + source_definition_id, + definition_type="yaml", + ).update_definition(manifest_yaml=manifest, pre_validate=False) + def _get_info(self) -> ConnectorBuilderProjectInfo: """Read the project once and return its IDs, URL, draft, and published manifest.""" project_data = self._get_project_data() diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index 786f372b2..03858f9d8 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -15,6 +15,7 @@ from pydantic import BaseModel, Field from airbyte.cloud.connector_builder._models import ( + ConnectorBuilderPublishedVersion, ConnectorBuilderReadinessReport, ConnectorBuilderStreamReadResult, ManifestTextEditMode, @@ -121,6 +122,45 @@ class ConnectorBuilderResolvedManifestResult(BaseModel): """The resolved manifest.""" +class ConnectorBuilderPublishedVersionsResult(BaseModel): + """Published manifest versions of a Connector Builder project.""" + + builder_project_id: str + """The Connector Builder project ID.""" + definition_id: str | None + """The published custom source definition ID, or `None` if never published.""" + versions: list[ConnectorBuilderPublishedVersion] + """Published versions, oldest first. Empty if never published.""" + + +class ConnectorBuilderPublishedManifestResult(BaseModel): + """One published manifest version of a Connector Builder project.""" + + builder_project_id: str + """The Connector Builder project ID.""" + definition_id: str + """The published custom source definition ID.""" + version: int | None + """The returned version; `None` when the active version was requested.""" + manifest_yaml: str + """The published manifest as YAML text.""" + + +class ConnectorBuilderRevertResult(BaseModel): + """Result of reverting a Connector Builder project to a published version.""" + + builder_project_id: str + """The Connector Builder project ID.""" + definition_id: str + """The published custom source definition ID.""" + reverted_to_version: int + """The published version whose manifest was restored.""" + as_draft: bool + """Whether the version was saved as the draft instead of published.""" + active_version: int | None + """The active published version after the revert.""" + + class ConnectorBuilderReadinessReportResult(ConnectorBuilderReadinessReport): """Readiness report of a Connector Builder project, with credentials redacted.""" @@ -627,6 +667,181 @@ def get_connector_builder_resolved_manifest( ) +@mcp_tool( + read_only=True, + idempotent=True, + insiders_only=True, +) +def list_connector_builder_published_versions( + ctx: Context, + *, + builder_project_id: Annotated[ + str | None, + Field( + description=_BUILDER_PROJECT_ID_DESCRIPTION, + default=None, + ), + ] = None, + definition_id: Annotated[ + str | None, + Field( + description=_BUILDER_DEFINITION_ID_DESCRIPTION, + default=None, + ), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], +) -> ConnectorBuilderPublishedVersionsResult: + """List the published manifest versions of a Connector Builder project, oldest first. + + Each version has `version`, `is_active` and `description`. Get a version's manifest with + `get_connector_builder_published_manifest`. + """ + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + return ConnectorBuilderPublishedVersionsResult( + builder_project_id=project.builder_project_id, + definition_id=project.source_definition_id, + versions=project.list_published_versions(), + ) + + +@mcp_tool( + read_only=True, + idempotent=True, + insiders_only=True, +) +def get_connector_builder_published_manifest( + ctx: Context, + *, + builder_project_id: Annotated[ + str | None, + Field( + description=_BUILDER_PROJECT_ID_DESCRIPTION, + default=None, + ), + ] = None, + definition_id: Annotated[ + str | None, + Field( + description=_BUILDER_DEFINITION_ID_DESCRIPTION, + default=None, + ), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], + version: Annotated[ + int | None, + Field( + description="Optional published version. Defaults to the active version.", + default=None, + ge=1, + ), + TraceArg.VALUE, + ] = None, +) -> ConnectorBuilderPublishedManifestResult: + """Get a published manifest version of a Connector Builder project as YAML text.""" + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + source_definition_id = project._require_source_definition_id() # noqa: SLF001 + return ConnectorBuilderPublishedManifestResult( + builder_project_id=project.builder_project_id, + definition_id=source_definition_id, + version=version, + manifest_yaml=project.get_published_manifest_yaml(version) or "", + ) + + +@mcp_tool( + destructive=True, + insiders_only=True, +) +def revert_connector_builder_published_version( + ctx: Context, + *, + version: Annotated[ + int, + Field( + description="The published version to revert to.", + ge=1, + ), + TraceArg.VALUE, + ], + builder_project_id: Annotated[ + str | None, + Field( + description=_BUILDER_PROJECT_ID_DESCRIPTION, + default=None, + ), + ] = None, + definition_id: Annotated[ + str | None, + Field( + description=_BUILDER_DEFINITION_ID_DESCRIPTION, + default=None, + ), + ] = None, + workspace_id: Annotated[ + str | None, + Field( + description=WORKSPACE_ID_TIP_TEXT, + default=None, + ), + TraceArg.OMIT, + ], + as_draft: Annotated[ + bool, + Field( + description=( + "True saves the version as the pending draft (overwriting unpublished edits) " + "and leaves the active version alone. False publishes it as a new active " + "version, which fails if the draft has unpublished edits." + ), + default=True, + ), + TraceArg.VALUE, + ] = True, +) -> ConnectorBuilderRevertResult: + """Revert a Connector Builder project to a published manifest version. + + List versions with `list_connector_builder_published_versions`. + """ + workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) + project = workspace.get_connector_builder_project( + builder_project_id, + definition_id=definition_id, + ) + _check_builder_project_created_in_session(project) + source_definition_id = project._require_source_definition_id() # noqa: SLF001 + project.revert_to_published_version(version, as_draft=as_draft) + active_versions = [item.version for item in project.list_published_versions() if item.is_active] + return ConnectorBuilderRevertResult( + builder_project_id=project.builder_project_id, + definition_id=source_definition_id, + reverted_to_version=version, + as_draft=as_draft, + active_version=active_versions[0] if active_versions else None, + ) + + @mcp_tool( external_access=True, insiders_only=True, diff --git a/tests/unit_tests/fixtures/arg_trace_plan.json b/tests/unit_tests/fixtures/arg_trace_plan.json index 6063f1d8b..3bba0f998 100644 --- a/tests/unit_tests/fixtures/arg_trace_plan.json +++ b/tests/unit_tests/fixtures/arg_trace_plan.json @@ -155,6 +155,18 @@ "definition_id": "hash", "workspace_id": "omit" }, + "get_connector_builder_published_manifest": { + "builder_project_id": "hash", + "definition_id": "hash", + "version": "value int", + "workspace_id": "omit" + }, + "get_connector_builder_resolved_manifest": { + "builder_project_id": "hash", + "definition_id": "hash", + "stream_limit": "value int", + "workspace_id": "omit" + }, "get_connector_info": { "connector_name": "fingerprint" }, @@ -222,6 +234,11 @@ "organization_name": "fingerprint", "privilege_scope": "value allowed=[\"any\",\"instance_admin\",\"member_of\",\"organization_admin\"]" }, + "list_connector_builder_published_versions": { + "builder_project_id": "hash", + "definition_id": "hash", + "workspace_id": "omit" + }, "list_connector_config_secrets": { "connector_name": "fingerprint" }, @@ -298,10 +315,11 @@ "streams": "hash list", "workspace_id": "omit" }, - "get_connector_builder_resolved_manifest": { + "revert_connector_builder_published_version": { + "as_draft": "value allowed=[false,true]", "builder_project_id": "hash", "definition_id": "hash", - "stream_limit": "value int", + "version": "value int", "workspace_id": "omit" }, "run_cloud_sync": { @@ -319,6 +337,15 @@ "timeout_seconds": "hash", "workspace_id": "omit" }, + "run_connector_builder_stream_test_read": { + "builder_project_id": "hash", + "definition_id": "hash", + "page_limit": "value int", + "record_limit": "value int", + "slice_limit": "value int", + "stream_name": "fingerprint", + "workspace_id": "omit" + }, "run_sql_query": { "max_records": "hash", "sql_query": "hash" @@ -379,15 +406,6 @@ "source_connector_name": "fingerprint", "streams": "fingerprint list" }, - "run_connector_builder_stream_test_read": { - "builder_project_id": "hash", - "definition_id": "hash", - "page_limit": "value int", - "record_limit": "value int", - "slice_limit": "value int", - "stream_name": "fingerprint", - "workspace_id": "omit" - }, "update_cloud_connection": { "connection_id": "hash", "cron_expression": "fingerprint", diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index def99125c..5b2aa06b8 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -1532,3 +1532,117 @@ def test_run_full_sync_rejects_non_noop_destination_with_default_name( workspace.deploy_destination.assert_not_called() workspace.deploy_connection.assert_not_called() + + +def _patch_published_versions( + monkeypatch: pytest.MonkeyPatch, + *, + draft: dict[str, Any] | None, +) -> dict[str, Any]: + store = _patch_project_store(monkeypatch, draft=draft, source_definition_id="def") + versions = { + 1: {"version": "6.0.0", "streams": []}, + 2: {"version": "6.1.0", "streams": []}, + } + unversioned_get = api_util.get_connector_builder_project + + def fake_get(**kwargs: Any) -> dict[str, Any]: + if kwargs.get("version") is None: + return unversioned_get(**kwargs) + return { + "builderProject": {"name": "My API", "sourceDefinitionId": "def"}, + "declarativeManifest": { + "manifest": versions[kwargs["version"]], + "isDraft": False, + "version": kwargs["version"], + }, + } + + monkeypatch.setattr(api_util, "get_connector_builder_project", fake_get) + monkeypatch.setattr( + api_util, + "list_declarative_manifest_versions", + lambda **_: [ + {"version": 2, "isActive": True, "description": ""}, + {"version": 1, "isActive": False, "description": "first"}, + ], + ) + store["versions"] = versions + return store + + +def test_list_published_versions_sorts_oldest_first( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_published_versions(monkeypatch, draft=None) + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) + + versions = project.list_published_versions() + + assert [(v.version, v.is_active, v.description) for v in versions] == [ + (1, False, "first"), + (2, True, ""), + ] + + +def test_list_published_versions_unpublished_is_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store(monkeypatch, draft={"version": "6.0.0"}) + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) + + assert project.list_published_versions() == [] + with pytest.raises(AirbyteLibInputError, match="never been published"): + project.get_published_manifest_dict(1) + + +def test_get_published_manifest_by_version(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_published_versions(monkeypatch, draft=None) + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) + + assert project.get_published_manifest_dict(1) == {"version": "6.0.0", "streams": []} + assert "version: 6.0.0" in (project.get_published_manifest_yaml(1) or "") + + +def test_revert_as_draft_replaces_draft(monkeypatch: pytest.MonkeyPatch) -> None: + store = _patch_published_versions(monkeypatch, draft={"version": "7.0.0"}) + workspace = _fake_workspace() + project = CloudConnectorBuilderProject(workspace=workspace, builder_project_id="bp") + + project.revert_to_published_version(1) + + assert store["draft"] == store["versions"][1] + workspace.get_custom_source_definition.return_value.update_definition.assert_not_called() + + +def test_revert_publish_refuses_unpublished_draft_edits( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_published_versions(monkeypatch, draft={"version": "7.0.0"}) + workspace = _fake_workspace() + workspace.get_custom_source_definition.return_value.manifest = {"version": "6.1.0"} + project = CloudConnectorBuilderProject(workspace=workspace, builder_project_id="bp") + + with pytest.raises(AirbyteLibInputError, match="unpublished draft edits"): + project.revert_to_published_version(1, as_draft=False) + + workspace.get_custom_source_definition.return_value.update_definition.assert_not_called() + + +def test_revert_publish_publishes_version(monkeypatch: pytest.MonkeyPatch) -> None: + store = _patch_published_versions(monkeypatch, draft=None) + workspace = _fake_workspace() + project = CloudConnectorBuilderProject(workspace=workspace, builder_project_id="bp") + + project.revert_to_published_version(1, as_draft=False) + + workspace.get_custom_source_definition.return_value.update_definition.assert_called_once_with( + manifest_yaml=store["versions"][1], + pre_validate=False, + ) diff --git a/tests/unit_tests/test_mcp_tool_utils.py b/tests/unit_tests/test_mcp_tool_utils.py index 416e08360..1087696bc 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -302,6 +302,9 @@ def test_server_tools_register_expected_policies() -> None: "run_connector_builder_stream_test_read", "get_connector_builder_resolved_manifest", "run_connector_builder_readiness_checks", + "list_connector_builder_published_versions", + "get_connector_builder_published_manifest", + "revert_connector_builder_published_version", } From 1e80eb49b1165b08e129b3e13e9bf37dcaf8b0e4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:28:17 +0000 Subject: [PATCH 43/47] style(cloud): one argument per line in Connector Builder signatures Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/_models.py | 8 ++++++-- airbyte/cloud/connector_builder/_util.py | 5 ++++- airbyte/cloud/connector_builder/project.py | 20 ++++++++++++++++---- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/airbyte/cloud/connector_builder/_models.py b/airbyte/cloud/connector_builder/_models.py index d7dd62f8d..4b4926c90 100644 --- a/airbyte/cloud/connector_builder/_models.py +++ b/airbyte/cloud/connector_builder/_models.py @@ -147,7 +147,10 @@ class ConnectorBuilderResolvedManifest(BaseModel): stream_names: list[str] @classmethod - def _from_manifest(cls, manifest: dict[str, Any]) -> ConnectorBuilderResolvedManifest: + def _from_manifest( + cls, + manifest: dict[str, Any], + ) -> ConnectorBuilderResolvedManifest: """Build a result from a resolved manifest.""" return cls( manifest_dict=manifest, @@ -216,7 +219,8 @@ class ConnectorBuilderStreamReadiness(BaseModel): @classmethod def _from_stream_read( - cls, result: ConnectorBuilderStreamReadResult + cls, + result: ConnectorBuilderStreamReadResult, ) -> ConnectorBuilderStreamReadiness: """Summarize errors and results from a stream test read.""" http_errors = [ diff --git a/airbyte/cloud/connector_builder/_util.py b/airbyte/cloud/connector_builder/_util.py index fa199eab7..ee772d2e3 100644 --- a/airbyte/cloud/connector_builder/_util.py +++ b/airbyte/cloud/connector_builder/_util.py @@ -272,7 +272,10 @@ def drop_masked_secrets(value: Any) -> Any: # noqa: ANN401 return value -def spec_missing_required(schema: Any, config: dict[str, Any]) -> bool: # noqa: ANN401 +def spec_missing_required( + schema: Any, # noqa: ANN401 + config: dict[str, Any], +) -> bool: """Return whether `config` lacks a required top-level field that has no default.""" if not isinstance(schema, dict): return False diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index 24db37714..0cdb5b496 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -132,7 +132,10 @@ def source_definition_id(self) -> str | None: """The ID of the custom source definition, or `None` if never published.""" return self._get_project_data()["builderProject"].get("sourceDefinitionId") - def _published_manifest(self, source_definition_id: str | None) -> dict[str, Any] | None: + def _published_manifest( + self, + source_definition_id: str | None, + ) -> dict[str, Any] | None: if source_definition_id is None: return None return self.workspace.get_custom_source_definition( @@ -181,7 +184,10 @@ def list_published_versions(self) -> list[ConnectorBuilderPublishedVersion]: key=lambda item: item.version, ) - def get_published_manifest_dict(self, version: int | None = None) -> dict[str, Any] | None: + def get_published_manifest_dict( + self, + version: int | None = None, + ) -> dict[str, Any] | None: """Return a published manifest version, or `None` if never published. Args: @@ -203,7 +209,10 @@ def get_published_manifest_dict(self, version: int | None = None) -> dict[str, A declarative_manifest = project_data.get("declarativeManifest") or {} return unwrap_builder_manifest(declarative_manifest.get("manifest")) - def get_published_manifest_yaml(self, version: int | None = None) -> str | None: + def get_published_manifest_yaml( + self, + version: int | None = None, + ) -> str | None: """Return a published manifest version as YAML text, or `None` if never published. Args: @@ -278,7 +287,10 @@ def draft_manifest_dict(self) -> dict[str, Any] | None: """The unpublished draft manifest, or `None` if the project has no draft.""" return draft_from_project_data(self._get_project_data()) - def _require_draft_manifest(self, project_data: dict[str, Any] | None = None) -> dict[str, Any]: + def _require_draft_manifest( + self, + project_data: dict[str, Any] | None = None, + ) -> dict[str, Any]: manifest = draft_from_project_data(project_data or self._get_project_data()) if manifest is None: raise exc.AirbyteLibInputError( From 080761f96b512b2402fd9daee074aeaf95cdf9f2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:35:12 +0000 Subject: [PATCH 44/47] feat(cloud): release-note description on Connector Builder publish and revert, defaulting to a UTC timestamp Co-Authored-By: AJ Steers --- airbyte/_util/api_util.py | 43 +++++++- airbyte/cloud/connector_builder/project.py | 100 +++++++++++++----- airbyte/mcp/connector_builder.py | 18 +++- tests/unit_tests/fixtures/arg_trace_plan.json | 1 + .../test_cloud_connector_builder.py | 86 +++++++++++++-- 5 files changed, 210 insertions(+), 38 deletions(-) diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index 26068bc13..910896771 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -3365,6 +3365,7 @@ def publish_connector_builder_project( # noqa: PLR0913 name: str, manifest: dict[str, Any], spec: dict[str, Any], + description: str, api_root: str, client_id: SecretString | None, client_secret: SecretString | None, @@ -3386,7 +3387,7 @@ def publish_connector_builder_project( # noqa: PLR0913 "manifest": manifest, "spec": spec, "version": 1, - "description": "", + "description": description, }, }, api_root=api_root, @@ -3404,6 +3405,46 @@ def publish_connector_builder_project( # noqa: PLR0913 return source_definition_id +def create_declarative_manifest_version( # noqa: PLR0913 + *, + workspace_id: str, + source_definition_id: str, + manifest: dict[str, Any], + spec: dict[str, Any], + version: int, + description: str, + api_root: str, + client_id: SecretString | None, + client_secret: SecretString | None, + bearer_token: SecretString | None, + config_api_root: str | None = None, +) -> None: + """Publish a manifest as a new active version of a declarative custom source definition. + + Calls `POST /v1/declarative_source_definitions/create_manifest`, which also deletes the + definition's Connector Builder draft. + """ + _make_config_api_request( + path="/declarative_source_definitions/create_manifest", + json={ + "workspaceId": workspace_id, + "sourceDefinitionId": source_definition_id, + "setAsActiveManifest": True, + "declarativeManifest": { + "manifest": manifest, + "spec": spec, + "version": version, + "description": description, + }, + }, + api_root=api_root, + config_api_root=config_api_root, + client_id=client_id, + client_secret=client_secret, + bearer_token=bearer_token, + ) + + # Organization and workspace listing diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index 0cdb5b496..4b4dcf8a6 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -6,6 +6,7 @@ import difflib import time from concurrent.futures import ThreadPoolExecutor, wait +from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, Literal, overload import requests @@ -57,6 +58,11 @@ """Shortest HTTP timeout for a test read started just before the readiness deadline.""" +def _default_version_description(action: str) -> str: + """Return a generic release note with the current UTC time.""" + return f"{action} by PyAirbyte at {datetime.now(UTC):%Y-%m-%dT%H:%M:%SZ}" + + def _check_test_read_limits( record_limit: int, page_limit: int, @@ -226,6 +232,7 @@ def revert_to_published_version( version: int, *, as_draft: bool = True, + description: str | None = None, ) -> None: """Revert the project to a published manifest version. @@ -237,8 +244,10 @@ def revert_to_published_version( Args: version: The published version to revert to. as_draft: Whether to save the version as the draft instead of publishing it. + description: Release note for the new version when `as_draft=False`. Defaults + to a generic note with the reverted version and the UTC publish time. """ - source_definition_id = self._require_source_definition_id() + self._require_source_definition_id() manifest = self.get_published_manifest_dict(version) if manifest is None: raise exc.AirbyteLibInputError( @@ -259,10 +268,13 @@ def revert_to_published_version( ), context={"builder_project_id": self.builder_project_id, "version": version}, ) - self.workspace.get_custom_source_definition( - source_definition_id, - definition_type="yaml", - ).update_definition(manifest_yaml=manifest, pre_validate=False) + self._publish_manifest( + manifest, + name=self.name, + description=description + or _default_version_description(f"Reverted to version {version}"), + pre_validate=False, + ) def _get_info(self) -> ConnectorBuilderProjectInfo: """Read the project once and return its IDs, URL, draft, and published manifest.""" @@ -629,36 +641,74 @@ def publish( name: str, *, pre_validate: bool = True, + description: str | None = None, ) -> CustomCloudSourceDefinition: """Publish the draft manifest as a custom source definition. The first publish creates the definition with `name`. If the project is already published, the draft becomes a new active version of the existing definition. + + Args: + name: The definition name, used only by the first publish. + pre_validate: Whether to validate the manifest client-side before publishing. + description: Release note for the new version. Defaults to a generic note with + the UTC publish time. """ - manifest = self._require_draft_manifest() - source_definition_id = self.source_definition_id - if source_definition_id is not None: - definition = self.workspace.get_custom_source_definition( - source_definition_id, - definition_type="yaml", - ) - return definition.update_definition(manifest_yaml=manifest, pre_validate=pre_validate) + return self._publish_manifest( + self._require_draft_manifest(), + name=name, + description=description or _default_version_description("Published"), + pre_validate=pre_validate, + ) + def _publish_manifest( + self, + manifest: dict[str, Any], + *, + name: str, + description: str, + pre_validate: bool, + ) -> CustomCloudSourceDefinition: + """Publish `manifest` as version 1 or as the next active version of the definition.""" if pre_validate: api_util.validate_yaml_manifest(manifest, raise_on_error=True) - source_definition_id = api_util.publish_connector_builder_project( - workspace_id=self.owner_workspace_id, - builder_project_id=self.builder_project_id, - name=name, - manifest=manifest, - spec=publish_spec(manifest), - api_root=self.workspace.api_root, - client_id=self.workspace.client_id, - client_secret=self.workspace.client_secret, - bearer_token=self.workspace.bearer_token, - config_api_root=self.workspace.config_api_root, - ) + source_definition_id = self.source_definition_id + if source_definition_id is None: + source_definition_id = api_util.publish_connector_builder_project( + workspace_id=self.owner_workspace_id, + builder_project_id=self.builder_project_id, + name=name, + manifest=manifest, + spec=publish_spec(manifest), + description=description, + api_root=self.workspace.api_root, + client_id=self.workspace.client_id, + client_secret=self.workspace.client_secret, + bearer_token=self.workspace.bearer_token, + config_api_root=self.workspace.config_api_root, + ) + else: + next_version = ( + max( + (item.version for item in self.list_published_versions()), + default=0, + ) + + 1 + ) + api_util.create_declarative_manifest_version( + workspace_id=self.owner_workspace_id, + source_definition_id=source_definition_id, + manifest=manifest, + spec=publish_spec(manifest), + version=next_version, + description=description, + api_root=self.workspace.api_root, + client_id=self.workspace.client_id, + client_secret=self.workspace.client_secret, + bearer_token=self.workspace.bearer_token, + config_api_root=self.workspace.config_api_root, + ) return self.workspace.get_custom_source_definition( source_definition_id, definition_type="yaml", diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index 03858f9d8..c973b6081 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -819,6 +819,18 @@ def revert_connector_builder_published_version( ), TraceArg.VALUE, ] = True, + description: Annotated[ + str | None, + Field( + description=( + "Optional release note for the new version when `as_draft` is false. " + "Defaults to a generic note with the reverted version and the UTC time." + ), + default=None, + max_length=256, + ), + TraceArg.FINGERPRINT, + ] = None, ) -> ConnectorBuilderRevertResult: """Revert a Connector Builder project to a published manifest version. @@ -831,7 +843,11 @@ def revert_connector_builder_published_version( ) _check_builder_project_created_in_session(project) source_definition_id = project._require_source_definition_id() # noqa: SLF001 - project.revert_to_published_version(version, as_draft=as_draft) + project.revert_to_published_version( + version, + as_draft=as_draft, + description=description, + ) active_versions = [item.version for item in project.list_published_versions() if item.is_active] return ConnectorBuilderRevertResult( builder_project_id=project.builder_project_id, diff --git a/tests/unit_tests/fixtures/arg_trace_plan.json b/tests/unit_tests/fixtures/arg_trace_plan.json index 3bba0f998..c1ee61a14 100644 --- a/tests/unit_tests/fixtures/arg_trace_plan.json +++ b/tests/unit_tests/fixtures/arg_trace_plan.json @@ -319,6 +319,7 @@ "as_draft": "value allowed=[false,true]", "builder_project_id": "hash", "definition_id": "hash", + "description": "fingerprint", "version": "value int", "workspace_id": "omit" }, diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 5b2aa06b8..8aed04a50 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -3,6 +3,8 @@ from __future__ import annotations +import re + from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock @@ -318,6 +320,7 @@ def test_resolved_manifest_lists_stream_names() -> None: "name": "My API", "manifest": MANIFEST, "spec": {"connectionSpecification": {}}, + "description": "First release", }, "/connector_builder_projects/publish", { @@ -328,13 +331,39 @@ def test_resolved_manifest_lists_stream_names() -> None: "manifest": MANIFEST, "spec": {"connectionSpecification": {}}, "version": 1, - "description": "", + "description": "First release", }, }, {"sourceDefinitionId": "def"}, "def", id="publish", ), + pytest.param( + api_util.create_declarative_manifest_version, + { + "workspace_id": "ws", + "source_definition_id": "def", + "manifest": MANIFEST, + "spec": {"connectionSpecification": {}}, + "version": 3, + "description": "Fix paging", + }, + "/declarative_source_definitions/create_manifest", + { + "workspaceId": "ws", + "sourceDefinitionId": "def", + "setAsActiveManifest": True, + "declarativeManifest": { + "manifest": MANIFEST, + "spec": {"connectionSpecification": {}}, + "version": 3, + "description": "Fix paging", + }, + }, + {}, + None, + id="create-manifest-version", + ), ], ) def test_connector_builder_api_helpers_forward_requests( @@ -530,17 +559,45 @@ def test_publish_already_published_project_adds_version( ) -> None: _patch_project_store(monkeypatch, draft=MANIFEST, source_definition_id="def") publish = MagicMock() + create_version = MagicMock() monkeypatch.setattr(api_util, "publish_connector_builder_project", publish) + monkeypatch.setattr(api_util, "create_declarative_manifest_version", create_version) + monkeypatch.setattr( + api_util, + "list_declarative_manifest_versions", + lambda **_: [ + {"version": 1, "isActive": False}, + {"version": 2, "isActive": True}, + ], + ) workspace = _fake_workspace() CloudConnectorBuilderProject(workspace=workspace, builder_project_id="bp").publish( - "My API" + "My API", + description="Fix paging", ) publish.assert_not_called() - definition = workspace.get_custom_source_definition.return_value - definition.update_definition.assert_called_once_with( - manifest_yaml=MANIFEST, pre_validate=True + kwargs = create_version.call_args.kwargs + assert kwargs["manifest"] == MANIFEST + assert kwargs["version"] == 3 + assert kwargs["description"] == "Fix paging" + + +def test_publish_defaults_description_to_utc_timestamp( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store(monkeypatch, draft=MANIFEST) + publish = MagicMock(return_value="def") + monkeypatch.setattr(api_util, "publish_connector_builder_project", publish) + + CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ).publish("My API") + + assert re.fullmatch( + r"Published by PyAirbyte at \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", + publish.call_args.kwargs["description"], ) @@ -1614,11 +1671,13 @@ def test_revert_as_draft_replaces_draft(monkeypatch: pytest.MonkeyPatch) -> None store = _patch_published_versions(monkeypatch, draft={"version": "7.0.0"}) workspace = _fake_workspace() project = CloudConnectorBuilderProject(workspace=workspace, builder_project_id="bp") + create_version = MagicMock() + monkeypatch.setattr(api_util, "create_declarative_manifest_version", create_version) project.revert_to_published_version(1) assert store["draft"] == store["versions"][1] - workspace.get_custom_source_definition.return_value.update_definition.assert_not_called() + create_version.assert_not_called() def test_revert_publish_refuses_unpublished_draft_edits( @@ -1628,11 +1687,13 @@ def test_revert_publish_refuses_unpublished_draft_edits( workspace = _fake_workspace() workspace.get_custom_source_definition.return_value.manifest = {"version": "6.1.0"} project = CloudConnectorBuilderProject(workspace=workspace, builder_project_id="bp") + create_version = MagicMock() + monkeypatch.setattr(api_util, "create_declarative_manifest_version", create_version) with pytest.raises(AirbyteLibInputError, match="unpublished draft edits"): project.revert_to_published_version(1, as_draft=False) - workspace.get_custom_source_definition.return_value.update_definition.assert_not_called() + create_version.assert_not_called() def test_revert_publish_publishes_version(monkeypatch: pytest.MonkeyPatch) -> None: @@ -1640,9 +1701,12 @@ def test_revert_publish_publishes_version(monkeypatch: pytest.MonkeyPatch) -> No workspace = _fake_workspace() project = CloudConnectorBuilderProject(workspace=workspace, builder_project_id="bp") + create_version = MagicMock() + monkeypatch.setattr(api_util, "create_declarative_manifest_version", create_version) + project.revert_to_published_version(1, as_draft=False) - workspace.get_custom_source_definition.return_value.update_definition.assert_called_once_with( - manifest_yaml=store["versions"][1], - pre_validate=False, - ) + kwargs = create_version.call_args.kwargs + assert kwargs["manifest"] == store["versions"][1] + assert kwargs["version"] == 3 + assert kwargs["description"].startswith("Reverted to version 1 by PyAirbyte at ") From 0aba8350e8d53a70250d48cf6110675bea9dc730 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:43:17 +0000 Subject: [PATCH 45/47] fix(mcp): reject secrets in Builder testing values and keep secrets saved on Cloud Co-Authored-By: AJ Steers --- airbyte/cloud/connector_builder/project.py | 21 +++++++++++++++++++ airbyte/mcp/connector_builder.py | 18 +++++++++------- .../test_cloud_connector_builder.py | 19 +++++++++++++++++ 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/airbyte/cloud/connector_builder/project.py b/airbyte/cloud/connector_builder/project.py index 4b4dcf8a6..227900b7b 100644 --- a/airbyte/cloud/connector_builder/project.py +++ b/airbyte/cloud/connector_builder/project.py @@ -418,6 +418,27 @@ def replace_testing_values( config_api_root=self.workspace.config_api_root, ) + def _testing_values_connection_spec(self) -> dict[str, Any]: + """Return the JSON schema of the testing values, from the draft or published spec.""" + project_data = self._get_project_data() + manifest = draft_from_project_data(project_data) or self._published_manifest( + project_data["builderProject"].get("sourceDefinitionId") + ) + if manifest is None: + manifest = self._require_draft_manifest(project_data) + return builder_testing_values_spec(manifest).get("connectionSpecification") or {} + + def _merge_testing_values( + self, + testing_values: dict[str, Any], + ) -> None: + """Merge `testing_values` onto the saved ones, keeping secrets stored on Cloud. + + Cloud returns saved secrets masked; sending the mask back keeps the stored secret. + """ + saved = self._get_project_data().get("testingValues") or {} + self.replace_testing_values({**saved, **testing_values}) + def run_stream_test_read( self, stream_name: str, diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index c973b6081..501bfcbf0 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -445,8 +445,9 @@ def set_connector_builder_testing_values( dict | str | None, Field( description=( - "Configuration values for test reads, as a JSON object or JSON string. " - "Replaces all existing testing values." + "Non-secret configuration values for test reads, as a JSON object or JSON " + "string. Merged onto the saved testing values, so secrets the user entered in " + "the Connector Builder are kept. Values for `airbyte_secret` fields are rejected." ), default=None, ), @@ -464,7 +465,7 @@ def set_connector_builder_testing_values( TraceArg.FINGERPRINT, ] = None, ) -> str: - """Replace the testing values (connector config) of a Connector Builder project. + """Update the testing values (connector config) of a Connector Builder project. Test reads use these values. Airbyte Cloud stores fields marked `airbyte_secret` in the draft manifest's spec in its secret storage. Requires a saved draft manifest. @@ -479,17 +480,18 @@ def set_connector_builder_testing_values( else "Provide `testing_values`." ), ) - testing_values_dict = resolve_connector_config( - config=testing_values, - config_secret_name=testing_values_secret_name, - ) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) project = workspace.get_connector_builder_project( builder_project_id, definition_id=definition_id, ) _check_builder_project_created_in_session(project) - project.replace_testing_values(testing_values_dict) + testing_values_dict = resolve_connector_config( + config=testing_values, + config_secret_name=testing_values_secret_name, + config_spec_jsonschema=project._testing_values_connection_spec(), # noqa: SLF001 + ) + project._merge_testing_values(testing_values_dict) # noqa: SLF001 return ( f"Saved testing values for Connector Builder project {project.builder_project_id}. " f"Users can review them or enter secrets at: {project.connector_builder_project_url}" diff --git a/tests/unit_tests/test_cloud_connector_builder.py b/tests/unit_tests/test_cloud_connector_builder.py index 8aed04a50..126e6b05c 100644 --- a/tests/unit_tests/test_cloud_connector_builder.py +++ b/tests/unit_tests/test_cloud_connector_builder.py @@ -1710,3 +1710,22 @@ def test_revert_publish_publishes_version(monkeypatch: pytest.MonkeyPatch) -> No assert kwargs["manifest"] == store["versions"][1] assert kwargs["version"] == 3 assert kwargs["description"].startswith("Reverted to version 1 by PyAirbyte at ") + + +def test_merge_testing_values_keeps_saved_secrets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_project_store( + monkeypatch, + draft=MANIFEST, + testing_values={"api_key": "**********", "region": "us"}, + ) + project = CloudConnectorBuilderProject( + workspace=_fake_workspace(), builder_project_id="bp" + ) + replace = MagicMock() + monkeypatch.setattr(project, "replace_testing_values", replace) + + project._merge_testing_values({"region": "eu"}) + + replace.assert_called_once_with({"api_key": "**********", "region": "eu"}) From e2f980cf874ae36ac7be2ee723f9d4473259490e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:48:14 +0000 Subject: [PATCH 46/47] fix(mcp): drop testing_values_secret_name from the Builder testing-values tool Co-Authored-By: AJ Steers --- airbyte/mcp/connector_builder.py | 28 ++----------------- tests/unit_tests/fixtures/arg_trace_plan.json | 1 - tests/unit_tests/test_mcp_arg_trace_parity.py | 1 - .../unit_tests/test_mcp_trusted_execution.py | 1 - 4 files changed, 2 insertions(+), 29 deletions(-) diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index 501bfcbf0..0bc3b649b 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -51,7 +51,6 @@ _check_builder_project_created_in_session, _get_cloud_workspace, _manifest_yaml_source_hint, - _testing_values_secret_hint, ) @@ -413,7 +412,6 @@ def update_connector_builder_manifest_text( @mcp_tool( destructive=True, idempotent=True, - extra_help_text=_testing_values_secret_hint, insiders_only=True, ) def set_connector_builder_testing_values( @@ -442,28 +440,15 @@ def set_connector_builder_testing_values( TraceArg.OMIT, ], testing_values: Annotated[ - dict | str | None, + dict | str, Field( description=( "Non-secret configuration values for test reads, as a JSON object or JSON " "string. Merged onto the saved testing values, so secrets the user entered in " "the Connector Builder are kept. Values for `airbyte_secret` fields are rejected." ), - default=None, - ), - ] = None, - testing_values_secret_name: Annotated[ - str | None, - Field( - description=( - "Optional name of a secret containing testing values in JSON or YAML format. " - "The secret is resolved by the MCP server and merged into testing_values, with " - "secret values taking precedence." - ), - default=None, ), - TraceArg.FINGERPRINT, - ] = None, + ], ) -> str: """Update the testing values (connector config) of a Connector Builder project. @@ -472,14 +457,6 @@ def set_connector_builder_testing_values( Don't ask users to paste secrets into the chat: send them the returned project URL to enter secrets in the Connector Builder's testing values form instead. """ - if testing_values is None and testing_values_secret_name is None: - raise AirbyteLibInputError( - message=( - "Provide `testing_values` or `testing_values_secret_name`." - if is_trusted_execution_enabled() - else "Provide `testing_values`." - ), - ) workspace: CloudWorkspace = _get_cloud_workspace(ctx, workspace_id) project = workspace.get_connector_builder_project( builder_project_id, @@ -488,7 +465,6 @@ def set_connector_builder_testing_values( _check_builder_project_created_in_session(project) testing_values_dict = resolve_connector_config( config=testing_values, - config_secret_name=testing_values_secret_name, config_spec_jsonschema=project._testing_values_connection_spec(), # noqa: SLF001 ) project._merge_testing_values(testing_values_dict) # noqa: SLF001 diff --git a/tests/unit_tests/fixtures/arg_trace_plan.json b/tests/unit_tests/fixtures/arg_trace_plan.json index c1ee61a14..382e5fbb7 100644 --- a/tests/unit_tests/fixtures/arg_trace_plan.json +++ b/tests/unit_tests/fixtures/arg_trace_plan.json @@ -368,7 +368,6 @@ "builder_project_id": "hash", "definition_id": "hash", "testing_values": "presence", - "testing_values_secret_name": "fingerprint", "workspace_id": "omit" }, "set_default_cloud_workspace": { diff --git a/tests/unit_tests/test_mcp_arg_trace_parity.py b/tests/unit_tests/test_mcp_arg_trace_parity.py index 196901baa..0249924b8 100644 --- a/tests/unit_tests/test_mcp_arg_trace_parity.py +++ b/tests/unit_tests/test_mcp_arg_trace_parity.py @@ -87,7 +87,6 @@ def test_inventory_matches_fixture() -> None: expected[name].pop("manifest_yaml_file", None) for name in ( "publish_custom_source_definition", - "set_connector_builder_testing_values", "update_custom_source_definition", ): if "testing_values_secret_name" not in plan[name]: diff --git a/tests/unit_tests/test_mcp_trusted_execution.py b/tests/unit_tests/test_mcp_trusted_execution.py index 416dcf638..acfa311d7 100644 --- a/tests/unit_tests/test_mcp_trusted_execution.py +++ b/tests/unit_tests/test_mcp_trusted_execution.py @@ -291,7 +291,6 @@ async def list_tools() -> dict[str, object]: for tool_name in ( "publish_custom_source_definition", - "set_connector_builder_testing_values", "update_custom_source_definition", ): tool = tools[tool_name] From b9148363d5e6e83a6858c380a210c7164c830031 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:50:38 +0000 Subject: [PATCH 47/47] refactor(mcp): rename Builder testing-values arg to nonsecret_testing_values Co-Authored-By: AJ Steers --- airbyte/mcp/connector_builder.py | 4 ++-- tests/unit_tests/fixtures/arg_trace_plan.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/airbyte/mcp/connector_builder.py b/airbyte/mcp/connector_builder.py index 0bc3b649b..12d1d2690 100644 --- a/airbyte/mcp/connector_builder.py +++ b/airbyte/mcp/connector_builder.py @@ -439,7 +439,7 @@ def set_connector_builder_testing_values( ), TraceArg.OMIT, ], - testing_values: Annotated[ + nonsecret_testing_values: Annotated[ dict | str, Field( description=( @@ -464,7 +464,7 @@ def set_connector_builder_testing_values( ) _check_builder_project_created_in_session(project) testing_values_dict = resolve_connector_config( - config=testing_values, + config=nonsecret_testing_values, config_spec_jsonschema=project._testing_values_connection_spec(), # noqa: SLF001 ) project._merge_testing_values(testing_values_dict) # noqa: SLF001 diff --git a/tests/unit_tests/fixtures/arg_trace_plan.json b/tests/unit_tests/fixtures/arg_trace_plan.json index 382e5fbb7..1a5b51832 100644 --- a/tests/unit_tests/fixtures/arg_trace_plan.json +++ b/tests/unit_tests/fixtures/arg_trace_plan.json @@ -367,7 +367,7 @@ "set_connector_builder_testing_values": { "builder_project_id": "hash", "definition_id": "hash", - "testing_values": "presence", + "nonsecret_testing_values": "presence", "workspace_id": "omit" }, "set_default_cloud_workspace": {