airbyte_ops_mcp.mcp.github_ops
MCP tools for GitHub operations: CI workflow triggering/status, Docker image info, and issue/PR subscriptions.
MCP reference
MCP primitives registered by the github_ops module of the airbyte-internal-ops server: 6 tool(s), 0 prompt(s), 0 resource(s).
Tools (6)
check_ci_workflow_status
Hints: read-only · idempotent · open-world
Check the status of a GitHub Actions workflow run.
You can provide either:
- A full workflow URL (workflow_url parameter), OR
- The component parts (owner, repo, run_id parameters)
Returns the current status, conclusion, and other details about the workflow run.
Uses the CI trigger token (GITHUB_CI_WORKFLOW_TRIGGER_PAT) so that workflow runs in private repositories are accessible.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
workflow_url |
string | null |
no | null |
Full GitHub Actions workflow run URL (e.g., 'https://github.com/owner/repo/actions/runs/12345') |
owner |
string | null |
no | null |
Repository owner (e.g., 'airbytehq') |
repo |
string | null |
no | null |
Repository name (e.g., 'airbyte') |
run_id |
integer | null |
no | null |
Workflow run ID |
Show input JSON schema
{
"additionalProperties": false,
"properties": {
"workflow_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Full GitHub Actions workflow run URL (e.g., 'https://github.com/owner/repo/actions/runs/12345')"
},
"owner": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Repository owner (e.g., 'airbytehq')"
},
"repo": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Repository name (e.g., 'airbyte')"
},
"run_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "Workflow run ID"
}
},
"type": "object"
}
Show output JSON schema
{
"description": "Response model for check_ci_workflow_status MCP tool.",
"properties": {
"run_id": {
"type": "integer"
},
"status": {
"type": "string"
},
"conclusion": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workflow_name": {
"type": "string"
},
"head_branch": {
"type": "string"
},
"head_sha": {
"type": "string"
},
"html_url": {
"type": "string"
},
"created_at": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"run_started_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
},
"jobs_url": {
"type": "string"
},
"jobs": {
"default": [],
"items": {
"description": "Information about a single job in a workflow run.",
"properties": {
"job_id": {
"type": "integer"
},
"name": {
"type": "string"
},
"status": {
"type": "string"
},
"conclusion": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
},
"started_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
},
"completed_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
}
},
"required": [
"job_id",
"name",
"status"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"run_id",
"status",
"conclusion",
"workflow_name",
"head_branch",
"head_sha",
"html_url",
"created_at",
"updated_at",
"jobs_url"
],
"type": "object"
}
get_docker_image_info
Hints: read-only · idempotent · open-world
Check if a Docker image exists on DockerHub.
Returns information about the image if it exists, or indicates if it doesn't exist. This is useful for confirming that a pre-release connector was successfully published.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
image |
string |
yes | — | Docker image name (e.g., 'airbyte/source-github') |
tag |
string |
yes | — | Image tag (e.g., '2.1.5-preview.abc1234') |
Show input JSON schema
{
"additionalProperties": false,
"properties": {
"image": {
"description": "Docker image name (e.g., 'airbyte/source-github')",
"type": "string"
},
"tag": {
"description": "Image tag (e.g., '2.1.5-preview.abc1234')",
"type": "string"
}
},
"required": [
"image",
"tag"
],
"type": "object"
}
Show output JSON schema
{
"description": "Response model for get_docker_image_info MCP tool.",
"properties": {
"exists": {
"type": "boolean"
},
"image": {
"type": "string"
},
"tag": {
"type": "string"
},
"full_name": {
"type": "string"
},
"digest": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
},
"last_updated": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
},
"size_bytes": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null
},
"architecture": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
},
"os": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
}
},
"required": [
"exists",
"image",
"tag",
"full_name"
],
"type": "object"
}
list_github_subscriptions
Hints: read-only · idempotent · open-world
List all active GitHub issue/PR subscriptions for this session.
Returns the list of GitHub issues and PRs that this session is currently subscribed to, along with their expiry times.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
agent_session_url |
string |
yes | — | Your Devin session URL. Use the session URL from your system prompt. |
Show input JSON schema
{
"additionalProperties": false,
"properties": {
"agent_session_url": {
"description": "Your Devin session URL. Use the session URL from your system prompt.",
"type": "string"
}
},
"required": [
"agent_session_url"
],
"type": "object"
}
Show output JSON schema
{
"description": "Response from the list_github_subscriptions tool.",
"properties": {
"success": {
"description": "Whether the listing was successful",
"type": "boolean"
},
"message": {
"description": "Human-readable status message",
"type": "string"
},
"subscriptions": {
"description": "List of active subscriptions with id, github_url, expires_at",
"items": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"type": "array"
}
},
"required": [
"success",
"message"
],
"type": "object"
}
subscribe_to_github_issue
Hints: idempotent · open-world
Subscribe to notifications on a GitHub issue or pull request.
Creates a subscription that will deliver real-time notifications back to your Devin session when activity occurs on the specified GitHub issue or PR. Notifications are triggered by GitHub webhooks and delivered within seconds.
If you are already subscribed to the same issue/PR, the subscription is updated (TTL extended, watch events merged).
Use this tool when you need to monitor a GitHub issue or PR for changes, new comments, merges, closures, or other activity.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
github_url |
string |
yes | — | The GitHub issue or PR URL to subscribe to. Examples: https://github.com/airbytehq/airbyte/issues/123 or https://github.com/airbytehq/airbyte/pull/456 |
agent_session_url |
string |
yes | — | Your Devin session URL so notifications can be delivered back to your session. Use the session URL from your system prompt. |
watch_events |
array<string> | null |
no | null |
Optional list of event types to watch. Valid values: 'comment', 'close', 'merge', 'reopen', 'label', 'synchronize', 'ready_for_review', 'assigned'. Defaults to all events if not specified. |
ttl_hours |
integer |
no | 240 |
Number of hours until the subscription expires. Default is 240 (10 days). |
slack_users_cc |
string | null |
no | null |
Optional comma-delimited list of Slack user tags to CC on notifications. Example: '<@U12345>, <@U67890>'. |
Show input JSON schema
{
"additionalProperties": false,
"properties": {
"github_url": {
"description": "The GitHub issue or PR URL to subscribe to. Examples: https://github.com/airbytehq/airbyte/issues/123 or https://github.com/airbytehq/airbyte/pull/456",
"type": "string"
},
"agent_session_url": {
"description": "Your Devin session URL so notifications can be delivered back to your session. Use the session URL from your system prompt.",
"type": "string"
},
"watch_events": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional list of event types to watch. Valid values: 'comment', 'close', 'merge', 'reopen', 'label', 'synchronize', 'ready_for_review', 'assigned'. Defaults to all events if not specified."
},
"ttl_hours": {
"default": 240,
"description": "Number of hours until the subscription expires. Default is 240 (10 days).",
"type": "integer"
},
"slack_users_cc": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional comma-delimited list of Slack user tags to CC on notifications. Example: '<@U12345>, <@U67890>'."
}
},
"required": [
"github_url",
"agent_session_url"
],
"type": "object"
}
Show output JSON schema
{
"description": "Response from the subscribe_to_github_issue tool.",
"properties": {
"success": {
"description": "Whether the subscription was created successfully",
"type": "boolean"
},
"message": {
"description": "Human-readable status message",
"type": "string"
},
"subscription_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "ID of the created or updated subscription"
},
"github_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "GitHub URL being watched"
},
"expires_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "When the subscription expires (ISO 8601)"
}
},
"required": [
"success",
"message"
],
"type": "object"
}
trigger_ci_workflow
Hints: open-world
Trigger a GitHub Actions CI workflow via workflow_dispatch.
This tool triggers a workflow in any GitHub repository that has workflow_dispatch enabled. It resolves PR numbers to branch names automatically since GitHub's workflow_dispatch API only accepts branch names, not refs/pull/{pr}/head format.
Requires GITHUB_CI_WORKFLOW_TRIGGER_PAT or GITHUB_TOKEN environment variable with 'actions:write' permission.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
owner |
string |
yes | — | Repository owner (e.g., 'airbytehq') |
repo |
string |
yes | — | Repository name (e.g., 'airbyte') |
workflow_file |
string |
yes | — | Workflow file name (e.g., 'connector-regression-test.yml') |
workflow_definition_ref |
string | null |
no | null |
Branch name or PR number for the workflow definition to use. If a PR number (integer string) is provided, it resolves to the PR's head branch name. If a branch name is provided, it is used directly. Defaults to 'main' if not specified, or AIRBYTE_OPS_DEFAULT_WORKFLOW_BRANCH_OVERRIDE when set for local testing. |
inputs |
object | null |
no | null |
Workflow inputs as a dictionary of string key-value pairs. These are passed to the workflow_dispatch event. |
Show input JSON schema
{
"additionalProperties": false,
"properties": {
"owner": {
"description": "Repository owner (e.g., 'airbytehq')",
"type": "string"
},
"repo": {
"description": "Repository name (e.g., 'airbyte')",
"type": "string"
},
"workflow_file": {
"description": "Workflow file name (e.g., 'connector-regression-test.yml')",
"type": "string"
},
"workflow_definition_ref": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Branch name or PR number for the workflow definition to use. If a PR number (integer string) is provided, it resolves to the PR's head branch name. If a branch name is provided, it is used directly. Defaults to 'main' if not specified, or AIRBYTE_OPS_DEFAULT_WORKFLOW_BRANCH_OVERRIDE when set for local testing."
},
"inputs": {
"anyOf": [
{
"additionalProperties": {
"type": "string"
},
"type": "object"
},
{
"type": "null"
}
],
"default": null,
"description": "Workflow inputs as a dictionary of string key-value pairs. These are passed to the workflow_dispatch event."
}
},
"required": [
"owner",
"repo",
"workflow_file"
],
"type": "object"
}
Show output JSON schema
{
"description": "Response model for trigger_ci_workflow MCP tool.",
"properties": {
"success": {
"type": "boolean"
},
"message": {
"type": "string"
},
"workflow_url": {
"type": "string"
},
"run_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null
},
"run_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
}
},
"required": [
"success",
"message",
"workflow_url"
],
"type": "object"
}
unsubscribe_from_github_issue
Hints: idempotent · open-world
Unsubscribe from notifications on a GitHub issue or pull request.
Removes an active subscription so you will no longer receive notifications for the specified issue/PR.
You can unsubscribe by:
- Providing a specific subscription_id
- Providing a github_url + session_url to unsubscribe from that specific issue/PR
- Providing only session_url to unsubscribe from all issues/PRs
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
agent_session_url |
string |
yes | — | Your Devin session URL. Use the session URL from your system prompt. |
github_url |
string | null |
no | null |
The GitHub issue or PR URL to unsubscribe from. If not provided, all subscriptions for this session are removed. |
subscription_id |
string | null |
no | null |
Optional specific subscription ID to remove. Use this if you know the exact subscription to cancel. |
Show input JSON schema
{
"additionalProperties": false,
"properties": {
"agent_session_url": {
"description": "Your Devin session URL. Use the session URL from your system prompt.",
"type": "string"
},
"github_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "The GitHub issue or PR URL to unsubscribe from. If not provided, all subscriptions for this session are removed."
},
"subscription_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional specific subscription ID to remove. Use this if you know the exact subscription to cancel."
}
},
"required": [
"agent_session_url"
],
"type": "object"
}
Show output JSON schema
{
"description": "Response from the unsubscribe_from_github_issue tool.",
"properties": {
"success": {
"description": "Whether the unsubscribe was successful",
"type": "boolean"
},
"message": {
"description": "Human-readable status message",
"type": "string"
},
"deleted_count": {
"default": 0,
"description": "Number of subscriptions removed",
"type": "integer"
}
},
"required": [
"success",
"message"
],
"type": "object"
}
1# Copyright (c) 2025 Airbyte, Inc., all rights reserved. 2"""MCP tools for GitHub operations: CI workflow triggering/status, Docker image info, and issue/PR subscriptions. 3 4## MCP reference 5 6.. include:: ../../../docs/mcp-generated/github_ops.md 7 :start-line: 2 8""" 9 10from __future__ import annotations 11 12__all__: list[str] = [] 13 14import logging 15import os 16import re 17from typing import Annotated 18 19import requests 20from fastmcp import FastMCP 21from fastmcp_extensions import mcp_tool, register_mcp_tools 22from pydantic import BaseModel, Field 23 24from airbyte_ops_mcp.github_actions import ( 25 get_workflow_jobs, 26 resolve_default_workflow_branch, 27 trigger_workflow_dispatch, 28) 29from airbyte_ops_mcp.github_api import ( 30 GITHUB_API_BASE, 31 get_pr_head_ref, 32 resolve_ci_trigger_github_token, 33) 34 35DOCKERHUB_API_BASE = "https://hub.docker.com/v2" 36 37 38class JobInfo(BaseModel): 39 """Information about a single job in a workflow run.""" 40 41 job_id: int 42 name: str 43 status: str 44 conclusion: str | None = None 45 started_at: str | None = None 46 completed_at: str | None = None 47 48 49class WorkflowRunStatus(BaseModel): 50 """Response model for check_ci_workflow_status MCP tool.""" 51 52 run_id: int 53 status: str 54 conclusion: str | None 55 workflow_name: str 56 head_branch: str 57 head_sha: str 58 html_url: str 59 created_at: str 60 updated_at: str 61 run_started_at: str | None = None 62 jobs_url: str 63 jobs: list[JobInfo] = [] 64 65 66def _parse_workflow_url(url: str) -> tuple[str, str, int]: 67 """Parse a GitHub Actions workflow run URL into components. 68 69 Args: 70 url: GitHub Actions workflow run URL 71 (e.g., "https://github.com/owner/repo/actions/runs/12345") 72 73 Returns: 74 Tuple of (owner, repo, run_id) 75 76 Raises: 77 ValueError: If URL format is invalid. 78 """ 79 pattern = r"https://github\.com/([^/]+)/([^/]+)/actions/runs/(\d+)" 80 match = re.match(pattern, url) 81 if not match: 82 raise ValueError( 83 f"Invalid workflow URL format: {url}. " 84 "Expected format: https://github.com/owner/repo/actions/runs/12345" 85 ) 86 return match.group(1), match.group(2), int(match.group(3)) 87 88 89def _get_workflow_run( 90 owner: str, 91 repo: str, 92 run_id: int, 93 token: str, 94) -> dict: 95 """Get workflow run details from GitHub API. 96 97 Args: 98 owner: Repository owner (e.g., "airbytehq") 99 repo: Repository name (e.g., "airbyte") 100 run_id: Workflow run ID 101 token: GitHub API token 102 103 Returns: 104 Workflow run data dictionary. 105 106 Raises: 107 ValueError: If workflow run not found. 108 requests.HTTPError: If API request fails. 109 """ 110 url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/actions/runs/{run_id}" 111 headers = { 112 "Authorization": f"Bearer {token}", 113 "Accept": "application/vnd.github+json", 114 "X-GitHub-Api-Version": "2022-11-28", 115 } 116 117 response = requests.get(url, headers=headers, timeout=30) 118 if response.status_code == 404: 119 raise ValueError(f"Workflow run {owner}/{repo}/actions/runs/{run_id} not found") 120 response.raise_for_status() 121 122 return response.json() 123 124 125@mcp_tool( 126 read_only=True, 127 idempotent=True, 128 open_world=True, 129) 130def check_ci_workflow_status( 131 workflow_url: Annotated[ 132 str | None, 133 Field( 134 description="Full GitHub Actions workflow run URL (e.g., 'https://github.com/owner/repo/actions/runs/12345')" 135 ), 136 ] = None, 137 owner: Annotated[ 138 str | None, 139 Field(description="Repository owner (e.g., 'airbytehq')"), 140 ] = None, 141 repo: Annotated[ 142 str | None, 143 Field(description="Repository name (e.g., 'airbyte')"), 144 ] = None, 145 run_id: Annotated[ 146 int | None, 147 Field(description="Workflow run ID"), 148 ] = None, 149) -> WorkflowRunStatus: 150 """Check the status of a GitHub Actions workflow run. 151 152 You can provide either: 153 - A full workflow URL (workflow_url parameter), OR 154 - The component parts (owner, repo, run_id parameters) 155 156 Returns the current status, conclusion, and other details about the workflow run. 157 158 Uses the CI trigger token (GITHUB_CI_WORKFLOW_TRIGGER_PAT) so that 159 workflow runs in private repositories are accessible. 160 """ 161 # Guard: Validate input parameters 162 if workflow_url: 163 # Parse URL to get components 164 owner, repo, run_id = _parse_workflow_url(workflow_url) 165 elif owner and repo and run_id: 166 # Use provided components 167 pass 168 else: 169 raise ValueError( 170 "Must provide either workflow_url OR all of (owner, repo, run_id)" 171 ) 172 173 # Guard: Check for required token 174 # Use the CI trigger token (same as trigger_ci_workflow) so that 175 # private-repo workflow runs are accessible. 176 token = resolve_ci_trigger_github_token() 177 178 # Get workflow run details 179 run_data = _get_workflow_run(owner, repo, run_id, token) 180 181 # Get jobs for the workflow run, passing the same token 182 workflow_jobs = get_workflow_jobs(owner, repo, run_id, token=token) 183 184 # Convert dataclass objects to Pydantic models for the response 185 jobs = [ 186 JobInfo( 187 job_id=job.job_id, 188 name=job.name, 189 status=job.status, 190 conclusion=job.conclusion, 191 started_at=job.started_at, 192 completed_at=job.completed_at, 193 ) 194 for job in workflow_jobs 195 ] 196 197 return WorkflowRunStatus( 198 run_id=run_data["id"], 199 status=run_data["status"], 200 conclusion=run_data["conclusion"], 201 workflow_name=run_data["name"], 202 head_branch=run_data["head_branch"], 203 head_sha=run_data["head_sha"], 204 html_url=run_data["html_url"], 205 created_at=run_data["created_at"], 206 updated_at=run_data["updated_at"], 207 run_started_at=run_data.get("run_started_at"), 208 jobs_url=run_data["jobs_url"], 209 jobs=jobs, 210 ) 211 212 213class TriggerCIWorkflowResult(BaseModel): 214 """Response model for trigger_ci_workflow MCP tool.""" 215 216 success: bool 217 message: str 218 workflow_url: str 219 run_id: int | None = None 220 run_url: str | None = None 221 222 223@mcp_tool( 224 read_only=False, 225 idempotent=False, 226 open_world=True, 227) 228def trigger_ci_workflow( 229 owner: Annotated[ 230 str, 231 Field(description="Repository owner (e.g., 'airbytehq')"), 232 ], 233 repo: Annotated[ 234 str, 235 Field(description="Repository name (e.g., 'airbyte')"), 236 ], 237 workflow_file: Annotated[ 238 str, 239 Field(description="Workflow file name (e.g., 'connector-regression-test.yml')"), 240 ], 241 workflow_definition_ref: Annotated[ 242 str | None, 243 Field( 244 description="Branch name or PR number for the workflow definition to use. " 245 "If a PR number (integer string) is provided, it resolves to the PR's head branch name. " 246 "If a branch name is provided, it is used directly. " 247 "Defaults to 'main' if not specified, " 248 "or AIRBYTE_OPS_DEFAULT_WORKFLOW_BRANCH_OVERRIDE when set for local testing." 249 ), 250 ] = None, 251 inputs: Annotated[ 252 dict[str, str] | None, 253 Field( 254 description="Workflow inputs as a dictionary of string key-value pairs. " 255 "These are passed to the workflow_dispatch event." 256 ), 257 ] = None, 258) -> TriggerCIWorkflowResult: 259 """Trigger a GitHub Actions CI workflow via workflow_dispatch. 260 261 This tool triggers a workflow in any GitHub repository that has workflow_dispatch 262 enabled. It resolves PR numbers to branch names automatically since GitHub's 263 workflow_dispatch API only accepts branch names, not refs/pull/{pr}/head format. 264 265 Requires GITHUB_CI_WORKFLOW_TRIGGER_PAT or GITHUB_TOKEN environment variable 266 with 'actions:write' permission. 267 """ 268 # Guard: Check for required token 269 token = resolve_ci_trigger_github_token() 270 271 if workflow_definition_ref: 272 if workflow_definition_ref.isdigit(): 273 pr_head_info = get_pr_head_ref( 274 owner, 275 repo, 276 int(workflow_definition_ref), 277 token, 278 ) 279 resolved_ref = pr_head_info.ref 280 else: 281 resolved_ref = workflow_definition_ref 282 else: 283 resolved_ref = resolve_default_workflow_branch("main") 284 285 # Trigger the workflow 286 result = trigger_workflow_dispatch( 287 owner=owner, 288 repo=repo, 289 workflow_file=workflow_file, 290 ref=resolved_ref, 291 inputs=inputs or {}, 292 token=token, 293 find_run=True, 294 ) 295 296 # Build response message 297 if result.run_id: 298 message = f"Successfully triggered workflow {workflow_file} on {owner}/{repo} (ref: {resolved_ref}). Run ID: {result.run_id}" 299 else: 300 message = f"Successfully triggered workflow {workflow_file} on {owner}/{repo} (ref: {resolved_ref}). Run ID not yet available." 301 302 return TriggerCIWorkflowResult( 303 success=True, 304 message=message, 305 workflow_url=result.workflow_url, 306 run_id=result.run_id, 307 run_url=result.run_url, 308 ) 309 310 311class DockerImageInfo(BaseModel): 312 """Response model for get_docker_image_info MCP tool.""" 313 314 exists: bool 315 image: str 316 tag: str 317 full_name: str 318 digest: str | None = None 319 last_updated: str | None = None 320 size_bytes: int | None = None 321 architecture: str | None = None 322 os: str | None = None 323 324 325def _check_dockerhub_image( 326 image: str, 327 tag: str, 328) -> dict | None: 329 """Check if a Docker image tag exists on DockerHub. 330 331 Args: 332 image: Docker image name (e.g., "airbyte/source-github") 333 tag: Image tag (e.g., "2.1.5-preview.abc1234") 334 335 Returns: 336 Tag data dictionary if found, None if not found. 337 """ 338 # DockerHub API endpoint for tag info 339 url = f"{DOCKERHUB_API_BASE}/repositories/{image}/tags/{tag}" 340 341 response = requests.get(url, timeout=30) 342 if response.status_code == 404: 343 return None 344 response.raise_for_status() 345 346 return response.json() 347 348 349@mcp_tool( 350 read_only=True, 351 idempotent=True, 352 open_world=True, 353) 354def get_docker_image_info( 355 image: Annotated[ 356 str, 357 Field(description="Docker image name (e.g., 'airbyte/source-github')"), 358 ], 359 tag: Annotated[ 360 str, 361 Field(description="Image tag (e.g., '2.1.5-preview.abc1234')"), 362 ], 363) -> DockerImageInfo: 364 """Check if a Docker image exists on DockerHub. 365 366 Returns information about the image if it exists, or indicates if it doesn't exist. 367 This is useful for confirming that a pre-release connector was successfully published. 368 """ 369 full_name = f"{image}:{tag}" 370 tag_data = _check_dockerhub_image(image, tag) 371 372 if not tag_data: 373 return DockerImageInfo( 374 exists=False, 375 image=image, 376 tag=tag, 377 full_name=full_name, 378 ) 379 380 # Extract image details from the first image in the list (if available) 381 images = tag_data.get("images", []) 382 first_image = images[0] if images else {} 383 384 return DockerImageInfo( 385 exists=True, 386 image=image, 387 tag=tag, 388 full_name=full_name, 389 digest=tag_data.get("digest"), 390 last_updated=tag_data.get("last_updated"), 391 size_bytes=first_image.get("size"), 392 architecture=first_image.get("architecture"), 393 os=first_image.get("os"), 394 ) 395 396 397logger = logging.getLogger(__name__) 398 399SUBSCRIPTION_API_URL_ENV = "SUBSCRIPTION_API_URL" 400 401SUBSCRIPTION_API_TOKEN_ENV = "SUBSCRIPTION_API_BEARER_TOKEN" 402 403 404def _get_api_url() -> str: 405 """Get the subscription API base URL.""" 406 url = os.environ.get(SUBSCRIPTION_API_URL_ENV) 407 if not url: 408 raise ValueError( 409 f"{SUBSCRIPTION_API_URL_ENV} environment variable is not set. " 410 "Cannot reach the GitHub subscriptions backend." 411 ) 412 return url.rstrip("/") 413 414 415def _get_api_token() -> str: 416 """Get the subscription API bearer token.""" 417 token = os.environ.get(SUBSCRIPTION_API_TOKEN_ENV) 418 if not token: 419 raise ValueError( 420 f"{SUBSCRIPTION_API_TOKEN_ENV} environment variable is not set. " 421 "Cannot authenticate to the GitHub subscriptions backend." 422 ) 423 return token 424 425 426def _api_headers() -> dict[str, str]: 427 """Build headers for API requests.""" 428 return { 429 "Authorization": f"Bearer {_get_api_token()}", 430 "Content-Type": "application/json", 431 } 432 433 434class SubscribeResponse(BaseModel): 435 """Response from the subscribe_to_github_issue tool.""" 436 437 success: bool = Field( 438 description="Whether the subscription was created successfully" 439 ) 440 message: str = Field(description="Human-readable status message") 441 subscription_id: str | None = Field( 442 default=None, 443 description="ID of the created or updated subscription", 444 ) 445 github_url: str | None = Field( 446 default=None, 447 description="GitHub URL being watched", 448 ) 449 expires_at: str | None = Field( 450 default=None, 451 description="When the subscription expires (ISO 8601)", 452 ) 453 454 455class UnsubscribeResponse(BaseModel): 456 """Response from the unsubscribe_from_github_issue tool.""" 457 458 success: bool = Field(description="Whether the unsubscribe was successful") 459 message: str = Field(description="Human-readable status message") 460 deleted_count: int = Field( 461 default=0, 462 description="Number of subscriptions removed", 463 ) 464 465 466class ListSubscriptionsResponse(BaseModel): 467 """Response from the list_github_subscriptions tool.""" 468 469 success: bool = Field(description="Whether the listing was successful") 470 message: str = Field(description="Human-readable status message") 471 subscriptions: list[dict[str, str]] = Field( 472 default_factory=list, 473 description="List of active subscriptions with id, github_url, expires_at", 474 ) 475 476 477@mcp_tool( 478 read_only=False, 479 idempotent=True, 480 open_world=True, 481) 482def subscribe_to_github_issue( 483 github_url: Annotated[ 484 str, 485 "The GitHub issue or PR URL to subscribe to. " 486 "Examples: https://github.com/airbytehq/airbyte/issues/123 " 487 "or https://github.com/airbytehq/airbyte/pull/456", 488 ], 489 agent_session_url: Annotated[ 490 str, 491 "Your Devin session URL so notifications can be delivered back to " 492 "your session. Use the session URL from your system prompt.", 493 ], 494 watch_events: Annotated[ 495 list[str] | None, 496 "Optional list of event types to watch. Valid values: " 497 "'comment', 'close', 'merge', 'reopen', 'label', 'synchronize', " 498 "'ready_for_review', 'assigned'. Defaults to all events if not specified.", 499 ] = None, 500 ttl_hours: Annotated[ 501 int, 502 "Number of hours until the subscription expires. Default is 240 (10 days).", 503 ] = 240, 504 slack_users_cc: Annotated[ 505 str | None, 506 "Optional comma-delimited list of Slack user tags to CC on " 507 "notifications. Example: '<@U12345>, <@U67890>'.", 508 ] = None, 509) -> SubscribeResponse: 510 """Subscribe to notifications on a GitHub issue or pull request. 511 512 Creates a subscription that will deliver real-time notifications back 513 to your Devin session when activity occurs on the specified GitHub 514 issue or PR. Notifications are triggered by GitHub webhooks and 515 delivered within seconds. 516 517 If you are already subscribed to the same issue/PR, the subscription 518 is updated (TTL extended, watch events merged). 519 520 Use this tool when you need to monitor a GitHub issue or PR for 521 changes, new comments, merges, closures, or other activity. 522 """ 523 try: 524 api_url = _get_api_url() 525 body: dict[str, str | list[str] | int | None] = { 526 "github_url": github_url, 527 "session_url": agent_session_url, 528 "ttl_hours": ttl_hours, 529 } 530 if watch_events: 531 body["watch_events"] = watch_events 532 if slack_users_cc: 533 body["slack_users_cc"] = slack_users_cc 534 535 response = requests.post( 536 f"{api_url}/subscriptions", 537 json=body, 538 headers=_api_headers(), 539 timeout=10, 540 ) 541 response.raise_for_status() 542 data = response.json() 543 544 return SubscribeResponse( 545 success=True, 546 message=( 547 f"Subscribed to {github_url}. " 548 f"You will receive notifications in this session until " 549 f"{data.get('expires_at', 'expiry unknown')}." 550 ), 551 subscription_id=data.get("id"), 552 github_url=github_url, 553 expires_at=data.get("expires_at"), 554 ) 555 556 except ValueError as e: 557 return SubscribeResponse( 558 success=False, 559 message=f"Configuration error: {e}", 560 ) 561 except requests.RequestException as e: 562 logger.exception("Failed to create subscription") 563 return SubscribeResponse( 564 success=False, 565 message=f"Failed to create subscription: {e}", 566 ) 567 568 569@mcp_tool( 570 read_only=False, 571 idempotent=True, 572 open_world=True, 573) 574def unsubscribe_from_github_issue( 575 agent_session_url: Annotated[ 576 str, 577 "Your Devin session URL. Use the session URL from your system prompt.", 578 ], 579 github_url: Annotated[ 580 str | None, 581 "The GitHub issue or PR URL to unsubscribe from. " 582 "If not provided, all subscriptions for this session are removed.", 583 ] = None, 584 subscription_id: Annotated[ 585 str | None, 586 "Optional specific subscription ID to remove. " 587 "Use this if you know the exact subscription to cancel.", 588 ] = None, 589) -> UnsubscribeResponse: 590 """Unsubscribe from notifications on a GitHub issue or pull request. 591 592 Removes an active subscription so you will no longer receive 593 notifications for the specified issue/PR. 594 595 You can unsubscribe by: 596 - Providing a specific subscription_id 597 - Providing a github_url + session_url to unsubscribe from that specific issue/PR 598 - Providing only session_url to unsubscribe from all issues/PRs 599 """ 600 try: 601 api_url = _get_api_url() 602 603 if subscription_id: 604 # Delete by ID 605 response = requests.delete( 606 f"{api_url}/subscriptions/{subscription_id}", 607 headers=_api_headers(), 608 timeout=10, 609 ) 610 else: 611 # Delete by match 612 params: dict[str, str] = {"session_url": agent_session_url} 613 if github_url: 614 params["github_url"] = github_url 615 response = requests.delete( 616 f"{api_url}/subscriptions", 617 params=params, 618 headers=_api_headers(), 619 timeout=10, 620 ) 621 622 response.raise_for_status() 623 data = response.json() 624 count = data.get("deleted_count", 0) 625 626 return UnsubscribeResponse( 627 success=True, 628 message=f"Removed {count} subscription(s).", 629 deleted_count=count, 630 ) 631 632 except ValueError as e: 633 return UnsubscribeResponse( 634 success=False, 635 message=f"Configuration error: {e}", 636 ) 637 except requests.RequestException as e: 638 logger.exception("Failed to unsubscribe") 639 return UnsubscribeResponse( 640 success=False, 641 message=f"Failed to unsubscribe: {e}", 642 ) 643 644 645@mcp_tool( 646 read_only=True, 647 idempotent=True, 648 open_world=True, 649) 650def list_github_subscriptions( 651 agent_session_url: Annotated[ 652 str, 653 "Your Devin session URL. Use the session URL from your system prompt.", 654 ], 655) -> ListSubscriptionsResponse: 656 """List all active GitHub issue/PR subscriptions for this session. 657 658 Returns the list of GitHub issues and PRs that this session is 659 currently subscribed to, along with their expiry times. 660 """ 661 try: 662 api_url = _get_api_url() 663 664 response = requests.get( 665 f"{api_url}/subscriptions", 666 params={"session_url": agent_session_url}, 667 headers=_api_headers(), 668 timeout=10, 669 ) 670 response.raise_for_status() 671 data = response.json() 672 673 subs = [ 674 { 675 "id": s["id"], 676 "github_url": s["github_url"], 677 "watch_events": ", ".join(s.get("watch_events", [])), 678 "expires_at": s.get("expires_at", "unknown"), 679 } 680 for s in data 681 ] 682 683 if not subs: 684 return ListSubscriptionsResponse( 685 success=True, 686 message="No active subscriptions for this session.", 687 subscriptions=[], 688 ) 689 690 return ListSubscriptionsResponse( 691 success=True, 692 message=f"Found {len(subs)} active subscription(s).", 693 subscriptions=subs, 694 ) 695 696 except ValueError as e: 697 return ListSubscriptionsResponse( 698 success=False, 699 message=f"Configuration error: {e}", 700 ) 701 except requests.RequestException as e: 702 logger.exception("Failed to list subscriptions") 703 return ListSubscriptionsResponse( 704 success=False, 705 message=f"Failed to list subscriptions: {e}", 706 ) 707 708 709def register_github_ops_tools(app: FastMCP) -> None: 710 """Register github_ops tools with the FastMCP app.""" 711 register_mcp_tools(app, mcp_module=__name__)