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