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