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__)