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

Hints: open-world

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

Hints: read-only · idempotent · open-world

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

Hints: open-world

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.
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"
    },
    "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

Hints: open-world

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    with_state: Annotated[
346        bool | None,
347        "Fetch and pass the connection's current state to the read command, "
348        "producing a warm read instead of a cold read. Defaults to `True` when "
349        "`connection_id` is provided, `False` otherwise. Has no effect unless "
350        "the command is `read`.",
351    ] = None,
352) -> RunRegressionTestsResponse:
353    """Start a regression test run via GitHub Actions workflow.
354
355    This tool triggers the regression test workflow which builds the connector
356    from the specified PR and runs tests against it.
357
358    Supports both OSS connectors (from airbytehq/airbyte) and enterprise connectors
359    (from airbytehq/airbyte-enterprise). Use the 'repo' parameter to specify which
360    repository contains the connector PR.
361
362    - skip_compare=False (default): Comparison mode - compares the PR version
363      against the baseline (control) version.
364    - skip_compare=True: Single-version mode - runs tests without comparison.
365
366    If connection_id is provided, config/catalog are fetched from Airbyte Cloud.
367    Otherwise, GSM integration test secrets are used.
368
369    Returns immediately with a run_id and workflow URL. Check the workflow URL
370    to monitor progress and view results.
371
372    Requires GITHUB_CI_WORKFLOW_TRIGGER_PAT or GITHUB_TOKEN environment variable
373    with 'actions:write' permission.
374    """
375    # Resolve workspace ID alias
376    resolved_workspace_id = WorkspaceAliasEnum.resolve(workspace_id)
377
378    # Generate a unique run ID for tracking
379    run_id = str(uuid.uuid4())
380
381    # Get GitHub token
382    try:
383        token = resolve_ci_trigger_github_token()
384    except ValueError as e:
385        return RunRegressionTestsResponse(
386            run_id=run_id,
387            status=TestRunStatus.FAILED,
388            message=str(e),
389            workflow_url=None,
390        )
391
392    # Validate workspace membership if workspace_id and connection_id are provided
393    if resolved_workspace_id and connection_id:
394        try:
395            validate_connection_workspace(connection_id, resolved_workspace_id)
396        except (
397            ValueError,
398            AirbyteWorkspaceMismatchError,
399            AirbyteMissingResourceError,
400        ) as e:
401            return RunRegressionTestsResponse(
402                run_id=run_id,
403                status=TestRunStatus.FAILED,
404                message=str(e),
405                workflow_url=None,
406            )
407
408    # Build workflow inputs - connector_name, pr, and repo are required
409    workflow_inputs: dict[str, str] = {
410        "connector_name": connector_name,
411        "pr": str(pr),
412        "repo": repo,
413    }
414
415    # Add optional inputs
416    if connection_id:
417        workflow_inputs["connection_id"] = connection_id
418    if skip_compare:
419        workflow_inputs["skip_compare"] = "true"
420    if skip_read_action:
421        workflow_inputs["skip_read_action"] = "true"
422    if override_test_image:
423        workflow_inputs["override_test_image"] = override_test_image
424    if override_control_image:
425        workflow_inputs["override_control_image"] = override_control_image
426    if selected_streams:
427        workflow_inputs["selected_streams"] = ",".join(selected_streams)
428    if enable_debug_logs:
429        workflow_inputs["enable_debug_logs"] = "true"
430    if with_state is True:
431        workflow_inputs["with_state"] = "true"
432    elif with_state is False:
433        workflow_inputs["with_state"] = "false"
434
435    mode_description = "single-version" if skip_compare else "comparison"
436
437    dispatch_result = trigger_workflow_dispatch(
438        owner=REGRESSION_TEST_REPO_OWNER,
439        repo=REGRESSION_TEST_REPO_NAME,
440        workflow_file=REGRESSION_TEST_WORKFLOW_FILE,
441        ref=resolve_default_workflow_branch(REGRESSION_TEST_DEFAULT_BRANCH),
442        inputs=workflow_inputs,
443        token=token,
444    )
445
446    view_url = dispatch_result.run_url or dispatch_result.workflow_url
447    connection_info = f" for connection {connection_id}" if connection_id else ""
448    repo_info = f" from {repo}" if repo != ConnectorRepo.AIRBYTE else ""
449    return RunRegressionTestsResponse(
450        run_id=run_id,
451        status=TestRunStatus.QUEUED,
452        message=(
453            f"{mode_description.capitalize()} regression test workflow triggered "
454            f"for {connector_name} (PR #{pr}{repo_info}){connection_info}. View progress at: {view_url}"
455        ),
456        workflow_url=dispatch_result.workflow_url,
457        github_run_id=dispatch_result.run_id,
458        github_run_url=dispatch_result.run_url,
459    )
460
461
462AIRBYTE_REPO_OWNER = "airbytehq"
463
464AIRBYTE_REPO_NAME = "airbyte"
465
466BLOCK_RELEASE_WORKFLOW_FILE = "block-release.yml"
467
468DEFAULT_REF = "master"
469
470
471class BlockConnectorReleaseResult(BaseModel):
472    """Result of triggering a release block workflow."""
473
474    success: bool = Field(description="Whether the workflow dispatch succeeded")
475    message: str = Field(description="Human-readable result message")
476    workflow_url: str = Field(description="URL to the workflow")
477    run_id: int | None = Field(
478        default=None, description="Workflow run ID, if discovered"
479    )
480    run_url: str | None = Field(default=None, description="URL to the workflow run")
481
482
483class UnblockConnectorReleaseResult(BaseModel):
484    """Result of triggering a release unblock workflow."""
485
486    success: bool = Field(description="Whether the workflow dispatch succeeded")
487    message: str = Field(description="Human-readable result message")
488    workflow_url: str = Field(description="URL to the workflow")
489    run_id: int | None = Field(
490        default=None, description="Workflow run ID, if discovered"
491    )
492    run_url: str | None = Field(default=None, description="URL to the workflow run")
493
494
495class ListBlockedConnectorsResult(BaseModel):
496    """Result of listing blocked connectors via the GitHub API."""
497
498    blocked_connectors: list[dict] = Field(
499        default_factory=list,
500        description="List of blocked connectors with optional block metadata",
501    )
502    count: int = Field(default=0, description="Number of blocked connectors")
503
504
505@mcp_tool(
506    read_only=False,
507    idempotent=False,
508    open_world=True,
509)
510def block_connector_release(
511    connector_name: Annotated[
512        str,
513        Field(
514            description="Connector technical name (e.g., `source-faker`, `destination-postgres`)"
515        ),
516    ],
517    reason: Annotated[
518        str,
519        Field(description="Human-readable reason for blocking the release"),
520    ],
521    yanked_version: Annotated[
522        str | None,
523        Field(description="Version that was yanked (for reference)"),
524    ] = None,
525    blocked_by: Annotated[
526        str | None,
527        Field(description="Email or identifier of the person requesting the block"),
528    ] = None,
529) -> BlockConnectorReleaseResult:
530    """Block a connector from being released by creating a `block-release.yaml` marker.
531
532    Triggers the `block-release.yml` workflow in the airbyte monorepo, which
533    creates a marker file, opens a PR, and force-merges it to master. While the
534    marker exists, the publish pipeline will skip the connector with a warning.
535
536    Use this after yanking a connector version to prevent CI from accidentally
537    re-publishing the broken code.
538    """
539    token = resolve_ci_trigger_github_token()
540
541    inputs: dict[str, str] = {
542        "connector-name": connector_name,
543        "action": "block",
544        "reason": reason,
545    }
546    if yanked_version:
547        inputs["yanked-version"] = yanked_version
548    if blocked_by:
549        inputs["blocked-by"] = blocked_by
550
551    result = trigger_workflow_dispatch(
552        owner=AIRBYTE_REPO_OWNER,
553        repo=AIRBYTE_REPO_NAME,
554        workflow_file=BLOCK_RELEASE_WORKFLOW_FILE,
555        ref=resolve_default_workflow_branch(DEFAULT_REF),
556        inputs=inputs,
557        token=token,
558        find_run=True,
559    )
560
561    if result.run_id:
562        message = (
563            f"Successfully triggered release block for {connector_name}. "
564            f"Run ID: {result.run_id}"
565        )
566    else:
567        message = (
568            f"Successfully triggered release block for {connector_name}. "
569            "Run ID not yet available."
570        )
571
572    return BlockConnectorReleaseResult(
573        success=True,
574        message=message,
575        workflow_url=result.workflow_url,
576        run_id=result.run_id,
577        run_url=result.run_url,
578    )
579
580
581@mcp_tool(
582    read_only=False,
583    idempotent=False,
584    open_world=True,
585)
586def unblock_connector_release(
587    connector_name: Annotated[
588        str,
589        Field(
590            description="Connector technical name (e.g., `source-faker`, `destination-postgres`)"
591        ),
592    ],
593) -> UnblockConnectorReleaseResult:
594    """Remove a release block for a connector by deleting its `block-release.yaml` marker.
595
596    Triggers the `block-release.yml` workflow with action=unblock, which removes
597    the marker file, opens a PR, and force-merges it to master. After this, the
598    connector can be published normally again.
599    """
600    token = resolve_ci_trigger_github_token()
601
602    result = trigger_workflow_dispatch(
603        owner=AIRBYTE_REPO_OWNER,
604        repo=AIRBYTE_REPO_NAME,
605        workflow_file=BLOCK_RELEASE_WORKFLOW_FILE,
606        ref=resolve_default_workflow_branch(DEFAULT_REF),
607        inputs={
608            "connector-name": connector_name,
609            "action": "unblock",
610        },
611        token=token,
612        find_run=True,
613    )
614
615    if result.run_id:
616        message = (
617            f"Successfully triggered release unblock for {connector_name}. "
618            f"Run ID: {result.run_id}"
619        )
620    else:
621        message = (
622            f"Successfully triggered release unblock for {connector_name}. "
623            "Run ID not yet available."
624        )
625
626    return UnblockConnectorReleaseResult(
627        success=True,
628        message=message,
629        workflow_url=result.workflow_url,
630        run_id=result.run_id,
631        run_url=result.run_url,
632    )
633
634
635@mcp_tool(
636    read_only=True,
637    idempotent=True,
638    open_world=True,
639)
640def list_blocked_connector_releases(
641    connector_name: Annotated[
642        str | None,
643        Field(
644            description="Optional connector name to check. If not provided, scans all connectors."
645        ),
646    ] = None,
647    include_details: Annotated[
648        bool,
649        Field(
650            description="Whether to fetch and parse each marker file for reason and metadata."
651        ),
652    ] = True,
653) -> ListBlockedConnectorsResult:
654    """List connectors that are currently blocked from release.
655
656    Searches the airbyte monorepo for `block-release.yaml` marker files using
657    the GitHub API. Returns a list of blocked connectors, with marker metadata
658    when `include_details` is `True`.
659    """
660
661    token = resolve_ci_trigger_github_token()
662    ref = resolve_default_workflow_branch(DEFAULT_REF)
663    blocked: list[dict] = []
664
665    if connector_name:
666        blocked = _check_single_connector_block(connector_name, token, ref)
667    else:
668        blocked = _search_all_blocked_connectors(token, ref, include_details)
669
670    return ListBlockedConnectorsResult(
671        blocked_connectors=blocked,
672        count=len(blocked),
673    )
674
675
676def _check_single_connector_block(
677    connector_name: str,
678    token: str,
679    ref: str,
680) -> list[dict]:
681    """Check if a single connector has a release block."""
682    path = f"airbyte-integrations/connectors/{connector_name}/block-release.yaml"
683    content = get_file_contents_at_ref(
684        owner=AIRBYTE_REPO_OWNER,
685        repo=AIRBYTE_REPO_NAME,
686        path=path,
687        ref=ref,
688        token=token,
689    )
690    if content is None:
691        return []
692
693    return _parse_block_marker_content(connector_name, content)
694
695
696def _parse_block_marker_content(connector_name: str, content: str) -> list[dict]:
697    """Parse a `block-release.yaml` marker into the MCP response shape."""
698    try:
699        block_file_data = yaml.safe_load(content)
700        if isinstance(block_file_data, dict):
701            return [
702                {
703                    "connector_name": connector_name,
704                    "reason": block_file_data.get("reason", "(no reason provided)"),
705                    "yanked_version": block_file_data.get("yanked_version"),
706                    "blocked_at": block_file_data.get("blocked_at"),
707                    "blocked_by": block_file_data.get("blocked_by"),
708                }
709            ]
710    except yaml.YAMLError:
711        return [
712            {"connector_name": connector_name, "reason": "(unable to parse marker)"}
713        ]
714
715    return [
716        {
717            "connector_name": connector_name,
718            "reason": "(invalid block-release.yaml format)",
719        }
720    ]
721
722
723def _search_all_blocked_connectors(
724    token: str,
725    ref: str,
726    include_details: bool,
727) -> list[dict]:
728    """Search the repo for all `block-release.yaml` files at the requested ref."""
729    tree_ref = quote(ref, safe="")
730    url = (
731        f"{GITHUB_API_BASE}/repos/{AIRBYTE_REPO_OWNER}/{AIRBYTE_REPO_NAME}"
732        f"/git/trees/{tree_ref}"
733    )
734    headers = {
735        "Authorization": f"Bearer {token}",
736        "Accept": "application/vnd.github+json",
737        "X-GitHub-Api-Version": "2022-11-28",
738    }
739    response = requests.get(
740        url,
741        headers=headers,
742        params={"recursive": "1"},
743        timeout=30,
744    )
745    response.raise_for_status()
746
747    tree = response.json().get("tree", [])
748    blocked: list[dict] = []
749
750    for item in tree:
751        file_path = item.get("path", "")
752        if not file_path.endswith("/block-release.yaml"):
753            continue
754
755        parts = file_path.split("/")
756        if (
757            len(parts) == 4
758            and parts[0] == "airbyte-integrations"
759            and parts[1] == "connectors"
760        ):
761            connector_name = parts[2]
762            if not include_details:
763                blocked.append({"connector_name": connector_name})
764                continue
765
766            content = get_file_contents_at_ref(
767                owner=AIRBYTE_REPO_OWNER,
768                repo=AIRBYTE_REPO_NAME,
769                path=file_path,
770                ref=ref,
771                token=token,
772            )
773            if content is not None:
774                blocked.extend(_parse_block_marker_content(connector_name, content))
775
776    return blocked
777
778
779def register_connector_qa_tools(app: FastMCP) -> None:
780    """Register connector_qa tools with the FastMCP app."""
781    register_mcp_tools(app, mcp_module=__name__)