airbyte_ops_mcp.mcp.connector_qa

MCP tools for connector quality assurance: regression tests and connector release blocking.

MCP reference

MCP primitives registered by the connector_qa module of the airbyte-internal-ops server: 4 tool(s), 0 prompt(s), 0 resource(s).

Tools (4)

block_connector_release

Block a connector from being released by creating a block-release.yaml marker.

Triggers the block-release.yml workflow in the airbyte monorepo, which creates a marker file, opens a PR, and force-merges it to master. While the marker exists, the publish pipeline will skip the connector with a warning.

Use this after yanking a connector version to prevent CI from accidentally re-publishing the broken code.

Parameters:

Name Type Required Default Description
connector_name string yes — Connector technical name (e.g., source-faker, destination-postgres)
reason string yes — Human-readable reason for blocking the release
yanked_version string | null no null Version that was yanked (for reference)
blocked_by string | null no null Email or identifier of the person requesting the block

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "connector_name": {
      "description": "Connector technical name (e.g., `source-faker`, `destination-postgres`)",
      "type": "string"
    },
    "reason": {
      "description": "Human-readable reason for blocking the release",
      "type": "string"
    },
    "yanked_version": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Version that was yanked (for reference)"
    },
    "blocked_by": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Email or identifier of the person requesting the block"
    }
  },
  "required": [
    "connector_name",
    "reason"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Result of triggering a release block workflow.",
  "properties": {
    "success": {
      "description": "Whether the workflow dispatch succeeded",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable result message",
      "type": "string"
    },
    "workflow_url": {
      "description": "URL to the workflow",
      "type": "string"
    },
    "run_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Workflow run ID, if discovered"
    },
    "run_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "URL to the workflow run"
    }
  },
  "required": [
    "success",
    "message",
    "workflow_url"
  ],
  "type": "object"
}

list_blocked_connector_releases

List connectors that are currently blocked from release.

Searches the airbyte monorepo for block-release.yaml marker files using the GitHub API. Returns a list of blocked connectors, with marker metadata when include_details is True.

Parameters:

Name Type Required Default Description
connector_name string | null no null Optional connector name to check. If not provided, scans all connectors.
include_details boolean no true Whether to fetch and parse each marker file for reason and metadata.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "connector_name": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional connector name to check. If not provided, scans all connectors."
    },
    "include_details": {
      "default": true,
      "description": "Whether to fetch and parse each marker file for reason and metadata.",
      "type": "boolean"
    }
  },
  "type": "object"
}

Show output JSON schema

{
  "description": "Result of listing blocked connectors via the GitHub API.",
  "properties": {
    "blocked_connectors": {
      "description": "List of blocked connectors with optional block metadata",
      "items": {
        "additionalProperties": true,
        "type": "object"
      },
      "type": "array"
    },
    "count": {
      "default": 0,
      "description": "Number of blocked connectors",
      "type": "integer"
    }
  },
  "type": "object"
}

run_regression_tests

Start a regression test run via GitHub Actions workflow.

This tool triggers the regression test workflow which builds the connector from the specified PR and runs tests against it.

Supports both OSS connectors (from airbytehq/airbyte) and enterprise connectors (from airbytehq/airbyte-enterprise). Use the 'repo' parameter to specify which repository contains the connector PR.

  • skip_compare=False (default): Comparison mode - compares the PR version against the baseline (control) version.
  • skip_compare=True: Single-version mode - runs tests without comparison.

If connection_id is provided, config/catalog are fetched from Airbyte Cloud. Otherwise, GSM integration test secrets are used.

Returns immediately with a run_id and workflow URL. Check the workflow URL to monitor progress and view results.

Requires GITHUB_CI_WORKFLOW_TRIGGER_PAT or GITHUB_TOKEN environment variable with 'actions:write' permission.

Parameters:

