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__)
AIRBYTE_REPO_OWNER = $GITHUB_REPOSITORY_OWNER
AIRBYTE_REPO_NAME = 'airbyte'
BLOCK_RELEASE_WORKFLOW_FILE = 'block-release.yml'
DEFAULT_REF = 'master'
class BlockConnectorReleaseResult(pydantic.main.BaseModel):
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.

success: bool = PydanticUndefined

Whether the workflow dispatch succeeded

message: str = PydanticUndefined

Human-readable result message

workflow_url: str = PydanticUndefined

URL to the workflow

run_id: int | None = None

Workflow run ID, if discovered

run_url: str | None = None

URL to the workflow run

class UnblockConnectorReleaseResult(pydantic.main.BaseModel):
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.

success: bool = PydanticUndefined

Whether the workflow dispatch succeeded

message: str = PydanticUndefined

Human-readable result message

workflow_url: str = PydanticUndefined

URL to the workflow

run_id: int | None = None

Workflow run ID, if discovered

run_url: str | None = None

URL to the workflow run

class ListBlockedConnectorsResult(pydantic.main.BaseModel):
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.

blocked_connectors: list[dict] = PydanticUndefined

List of blocked connectors with optional block metadata

count: int = 0

Number of blocked connectors

@mcp_tool(read_only=False, idempotent=False, open_world=True)
def block_connector_release( connector_name: typing.Annotated[str, FieldInfo(annotation=NoneType, required=True, description='Connector technical name (e.g., `source-faker`, `destination-postgres`)')], reason: typing.Annotated[str, FieldInfo(annotation=NoneType, required=True, description='Human-readable reason for blocking the release')], yanked_version: typing.Annotated[str | None, FieldInfo(annotation=NoneType, required=True, description='Version that was yanked (for reference)')] = None, blocked_by: typing.Annotated[str | None, FieldInfo(annotation=NoneType, required=True, description='Email or identifier of the person requesting the block')] = None) -> BlockConnectorReleaseResult:
 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.

@mcp_tool(read_only=False, idempotent=False, open_world=True)
def unblock_connector_release( connector_name: typing.Annotated[str, FieldInfo(annotation=NoneType, required=True, description='Connector technical name (e.g., `source-faker`, `destination-postgres`)')]) -> UnblockConnectorReleaseResult:
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.

@mcp_tool(read_only=True, idempotent=True, open_world=True)
def list_blocked_connector_releases( connector_name: typing.Annotated[str | None, FieldInfo(annotation=NoneType, required=True, description='Optional connector name to check. If not provided, scans all connectors.')] = None, include_details: typing.Annotated[bool, FieldInfo(annotation=NoneType, required=True, description='Whether to fetch and parse each marker file for reason and metadata.')] = True) -> ListBlockedConnectorsResult:
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.

def register_release_block_tools(app: fastmcp.server.server.FastMCP) -> None:
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.