airbyte_ops_mcp.mcp.release_block
MCP tools for connector release block operations.
These tools trigger the block-release.yml GitHub Actions workflow in the
airbyte monorepo to create or remove block-release.yaml marker files.
The workflow handles branching, PR creation, and force-merge automatically.
1# Copyright (c) 2025 Airbyte, Inc., all rights reserved. 2"""MCP tools for connector release block operations. 3 4These tools trigger the `block-release.yml` GitHub Actions workflow in the 5airbyte monorepo to create or remove `block-release.yaml` marker files. 6The workflow handles branching, PR creation, and force-merge automatically. 7""" 8 9from __future__ import annotations 10 11from typing import Annotated 12from urllib.parse import quote 13 14import requests 15import yaml 16from fastmcp import FastMCP 17from fastmcp_extensions import mcp_tool, register_mcp_tools 18from pydantic import BaseModel, Field 19 20from airbyte_ops_mcp.github_actions import ( 21 resolve_default_workflow_branch, 22 trigger_workflow_dispatch, 23) 24from airbyte_ops_mcp.github_api import ( 25 GITHUB_API_BASE, 26 get_file_contents_at_ref, 27 resolve_ci_trigger_github_token, 28) 29 30AIRBYTE_REPO_OWNER = "airbytehq" 31AIRBYTE_REPO_NAME = "airbyte" 32BLOCK_RELEASE_WORKFLOW_FILE = "block-release.yml" 33DEFAULT_REF = "master" 34 35 36class BlockConnectorReleaseResult(BaseModel): 37 """Result of triggering a release block workflow.""" 38 39 success: bool = Field(description="Whether the workflow dispatch succeeded") 40 message: str = Field(description="Human-readable result message") 41 workflow_url: str = Field(description="URL to the workflow") 42 run_id: int | None = Field( 43 default=None, description="Workflow run ID, if discovered" 44 ) 45 run_url: str | None = Field(default=None, description="URL to the workflow run") 46 47 48class UnblockConnectorReleaseResult(BaseModel): 49 """Result of triggering a release unblock workflow.""" 50 51 success: bool = Field(description="Whether the workflow dispatch succeeded") 52 message: str = Field(description="Human-readable result message") 53 workflow_url: str = Field(description="URL to the workflow") 54 run_id: int | None = Field( 55 default=None, description="Workflow run ID, if discovered" 56 ) 57 run_url: str | None = Field(default=None, description="URL to the workflow run") 58 59 60class ListBlockedConnectorsResult(BaseModel): 61 """Result of listing blocked connectors via the GitHub API.""" 62 63 blocked_connectors: list[dict] = Field( 64 default_factory=list, 65 description="List of blocked connectors with optional block metadata", 66 ) 67 count: int = Field(default=0, description="Number of blocked connectors") 68 69 70@mcp_tool( 71 read_only=False, 72 idempotent=False, 73 open_world=True, 74) 75def block_connector_release( 76 connector_name: Annotated[ 77 str, 78 Field( 79 description="Connector technical name (e.g., `source-faker`, `destination-postgres`)" 80 ), 81 ], 82 reason: Annotated[ 83 str, 84 Field(description="Human-readable reason for blocking the release"), 85 ], 86 yanked_version: Annotated[ 87 str | None, 88 Field(description="Version that was yanked (for reference)"), 89 ] = None, 90 blocked_by: Annotated[ 91 str | None, 92 Field(description="Email or identifier of the person requesting the block"), 93 ] = None, 94) -> BlockConnectorReleaseResult: 95 """Block a connector from being released by creating a `block-release.yaml` marker. 96 97 Triggers the `block-release.yml` workflow in the airbyte monorepo, which 98 creates a marker file, opens a PR, and force-merges it to master. While the 99 marker exists, the publish pipeline will skip the connector with a warning. 100 101 Use this after yanking a connector version to prevent CI from accidentally 102 re-publishing the broken code. 103 """ 104 token = resolve_ci_trigger_github_token() 105 106 inputs: dict[str, str] = { 107 "connector-name": connector_name, 108 "action": "block", 109 "reason": reason, 110 } 111 if yanked_version: 112 inputs["yanked-version"] = yanked_version 113 if blocked_by: 114 inputs["blocked-by"] = blocked_by 115 116 result = trigger_workflow_dispatch( 117 owner=AIRBYTE_REPO_OWNER, 118 repo=AIRBYTE_REPO_NAME, 119 workflow_file=BLOCK_RELEASE_WORKFLOW_FILE, 120 ref=resolve_default_workflow_branch(DEFAULT_REF), 121 inputs=inputs, 122 token=token, 123 find_run=True, 124 ) 125 126 if result.run_id: 127 message = ( 128 f"Successfully triggered release block for {connector_name}. " 129 f"Run ID: {result.run_id}" 130 ) 131 else: 132 message = ( 133 f"Successfully triggered release block for {connector_name}. " 134 "Run ID not yet available." 135 ) 136 137 return BlockConnectorReleaseResult( 138 success=True, 139 message=message, 140 workflow_url=result.workflow_url, 141 run_id=result.run_id, 142 run_url=result.run_url, 143 ) 144 145 146@mcp_tool( 147 read_only=False, 148 idempotent=False, 149 open_world=True, 150) 151def unblock_connector_release( 152 connector_name: Annotated[ 153 str, 154 Field( 155 description="Connector technical name (e.g., `source-faker`, `destination-postgres`)" 156 ), 157 ], 158) -> UnblockConnectorReleaseResult: 159 """Remove a release block for a connector by deleting its `block-release.yaml` marker. 160 161 Triggers the `block-release.yml` workflow with action=unblock, which removes 162 the marker file, opens a PR, and force-merges it to master. After this, the 163 connector can be published normally again. 164 """ 165 token = resolve_ci_trigger_github_token() 166 167 result = trigger_workflow_dispatch( 168 owner=AIRBYTE_REPO_OWNER, 169 repo=AIRBYTE_REPO_NAME, 170 workflow_file=BLOCK_RELEASE_WORKFLOW_FILE, 171 ref=resolve_default_workflow_branch(DEFAULT_REF), 172 inputs={ 173 "connector-name": connector_name, 174 "action": "unblock", 175 }, 176 token=token, 177 find_run=True, 178 ) 179 180 if result.run_id: 181 message = ( 182 f"Successfully triggered release unblock for {connector_name}. " 183 f"Run ID: {result.run_id}" 184 ) 185 else: 186 message = ( 187 f"Successfully triggered release unblock for {connector_name}. " 188 "Run ID not yet available." 189 ) 190 191 return UnblockConnectorReleaseResult( 192 success=True, 193 message=message, 194 workflow_url=result.workflow_url, 195 run_id=result.run_id, 196 run_url=result.run_url, 197 ) 198 199 200@mcp_tool( 201 read_only=True, 202 idempotent=True, 203 open_world=True, 204) 205def list_blocked_connector_releases( 206 connector_name: Annotated[ 207 str | None, 208 Field( 209 description="Optional connector name to check. If not provided, scans all connectors." 210 ), 211 ] = None, 212 include_details: Annotated[ 213 bool, 214 Field( 215 description="Whether to fetch and parse each marker file for reason and metadata." 216 ), 217 ] = True, 218) -> ListBlockedConnectorsResult: 219 """List connectors that are currently blocked from release. 220 221 Searches the airbyte monorepo for `block-release.yaml` marker files using 222 the GitHub API. Returns a list of blocked connectors, with marker metadata 223 when `include_details` is `True`. 224 """ 225 226 token = resolve_ci_trigger_github_token() 227 ref = resolve_default_workflow_branch(DEFAULT_REF) 228 blocked: list[dict] = [] 229 230 if connector_name: 231 blocked = _check_single_connector_block(connector_name, token, ref) 232 else: 233 blocked = _search_all_blocked_connectors(token, ref, include_details) 234 235 return ListBlockedConnectorsResult( 236 blocked_connectors=blocked, 237 count=len(blocked), 238 ) 239 240 241def _check_single_connector_block( 242 connector_name: str, 243 token: str, 244 ref: str, 245) -> list[dict]: 246 """Check if a single connector has a release block.""" 247 path = f"airbyte-integrations/connectors/{connector_name}/block-release.yaml" 248 content = get_file_contents_at_ref( 249 owner=AIRBYTE_REPO_OWNER, 250 repo=AIRBYTE_REPO_NAME, 251 path=path, 252 ref=ref, 253 token=token, 254 ) 255 if content is None: 256 return [] 257 258 return _parse_block_marker_content(connector_name, content) 259 260 261def _parse_block_marker_content(connector_name: str, content: str) -> list[dict]: 262 """Parse a `block-release.yaml` marker into the MCP response shape.""" 263 try: 264 block_file_data = yaml.safe_load(content) 265 if isinstance(block_file_data, dict): 266 return [ 267 { 268 "connector_name": connector_name, 269 "reason": block_file_data.get("reason", "(no reason provided)"), 270 "yanked_version": block_file_data.get("yanked_version"), 271 "blocked_at": block_file_data.get("blocked_at"), 272 "blocked_by": block_file_data.get("blocked_by"), 273 } 274 ] 275 except yaml.YAMLError: 276 return [ 277 {"connector_name": connector_name, "reason": "(unable to parse marker)"} 278 ] 279 280 return [ 281 { 282 "connector_name": connector_name, 283 "reason": "(invalid block-release.yaml format)", 284 } 285 ] 286 287 288def _search_all_blocked_connectors( 289 token: str, 290 ref: str, 291 include_details: bool, 292) -> list[dict]: 293 """Search the repo for all `block-release.yaml` files at the requested ref.""" 294 tree_ref = quote(ref, safe="") 295 url = ( 296 f"{GITHUB_API_BASE}/repos/{AIRBYTE_REPO_OWNER}/{AIRBYTE_REPO_NAME}" 297 f"/git/trees/{tree_ref}" 298 ) 299 headers = { 300 "Authorization": f"Bearer {token}", 301 "Accept": "application/vnd.github+json", 302 "X-GitHub-Api-Version": "2022-11-28", 303 } 304 response = requests.get( 305 url, 306 headers=headers, 307 params={"recursive": "1"}, 308 timeout=30, 309 ) 310 response.raise_for_status() 311 312 tree = response.json().get("tree", []) 313 blocked: list[dict] = [] 314 315 for item in tree: 316 file_path = item.get("path", "") 317 if not file_path.endswith("/block-release.yaml"): 318 continue 319 320 parts = file_path.split("/") 321 if ( 322 len(parts) == 4 323 and parts[0] == "airbyte-integrations" 324 and parts[1] == "connectors" 325 ): 326 connector_name = parts[2] 327 if not include_details: 328 blocked.append({"connector_name": connector_name}) 329 continue 330 331 content = get_file_contents_at_ref( 332 owner=AIRBYTE_REPO_OWNER, 333 repo=AIRBYTE_REPO_NAME, 334 path=file_path, 335 ref=ref, 336 token=token, 337 ) 338 if content is not None: 339 blocked.extend(_parse_block_marker_content(connector_name, content)) 340 341 return blocked 342 343 344def register_release_block_tools(app: FastMCP) -> None: 345 """Register release block tools with the FastMCP app.""" 346 register_mcp_tools(app, mcp_module=__name__)
37class BlockConnectorReleaseResult(BaseModel): 38 """Result of triggering a release block workflow.""" 39 40 success: bool = Field(description="Whether the workflow dispatch succeeded") 41 message: str = Field(description="Human-readable result message") 42 workflow_url: str = Field(description="URL to the workflow") 43 run_id: int | None = Field( 44 default=None, description="Workflow run ID, if discovered" 45 ) 46 run_url: str | None = Field(default=None, description="URL to the workflow run")
Result of triggering a release block workflow.
49class UnblockConnectorReleaseResult(BaseModel): 50 """Result of triggering a release unblock workflow.""" 51 52 success: bool = Field(description="Whether the workflow dispatch succeeded") 53 message: str = Field(description="Human-readable result message") 54 workflow_url: str = Field(description="URL to the workflow") 55 run_id: int | None = Field( 56 default=None, description="Workflow run ID, if discovered" 57 ) 58 run_url: str | None = Field(default=None, description="URL to the workflow run")
Result of triggering a release unblock workflow.
61class ListBlockedConnectorsResult(BaseModel): 62 """Result of listing blocked connectors via the GitHub API.""" 63 64 blocked_connectors: list[dict] = Field( 65 default_factory=list, 66 description="List of blocked connectors with optional block metadata", 67 ) 68 count: int = Field(default=0, description="Number of blocked connectors")
Result of listing blocked connectors via the GitHub API.
71@mcp_tool( 72 read_only=False, 73 idempotent=False, 74 open_world=True, 75) 76def block_connector_release( 77 connector_name: Annotated[ 78 str, 79 Field( 80 description="Connector technical name (e.g., `source-faker`, `destination-postgres`)" 81 ), 82 ], 83 reason: Annotated[ 84 str, 85 Field(description="Human-readable reason for blocking the release"), 86 ], 87 yanked_version: Annotated[ 88 str | None, 89 Field(description="Version that was yanked (for reference)"), 90 ] = None, 91 blocked_by: Annotated[ 92 str | None, 93 Field(description="Email or identifier of the person requesting the block"), 94 ] = None, 95) -> BlockConnectorReleaseResult: 96 """Block a connector from being released by creating a `block-release.yaml` marker. 97 98 Triggers the `block-release.yml` workflow in the airbyte monorepo, which 99 creates a marker file, opens a PR, and force-merges it to master. While the 100 marker exists, the publish pipeline will skip the connector with a warning. 101 102 Use this after yanking a connector version to prevent CI from accidentally 103 re-publishing the broken code. 104 """ 105 token = resolve_ci_trigger_github_token() 106 107 inputs: dict[str, str] = { 108 "connector-name": connector_name, 109 "action": "block", 110 "reason": reason, 111 } 112 if yanked_version: 113 inputs["yanked-version"] = yanked_version 114 if blocked_by: 115 inputs["blocked-by"] = blocked_by 116 117 result = trigger_workflow_dispatch( 118 owner=AIRBYTE_REPO_OWNER, 119 repo=AIRBYTE_REPO_NAME, 120 workflow_file=BLOCK_RELEASE_WORKFLOW_FILE, 121 ref=resolve_default_workflow_branch(DEFAULT_REF), 122 inputs=inputs, 123 token=token, 124 find_run=True, 125 ) 126 127 if result.run_id: 128 message = ( 129 f"Successfully triggered release block for {connector_name}. " 130 f"Run ID: {result.run_id}" 131 ) 132 else: 133 message = ( 134 f"Successfully triggered release block for {connector_name}. " 135 "Run ID not yet available." 136 ) 137 138 return BlockConnectorReleaseResult( 139 success=True, 140 message=message, 141 workflow_url=result.workflow_url, 142 run_id=result.run_id, 143 run_url=result.run_url, 144 )
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.
147@mcp_tool( 148 read_only=False, 149 idempotent=False, 150 open_world=True, 151) 152def unblock_connector_release( 153 connector_name: Annotated[ 154 str, 155 Field( 156 description="Connector technical name (e.g., `source-faker`, `destination-postgres`)" 157 ), 158 ], 159) -> UnblockConnectorReleaseResult: 160 """Remove a release block for a connector by deleting its `block-release.yaml` marker. 161 162 Triggers the `block-release.yml` workflow with action=unblock, which removes 163 the marker file, opens a PR, and force-merges it to master. After this, the 164 connector can be published normally again. 165 """ 166 token = resolve_ci_trigger_github_token() 167 168 result = trigger_workflow_dispatch( 169 owner=AIRBYTE_REPO_OWNER, 170 repo=AIRBYTE_REPO_NAME, 171 workflow_file=BLOCK_RELEASE_WORKFLOW_FILE, 172 ref=resolve_default_workflow_branch(DEFAULT_REF), 173 inputs={ 174 "connector-name": connector_name, 175 "action": "unblock", 176 }, 177 token=token, 178 find_run=True, 179 ) 180 181 if result.run_id: 182 message = ( 183 f"Successfully triggered release unblock for {connector_name}. " 184 f"Run ID: {result.run_id}" 185 ) 186 else: 187 message = ( 188 f"Successfully triggered release unblock for {connector_name}. " 189 "Run ID not yet available." 190 ) 191 192 return UnblockConnectorReleaseResult( 193 success=True, 194 message=message, 195 workflow_url=result.workflow_url, 196 run_id=result.run_id, 197 run_url=result.run_url, 198 )
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.
201@mcp_tool( 202 read_only=True, 203 idempotent=True, 204 open_world=True, 205) 206def list_blocked_connector_releases( 207 connector_name: Annotated[ 208 str | None, 209 Field( 210 description="Optional connector name to check. If not provided, scans all connectors." 211 ), 212 ] = None, 213 include_details: Annotated[ 214 bool, 215 Field( 216 description="Whether to fetch and parse each marker file for reason and metadata." 217 ), 218 ] = True, 219) -> ListBlockedConnectorsResult: 220 """List connectors that are currently blocked from release. 221 222 Searches the airbyte monorepo for `block-release.yaml` marker files using 223 the GitHub API. Returns a list of blocked connectors, with marker metadata 224 when `include_details` is `True`. 225 """ 226 227 token = resolve_ci_trigger_github_token() 228 ref = resolve_default_workflow_branch(DEFAULT_REF) 229 blocked: list[dict] = [] 230 231 if connector_name: 232 blocked = _check_single_connector_block(connector_name, token, ref) 233 else: 234 blocked = _search_all_blocked_connectors(token, ref, include_details) 235 236 return ListBlockedConnectorsResult( 237 blocked_connectors=blocked, 238 count=len(blocked), 239 )
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.
345def register_release_block_tools(app: FastMCP) -> None: 346 """Register release block tools with the FastMCP app.""" 347 register_mcp_tools(app, mcp_module=__name__)
Register release block tools with the FastMCP app.