Name Type Required Default Description
connector_name string yes — Connector name to build from source (e.g., 'source-pokeapi'). Required.
pr integer yes — PR number to checkout and build from (e.g., 70847). Required. The PR must be from the repository specified by the 'repo' parameter.
repo enum("airbyte", "airbyte-enterprise") yes — Repository where the connector PR is located. Use 'airbyte' for OSS connectors (default) or 'airbyte-enterprise' for enterprise connectors.
connection_id string | null no null Airbyte Cloud connection ID to fetch config/catalog from. If not provided, uses GSM integration test secrets.
skip_compare boolean no false If True, skip comparison and run single-version tests only. If False (default), run comparison tests (target vs control versions).
skip_read_action boolean no false If True, skip the read action (run only spec, check, discover). If False (default), run all verbs including read.
override_test_image string | null no null Override test connector image with tag (e.g., 'airbyte/source-github:1.0.0'). Ignored if skip_compare=False.
override_control_image string | null no null Override control connector image (baseline version) with tag. Ignored if skip_compare=True.
workspace_id string | enum("266ebdfe-0d7b-4540-9817-de7e4505ba61") | null no null Optional Airbyte Cloud workspace ID (UUID) or alias. If provided with connection_id, validates that the connection belongs to this workspace before triggering tests. Accepts '@devin-ai-sandbox' as an alias for the Devin AI sandbox workspace.
selected_streams array<string> | null no null List of stream names to include in the read. Only these streams will be included in the configured catalog. This is useful to limit data volume by testing only specific streams. If not provided, all streams are tested.
enable_debug_logs boolean no false Enable debug-level logging for regression test output. Also passed as LOG_LEVEL=DEBUG to the connector Docker container.
disable_http_replay boolean no false Run both connector versions against the live API instead of replaying the control run's recorded HTTP responses. Replay is on by default and is what makes a record difference attributable to the version under test rather than to upstream drift, so only disable it for a connector whose traffic cannot be replayed faithfully. Comparison mode only.
with_state boolean | null no null Fetch and pass the connection's current state to the read command, producing a warm read instead of a cold read. Defaults to True when connection_id is provided, False otherwise. Has no effect unless the command is read.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "connector_name": {
      "description": "Connector name to build from source (e.g., 'source-pokeapi'). Required.",
      "type": "string"
    },
    "pr": {
      "description": "PR number to checkout and build from (e.g., 70847). Required. The PR must be from the repository specified by the 'repo' parameter.",
      "type": "integer"
    },
    "repo": {
      "description": "Repository where the connector PR is located. Use 'airbyte' for OSS connectors (default) or 'airbyte-enterprise' for enterprise connectors.",
      "enum": [
        "airbyte",
        "airbyte-enterprise"
      ],
      "type": "string"
    },
    "connection_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Airbyte Cloud connection ID to fetch config/catalog from. If not provided, uses GSM integration test secrets."
    },
    "skip_compare": {
      "default": false,
      "description": "If True, skip comparison and run single-version tests only. If False (default), run comparison tests (target vs control versions).",
      "type": "boolean"
    },
    "skip_read_action": {
      "default": false,
      "description": "If True, skip the read action (run only spec, check, discover). If False (default), run all verbs including read.",
      "type": "boolean"
    },
    "override_test_image": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Override test connector image with tag (e.g., 'airbyte/source-github:1.0.0'). Ignored if skip_compare=False."
    },
    "override_control_image": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Override control connector image (baseline version) with tag. Ignored if skip_compare=True."
    },
    "workspace_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "description": "Workspace ID aliases that can be used in place of UUIDs.\n\nEach member's name is the alias (e.g., \"@devin-ai-sandbox\") and its value\nis the actual workspace UUID. Use `WorkspaceAliasEnum.resolve()` to\nresolve aliases to actual IDs.",
          "enum": [
            "266ebdfe-0d7b-4540-9817-de7e4505ba61"
          ],
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional Airbyte Cloud workspace ID (UUID) or alias. If provided with connection_id, validates that the connection belongs to this workspace before triggering tests. Accepts '@devin-ai-sandbox' as an alias for the Devin AI sandbox workspace."
    },
    "selected_streams": {
      "anyOf": [
        {
          "items": {
            "type": "string"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "List of stream names to include in the read. Only these streams will be included in the configured catalog. This is useful to limit data volume by testing only specific streams. If not provided, all streams are tested."
    },
    "enable_debug_logs": {
      "default": false,
      "description": "Enable debug-level logging for regression test output. Also passed as `LOG_LEVEL=DEBUG` to the connector Docker container.",
      "type": "boolean"
    },
    "disable_http_replay": {
      "default": false,
      "description": "Run both connector versions against the live API instead of replaying the control run's recorded HTTP responses. Replay is on by default and is what makes a record difference attributable to the version under test rather than to upstream drift, so only disable it for a connector whose traffic cannot be replayed faithfully. Comparison mode only.",
      "type": "boolean"
    },
    "with_state": {
      "anyOf": [
        {
          "type": "boolean"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Fetch and pass the connection's current state to the read command, producing a warm read instead of a cold read. Defaults to `True` when `connection_id` is provided, `False` otherwise. Has no effect unless the command is `read`."
    }
  },
  "required": [
    "connector_name",
    "pr",
    "repo"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from starting a regression test via GitHub Actions workflow.",
  "properties": {
    "run_id": {
      "description": "Unique identifier for the test run (internal tracking ID)",
      "type": "string"
    },
    "status": {
      "description": "Initial status of the test run",
      "enum": [
        "queued",
        "running",
        "succeeded",
        "failed"
      ],
      "type": "string"
    },
    "message": {
      "description": "Human-readable status message",
      "type": "string"
    },
    "workflow_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "URL to view the GitHub Actions workflow file"
    },
    "github_run_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "GitHub Actions workflow run ID (use with check_ci_workflow_status)"
    },
    "github_run_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Direct URL to the GitHub Actions workflow run"
    }
  },
  "required": [
    "run_id",
    "status",
    "message"
  ],
  "type": "object"
}

unblock_connector_release

Remove a release block for a connector by deleting its block-release.yaml marker.

Triggers the block-release.yml workflow with action=unblock, which removes the marker file, opens a PR, and force-merges it to master. After this, the connector can be published normally again.

Parameters:

Name Type Required Default Description
connector_name string yes — Connector technical name (e.g., source-faker, destination-postgres)

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "connector_name": {
      "description": "Connector technical name (e.g., `source-faker`, `destination-postgres`)",
      "type": "string"
    }
  },
  "required": [
    "connector_name"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Result of triggering a release unblock workflow.",
  "properties": {
    "success": {
      "description": "Whether the workflow dispatch succeeded",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable result message",
      "type": "string"
    },
    "workflow_url": {
      "description": "URL to the workflow",
      "type": "string"
    },
    "run_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Workflow run ID, if discovered"
    },
    "run_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "URL to the workflow run"
    }
  },
  "required": [
    "success",
    "message",
    "workflow_url"
  ],
  "type": "object"
}

  1# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
  2"""MCP tools for connector quality assurance: regression tests and connector release blocking.
  3
  4## MCP reference
  5
  6.. include:: ../../../docs/mcp-generated/connector_qa.md
  7    :start-line: 2
  8"""
  9
 10from __future__ import annotations
 11
 12__all__: list[str] = []
 13
 14import uuid
 15from datetime import datetime
 16from enum import Enum
 17from typing import Annotated, Any
 18from urllib.parse import quote
 19
 20import requests
 21import yaml
 22from airbyte.cloud import CloudWorkspace
 23from airbyte.cloud.auth import resolve_cloud_client_id, resolve_cloud_client_secret
 24from airbyte.exceptions import (
 25    AirbyteMissingResourceError,
 26    AirbyteNoCloudCredentialsError,
 27    AirbyteWorkspaceMismatchError,
 28)
 29from fastmcp import FastMCP
 30from fastmcp_extensions import mcp_tool, register_mcp_tools
 31from pydantic import BaseModel, Field
 32
 33from airbyte_ops_mcp.constants import WorkspaceAliasEnum
 34from airbyte_ops_mcp.github_actions import (
 35    resolve_default_workflow_branch,
 36    trigger_workflow_dispatch,
 37)
 38from airbyte_ops_mcp.github_api import (
 39    GITHUB_API_BASE,
 40    get_file_contents_at_ref,
 41    resolve_ci_trigger_github_token,
 42)
 43from airbyte_ops_mcp.mcp.connector_versions import ConnectorRepo
 44
 45REGRESSION_TEST_REPO_OWNER = "airbytehq"
 46
 47REGRESSION_TEST_REPO_NAME = "airbyte-ops-mcp"
 48
 49REGRESSION_TEST_DEFAULT_BRANCH = "main"
 50
 51REGRESSION_TEST_WORKFLOW_FILE = "connector-regression-test.yml"
 52
 53
 54def validate_connection_workspace(
 55    connection_id: str,
 56    workspace_id: str,
 57) -> None:
 58    """Validate that a connection belongs to the expected workspace.
 59
 60    Uses PyAirbyte's CloudConnection.check_is_valid() method to verify that
 61    the connection exists and belongs to the specified workspace.
 62
 63    Raises:
 64        `AirbyteNoCloudCredentialsError`: If Airbyte Cloud credentials are missing.
 65        `AirbyteWorkspaceMismatchError`: If connection belongs to a different workspace.
 66        `AirbyteMissingResourceError`: If connection is not found.
 67    """
 68    client_id = resolve_cloud_client_id()
 69    client_secret = resolve_cloud_client_secret()
 70    if not client_id or not client_secret:
 71        # Only client credentials are resolved; suppress bearer-token guidance.
 72        raise AirbyteNoCloudCredentialsError(_allow_bearer=False)
 73
 74    workspace = CloudWorkspace(
 75        workspace_id=workspace_id,
 76        client_id=client_id,
 77        client_secret=client_secret,
 78    )
 79    connection = workspace.get_connection(connection_id)
 80    connection.check_is_valid()
 81
 82
 83def _get_workflow_run_status(
 84    owner: str,
 85    repo: str,
 86    run_id: int,
 87    token: str,
 88) -> dict[str, Any]:
 89    """Get workflow run details from GitHub API.
 90
 91    Args:
 92        owner: Repository owner (e.g., "airbytehq")
 93        repo: Repository name (e.g., "airbyte-ops-mcp")
 94        run_id: Workflow run ID
 95        token: GitHub API token
 96
 97    Returns:
 98        Workflow run data dictionary.
 99
100    Raises:
101        ValueError: If workflow run not found.
102        requests.HTTPError: If API request fails.
103    """
104    url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/actions/runs/{run_id}"
105    headers = {
106        "Authorization": f"Bearer {token}",
107        "Accept": "application/vnd.github+json",
108        "X-GitHub-Api-Version": "2022-11-28",
109    }
110
111    response = requests.get(url, headers=headers, timeout=30)
112    if response.status_code == 404:
113        raise ValueError(f"Workflow run {owner}/{repo}/actions/runs/{run_id} not found")
114    response.raise_for_status()
115
116    return response.json()
117
118
119class TestRunStatus(str, Enum):
120    """Status of a test run."""
121
122    QUEUED = "queued"
123    RUNNING = "running"
124    SUCCEEDED = "succeeded"
125    FAILED = "failed"
126
127
128class TestOutcome(str, Enum):
129    """Outcome of a test (execution or comparison)."""
130
131    PENDING = "pending"
132    RUNNING = "running"
133    PASSED = "passed"
134    FAILED = "failed"
135    SKIPPED = "skipped"
136
137
138class ValidationResultModel(BaseModel):
139    """Result of a single validation check."""
140
141    name: str = Field(description="Name of the validation check")
142    passed: bool = Field(description="Whether the validation passed")
143    message: str = Field(description="Human-readable result message")
144    errors: list[str] = Field(
145        default_factory=list,
146        description="List of error messages if validation failed",
147    )
148
149
150class StreamComparisonResultModel(BaseModel):
151    """Result of comparing a single stream between control and target."""
152
153    stream_name: str = Field(description="Name of the stream")
154    passed: bool = Field(description="Whether all comparisons passed")
155    control_record_count: int = Field(description="Number of records in control")
156    target_record_count: int = Field(description="Number of records in target")
157    missing_pks: list[str] = Field(
158        default_factory=list,
159        description="Primary keys present in control but missing in target",
160    )
161    differing_records: int = Field(
162        default=0,
163        description="Number of records that differ between control and target",
164    )
165    message: str = Field(description="Human-readable comparison summary")
166
167
168class RegressionTestExecutionResult(BaseModel):
169    """Results from executing the connector (validations and record counts)."""
170
171    outcome: TestOutcome = Field(description="Outcome of the execution")
172    catalog_validations: list[ValidationResultModel] = Field(
173        default_factory=list,
174        description="Results of catalog validation checks",
175    )
176    record_validations: list[ValidationResultModel] = Field(
177        default_factory=list,
178        description="Results of record validation checks",
179    )
180    record_count: int = Field(
181        default=0,
182        description="Total number of records read",
183    )
184    error_message: str | None = Field(
185        default=None,
186        description="Error message if the execution failed",
187    )
188
189
190class RegressionTestComparisonResult(BaseModel):
191    """Results from comparing target vs control connector versions."""
192
193    outcome: TestOutcome = Field(description="Outcome of the comparison")
194    baseline_version: str | None = Field(
195        default=None,
196        description="Version of the baseline (control) connector",
197    )
198    stream_comparisons: list[StreamComparisonResultModel] = Field(
199        default_factory=list,
200        description="Per-stream comparison results",
201    )
202    error_message: str | None = Field(
203        default=None,
204        description="Error message if the comparison failed",
205    )
206
207
208class RegressionTestResult(BaseModel):
209    """Complete result of a regression test run."""
210
211    run_id: str = Field(description="Unique identifier for this test run")
212    connection_id: str = Field(description="The connection being tested")
213    workspace_id: str = Field(description="The workspace containing the connection")
214    status: TestRunStatus = Field(description="Overall status of the test run")
215    target_version: str | None = Field(
216        default=None,
217        description="Version of the target connector being tested",
218    )
219    baseline_version: str | None = Field(
220        default=None,
221        description="Version of the baseline connector (if comparison mode)",
222    )
223    evaluation_mode: str = Field(
224        default="diagnostic",
225        description="Evaluation mode used (diagnostic or strict)",
226    )
227    compare_versions: bool = Field(
228        default=False,
229        description="Whether comparison mode was used (target vs control)",
230    )
231    execution_result: RegressionTestExecutionResult | None = Field(
232        default=None,
233        description="Results from executing the connector (validations and record counts)",
234    )
235    comparison_result: RegressionTestComparisonResult | None = Field(
236        default=None,
237        description="Results from comparing target vs control connector versions",
238    )
239    artifacts: dict[str, str] = Field(
240        default_factory=dict,
241        description="Paths to generated artifacts (JSONL, DuckDB, HAR files)",
242    )
243    human_summary: str = Field(
244        default="",
245        description="Human-readable summary of the test results",
246    )
247    started_at: datetime | None = Field(
248        default=None,
249        description="When the test run started",
250    )
251    completed_at: datetime | None = Field(
252        default=None,
253        description="When the test run completed",
254    )
255    test_description: str | None = Field(
256        default=None,
257        description="Optional description/context for this test run",
258    )
259
260
261class RunRegressionTestsResponse(BaseModel):
262    """Response from starting a regression test via GitHub Actions workflow."""
263
264    run_id: str = Field(
265        description="Unique identifier for the test run (internal tracking ID)"
266    )
267    status: TestRunStatus = Field(description="Initial status of the test run")
268    message: str = Field(description="Human-readable status message")
269    workflow_url: str | None = Field(
270        default=None,
271        description="URL to view the GitHub Actions workflow file",
272    )
273    github_run_id: int | None = Field(
274        default=None,
275        description="GitHub Actions workflow run ID (use with check_ci_workflow_status)",
276    )
277    github_run_url: str | None = Field(
278        default=None,
279        description="Direct URL to the GitHub Actions workflow run",
280    )
281
282
283@mcp_tool(
284    read_only=False,
285    idempotent=False,
286    open_world=True,
287)
288def run_regression_tests(
289    connector_name: Annotated[
290        str,
291        "Connector name to build from source (e.g., 'source-pokeapi'). Required.",
292    ],
293    pr: Annotated[
294        int,
295        "PR number to checkout and build from (e.g., 70847). Required. "
296        "The PR must be from the repository specified by the 'repo' parameter.",
297    ],
298    repo: Annotated[
299        ConnectorRepo,
300        "Repository where the connector PR is located. "
301        "Use 'airbyte' for OSS connectors (default) or 'airbyte-enterprise' for enterprise connectors.",
302    ],
303    connection_id: Annotated[
304        str | None,
305        "Airbyte Cloud connection ID to fetch config/catalog from. "
306        "If not provided, uses GSM integration test secrets.",
307    ] = None,
308    skip_compare: Annotated[
309        bool,
310        "If True, skip comparison and run single-version tests only. "
311        "If False (default), run comparison tests (target vs control versions).",
312    ] = False,
313    skip_read_action: Annotated[
314        bool,
315        "If True, skip the read action (run only spec, check, discover). "
316        "If False (default), run all verbs including read.",
317    ] = False,
318    override_test_image: Annotated[
319        str | None,
320        "Override test connector image with tag (e.g., 'airbyte/source-github:1.0.0'). "
321        "Ignored if skip_compare=False.",
322    ] = None,
323    override_control_image: Annotated[
324        str | None,
325        "Override control connector image (baseline version) with tag. "
326        "Ignored if skip_compare=True.",
327    ] = None,
328    workspace_id: Annotated[
329        str | WorkspaceAliasEnum | None,
330        "Optional Airbyte Cloud workspace ID (UUID) or alias. If provided with connection_id, "
331        "validates that the connection belongs to this workspace before triggering tests. "
332        "Accepts '@devin-ai-sandbox' as an alias for the Devin AI sandbox workspace.",
333    ] = None,
334    selected_streams: Annotated[
335        list[str] | None,
336        "List of stream names to include in the read. Only these streams will be included "
337        "in the configured catalog. This is useful to limit data volume by testing only "
338        "specific streams. If not provided, all streams are tested.",
339    ] = None,
340    enable_debug_logs: Annotated[
341        bool,
342        "Enable debug-level logging for regression test output. "
343        "Also passed as `LOG_LEVEL=DEBUG` to the connector Docker container.",
344    ] = False,
345    disable_http_replay: Annotated[
346        bool,
347        "Run both connector versions against the live API instead of replaying the "
348        "control run's recorded HTTP responses. Replay is on by default and is what "
349        "makes a record difference attributable to the version under test rather "
350        "than to upstream drift, so only disable it for a connector whose traffic "
351        "cannot be replayed faithfully. Comparison mode only.",
352    ] = False,
353    with_state: Annotated[
354        bool | None,
355        "Fetch and pass the connection's current state to the read command, "
356        "producing a warm read instead of a cold read. Defaults to `True` when "
357        "`connection_id` is provided, `False` otherwise. Has no effect unless "
358        "the command is `read`.",
359    ] = None,
360) -> RunRegressionTestsResponse:
361    """Start a regression test run via GitHub Actions workflow.
362
363    This tool triggers the regression test workflow which builds the connector
364    from the specified PR and runs tests against it.
365
366    Supports both OSS connectors (from airbytehq/airbyte) and enterprise connectors
367    (from airbytehq/airbyte-enterprise). Use the 'repo' parameter to specify which
368    repository contains the connector PR.
369
370    - skip_compare=False (default): Comparison mode - compares the PR version
371      against the baseline (control) version.
372    - skip_compare=True: Single-version mode - runs tests without comparison.
373
374    If connection_id is provided, config/catalog are fetched from Airbyte Cloud.
375    Otherwise, GSM integration test secrets are used.
376
377    Returns immediately with a run_id and workflow URL. Check the workflow URL
378    to monitor progress and view results.
379
380    Requires GITHUB_CI_WORKFLOW_TRIGGER_PAT or GITHUB_TOKEN environment variable
381    with 'actions:write' permission.
382    """
383    # Resolve workspace ID alias
384    resolved_workspace_id = WorkspaceAliasEnum.resolve(workspace_id)
385
386    # Generate a unique run ID for tracking
387    run_id = str(uuid.uuid4())
388
389    # Get GitHub token
390    try:
391        token = resolve_ci_trigger_github_token()
392    except ValueError as e:
393        return RunRegressionTestsResponse(
394            run_id=run_id,
395            status=TestRunStatus.FAILED,
396            message=str(e),
397            workflow_url=None,
398        )
399
400    # Validate workspace membership if workspace_id and connection_id are provided
401    if resolved_workspace_id and connection_id:
402        try:
403            validate_connection_workspace(connection_id, resolved_workspace_id)
404        except (
405            ValueError,
406            AirbyteWorkspaceMismatchError,
407            AirbyteMissingResourceError,
408        ) as e:
409            return RunRegressionTestsResponse(
410                run_id=run_id,
411                status=TestRunStatus.FAILED,
412                message=str(e),
413                workflow_url=None,
414            )
415
416    # Build workflow inputs - connector_name, pr, and repo are required
417    workflow_inputs: dict[str, str] = {
418        "connector_name": connector_name,
419        "pr": str(pr),
420        "repo": repo,
421    }
422
423    # Add optional inputs
424    if connection_id:
425        workflow_inputs["connection_id"] = connection_id
426    if skip_compare:
427        workflow_inputs["skip_compare"] = "true"
428    if skip_read_action:
429        workflow_inputs["skip_read_action"] = "true"
430    if override_test_image:
431        workflow_inputs["override_test_image"] = override_test_image
432    if override_control_image:
433        workflow_inputs["override_control_image"] = override_control_image
434    if selected_streams:
435        workflow_inputs["selected_streams"] = ",".join(selected_streams)
436    if enable_debug_logs:
437        workflow_inputs["enable_debug_logs"] = "true"
438    if disable_http_replay:
439        workflow_inputs["disable_http_replay"] = "true"
440    if with_state is True:
441        workflow_inputs["with_state"] = "true"
442    elif with_state is False:
443        workflow_inputs["with_state"] = "false"
444
445    mode_description = "single-version" if skip_compare else "comparison"
446
447    dispatch_result = trigger_workflow_dispatch(
448        owner=REGRESSION_TEST_REPO_OWNER,
449        repo=REGRESSION_TEST_REPO_NAME,
450        workflow_file=REGRESSION_TEST_WORKFLOW_FILE,
451        ref=resolve_default_workflow_branch(REGRESSION_TEST_DEFAULT_BRANCH),
452        inputs=workflow_inputs,
453        token=token,
454    )
455
456    view_url = dispatch_result.run_url or dispatch_result.workflow_url
457    connection_info = f" for connection {connection_id}" if connection_id else ""
458    repo_info = f" from {repo}" if repo != ConnectorRepo.AIRBYTE else ""
459    return RunRegressionTestsResponse(
460        run_id=run_id,
461        status=TestRunStatus.QUEUED,
462        message=(
463            f"{mode_description.capitalize()} regression test workflow triggered "
464            f"for {connector_name} (PR #{pr}{repo_info}){connection_info}. View progress at: {view_url}"
465        ),
466        workflow_url=dispatch_result.workflow_url,
467        github_run_id=dispatch_result.run_id,
468        github_run_url=dispatch_result.run_url,
469    )
470
471
472AIRBYTE_REPO_OWNER = "airbytehq"
473
474AIRBYTE_REPO_NAME = "airbyte"
475
476BLOCK_RELEASE_WORKFLOW_FILE = "block-release.yml"
477
478DEFAULT_REF = "master"
479
480
481class BlockConnectorReleaseResult(BaseModel):
482    """Result of triggering a release block workflow."""
483
484    success: bool = Field(description="Whether the workflow dispatch succeeded")
485    message: str = Field(description="Human-readable result message")
486    workflow_url: str = Field(description="URL to the workflow")
487    run_id: int | None = Field(
488        default=None, description="Workflow run ID, if discovered"
489    )
490    run_url: str | None = Field(default=None, description="URL to the workflow run")
491
492
493class UnblockConnectorReleaseResult(BaseModel):
494    """Result of triggering a release unblock workflow."""
495
496    success: bool = Field(description="Whether the workflow dispatch succeeded")
497    message: str = Field(description="Human-readable result message")
498    workflow_url: str = Field(description="URL to the workflow")
499    run_id: int | None = Field(
500        default=None, description="Workflow run ID, if discovered"
501    )
502    run_url: str | None = Field(default=None, description="URL to the workflow run")
503
504
505class ListBlockedConnectorsResult(BaseModel):
506    """Result of listing blocked connectors via the GitHub API."""
507
508    blocked_connectors: list[dict] = Field(
509        default_factory=list,
510        description="List of blocked connectors with optional block metadata",
511    )
512    count: int = Field(default=0, description="Number of blocked connectors")
513
514
515@mcp_tool(
516    read_only=False,
517    idempotent=False,
518    open_world=True,
519)
520def block_connector_release(
521    connector_name: Annotated[
522        str,
523        Field(
524            description="Connector technical name (e.g., `source-faker`, `destination-postgres`)"
525        ),
526    ],
527    reason: Annotated[
528        str,
529        Field(description="Human-readable reason for blocking the release"),
530    ],
531    yanked_version: Annotated[
532        str | None,
533        Field(description="Version that was yanked (for reference)"),
534    ] = None,
535    blocked_by: Annotated[
536        str | None,
537        Field(description="Email or identifier of the person requesting the block"),
538    ] = None,
539) -> BlockConnectorReleaseResult:
540    """Block a connector from being released by creating a `block-release.yaml` marker.
541
542    Triggers the `block-release.yml` workflow in the airbyte monorepo, which
543    creates a marker file, opens a PR, and force-merges it to master. While the
544    marker exists, the publish pipeline will skip the connector with a warning.
545
546    Use this after yanking a connector version to prevent CI from accidentally
547    re-publishing the broken code.
548    """
549    token = resolve_ci_trigger_github_token()
550
551    inputs: dict[str, str] = {
552        "connector-name": connector_name,
553        "action": "block",
554        "reason": reason,
555    }
556    if yanked_version:
557        inputs["yanked-version"] = yanked_version
558    if blocked_by:
559        inputs["blocked-by"] = blocked_by
560
561    result = trigger_workflow_dispatch(
562        owner=AIRBYTE_REPO_OWNER,
563        repo=AIRBYTE_REPO_NAME,
564        workflow_file=BLOCK_RELEASE_WORKFLOW_FILE,
565        ref=resolve_default_workflow_branch(DEFAULT_REF),
566        inputs=inputs,
567        token=token,
568        find_run=True,
569    )
570
571    if result.run_id:
572        message = (
573            f"Successfully triggered release block for {connector_name}. "
574            f"Run ID: {result.run_id}"
575        )
576    else:
577        message = (
578            f"Successfully triggered release block for {connector_name}. "
579            "Run ID not yet available."
580        )
581
582    return BlockConnectorReleaseResult(
583        success=True,
584        message=message,
585        workflow_url=result.workflow_url,
586        run_id=result.run_id,
587        run_url=result.run_url,
588    )
589
590
591@mcp_tool(
592    read_only=False,
593    idempotent=False,
594    open_world=True,
595)
596def unblock_connector_release(
597    connector_name: Annotated[
598        str,
599        Field(
600            description="Connector technical name (e.g., `source-faker`, `destination-postgres`)"
601        ),
602    ],
603) -> UnblockConnectorReleaseResult:
604    """Remove a release block for a connector by deleting its `block-release.yaml` marker.
605
606    Triggers the `block-release.yml` workflow with action=unblock, which removes
607    the marker file, opens a PR, and force-merges it to master. After this, the
608    connector can be published normally again.
609    """
610    token = resolve_ci_trigger_github_token()
611
612    result = trigger_workflow_dispatch(
613        owner=AIRBYTE_REPO_OWNER,
614        repo=AIRBYTE_REPO_NAME,
615        workflow_file=BLOCK_RELEASE_WORKFLOW_FILE,
616        ref=resolve_default_workflow_branch(DEFAULT_REF),
617        inputs={
618            "connector-name": connector_name,
619            "action": "unblock",
620        },
621        token=token,
622        find_run=True,
623    )
624
625    if result.run_id:
626        message = (
627            f"Successfully triggered release unblock for {connector_name}. "
628            f"Run ID: {result.run_id}"
629        )
630    else:
631        message = (
632            f"Successfully triggered release unblock for {connector_name}. "
633            "Run ID not yet available."
634        )
635
636    return UnblockConnectorReleaseResult(
637        success=True,
638        message=message,
639        workflow_url=result.workflow_url,
640        run_id=result.run_id,
641        run_url=result.run_url,
642    )
643
644
645@mcp_tool(
646    read_only=True,
647    idempotent=True,
648    open_world=True,
649)
650def list_blocked_connector_releases(
651    connector_name: Annotated[
652        str | None,
653        Field(
654            description="Optional connector name to check. If not provided, scans all connectors."
655        ),
656    ] = None,
657    include_details: Annotated[
658        bool,
659        Field(
660            description="Whether to fetch and parse each marker file for reason and metadata."
661        ),
662    ] = True,
663) -> ListBlockedConnectorsResult:
664    """List connectors that are currently blocked from release.
665
666    Searches the airbyte monorepo for `block-release.yaml` marker files using
667    the GitHub API. Returns a list of blocked connectors, with marker metadata
668    when `include_details` is `True`.
669    """
670
671    token = resolve_ci_trigger_github_token()
672    ref = resolve_default_workflow_branch(DEFAULT_REF)
673    blocked: list[dict] = []
674
675    if connector_name:
676        blocked = _check_single_connector_block(connector_name, token, ref)
677    else:
678        blocked = _search_all_blocked_connectors(token, ref, include_details)
679
680    return ListBlockedConnectorsResult(
681        blocked_connectors=blocked,
682        count=len(blocked),
683    )
684
685
686def _check_single_connector_block(
687    connector_name: str,
688    token: str,
689    ref: str,
690) -> list[dict]:
691    """Check if a single connector has a release block."""
692    path = f"airbyte-integrations/connectors/{connector_name}/block-release.yaml"
693    content = get_file_contents_at_ref(
694        owner=AIRBYTE_REPO_OWNER,
695        repo=AIRBYTE_REPO_NAME,
696        path=path,
697        ref=ref,
698        token=token,
699    )
700    if content is None:
701        return []
702
703    return _parse_block_marker_content(connector_name, content)
704
705
706def _parse_block_marker_content(connector_name: str, content: str) -> list[dict]:
707    """Parse a `block-release.yaml` marker into the MCP response shape."""
708    try:
709        block_file_data = yaml.safe_load(content)
710        if isinstance(block_file_data, dict):
711            return [
712                {
713                    "connector_name": connector_name,
714                    "reason": block_file_data.get("reason", "(no reason provided)"),
715                    "yanked_version": block_file_data.get("yanked_version"),
716                    "blocked_at": block_file_data.get("blocked_at"),
717                    "blocked_by": block_file_data.get("blocked_by"),
718                }
719            ]
720    except yaml.YAMLError:
721        return [
722            {"connector_name": connector_name, "reason": "(unable to parse marker)"}
723        ]
724
725    return [
726        {
727            "connector_name": connector_name,
728            "reason": "(invalid block-release.yaml format)",
729        }
730    ]
731
732
733def _search_all_blocked_connectors(
734    token: str,
735    ref: str,
736    include_details: bool,
737) -> list[dict]:
738    """Search the repo for all `block-release.yaml` files at the requested ref."""
739    tree_ref = quote(ref, safe="")
740    url = (
741        f"{GITHUB_API_BASE}/repos/{AIRBYTE_REPO_OWNER}/{AIRBYTE_REPO_NAME}"
742        f"/git/trees/{tree_ref}"
743    )
744    headers = {
745        "Authorization": f"Bearer {token}",
746        "Accept": "application/vnd.github+json",
747        "X-GitHub-Api-Version": "2022-11-28",
748    }
749    response = requests.get(
750        url,
751        headers=headers,
752        params={"recursive": "1"},
753        timeout=30,
754    )
755    response.raise_for_status()
756
757    tree = response.json().get("tree", [])
758    blocked: list[dict] = []
759
760    for item in tree:
761        file_path = item.get("path", "")
762        if not file_path.endswith("/block-release.yaml"):
763            continue
764
765        parts = file_path.split("/")
766        if (
767            len(parts) == 4
768            and parts[0] == "airbyte-integrations"
769            and parts[1] == "connectors"
770        ):
771            connector_name = parts[2]
772            if not include_details:
773                blocked.append({"connector_name": connector_name})
774                continue
775
776            content = get_file_contents_at_ref(
777                owner=AIRBYTE_REPO_OWNER,
778                repo=AIRBYTE_REPO_NAME,
779                path=file_path,
780                ref=ref,
781                token=token,
782            )
783            if content is not None:
784                blocked.extend(_parse_block_marker_content(connector_name, content))
785
786    return blocked
787
788
789def register_connector_qa_tools(app: FastMCP) -> None:
790    """Register connector_qa tools with the FastMCP app."""
791    register_mcp_tools(app, mcp_module=__name__)