airbyte_ops_mcp.mcp.human_in_the_loop

MCP tools for human-in-the-loop workflows: escalation to a human, team-roster lookup, and Slack newsletter posting.

MCP reference

MCP primitives registered by the human_in_the_loop module of the airbyte-internal-ops server: 4 tool(s), 0 prompt(s), 0 resource(s).

Tools (4)

escalate_to_human

Hints: open-world

Escalate to a human team member via Slack.

Posts a formatted message to the #human-in-the-loop Slack channel, tagging the specified person(s). The message includes clickable buttons for the Devin session, PR, and issue links when provided, plus any additional freeform action buttons.

The Slack message is sent by a GitHub Actions workflow so that Slack credentials are never exposed to the calling agent. The workflow resolves person identifiers (email, GitHub handle, or Slack ID) to Slack user IDs using the internal team roster.

Use this tool when you need human input, approval, or help that you cannot resolve on your own.

Parameters:

Name Type Required Default Description
target_person string yes โ€” Primary person to notify. Accepts an email address (e.g. 'aj@airbyte.io'), a GitHub handle prefixed with @ (e.g. '@aaronsteers'), or a Slack user ID (e.g. 'U05AKF1BCC9').
message string yes โ€” The message body to deliver to the human. Format using Slack mrkdwn: bold, _italic_, code, code blocks, > blockquotes, - bullet lists, and links. Should clearly explain what you need help with or what decision is required.
agent_session_url string yes โ€” Your agent session URL so the human can view the full context. Use the session URL from your system prompt.
cc array<string> | null no null Optional list of additional people to tag on the message. Each entry uses the same identifier format as target_person.
pr_url string | null no null Optional URL to a related pull request for the 'View PR' button.
issue_url string | null no null Optional URL to a related GitHub issue for the 'View Issue' button.
additional_actions object | null no null Optional dictionary of label -> URL pairs for extra action buttons. Example: {'Start Workflow': 'https://github.com/...actions/...'}.
approval_requested boolean no false Set to True to add 'Approve' and 'Reject' buttons that post back to the Slack app. Each button includes a confirmation dialog (if approval_request_summary is provided). When either button is clicked, both buttons morph into non-interactive status text (e.g. ':white_check_mark: Approved by @user' or ':x: Rejected by @user').
approval_request_summary string | null no null Short description of what the user is approving, shown in a Slack confirmation dialog before the Approve button fires. Rendered as a blockquote. MUST be at most 280 characters; over-limit or unbalanced-backtick inputs are rejected at call time. Keep it to the minimum info the approver needs to identify what they are approving. Example: 'Pinning source-hubspot prerelease 4.5.3-preview to workspace for testing'.
approval_request_detail_url string | null no null Optional URL where the reviewer can read full details of what they are being asked to approve. Rendered as a 'View Details' button in the Slack message.
connector_name string | null no null Optional connector name to display prominently in the Slack message. For example, when combined with request_type='action', the header may be rendered as '๐Ÿ”ง Action Requested โ€” source-salesloft'. If request_type is omitted, the header remains the generic '๐Ÿ™‹ Human-in-the-loop request' and the connector name is still included for additional context. Always provide this when the escalation is about a specific connector.
request_type enum("action", "review", "input", "guidance", "approval", "blocked") | null no null Type of escalation request. Controls the Slack message header emoji and label. Accepted values: 'action' (๐Ÿ”ง Action Requested), 'review' (๐Ÿ‘€ Review Requested), 'input' (โ“ Input Needed), 'guidance' (๐Ÿงญ Guidance Needed), 'approval' (โœ… Approval Requested), 'blocked' (๐Ÿšซ Still Blocked). When omitted, defaults to the generic 'Human-in-the-loop request' header.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "target_person": {
      "description": "Primary person to notify. Accepts an email address (e.g. 'aj@airbyte.io'), a GitHub handle prefixed with @ (e.g. '@aaronsteers'), or a Slack user ID (e.g. 'U05AKF1BCC9').",
      "type": "string"
    },
    "message": {
      "description": "The message body to deliver to the human. Format using Slack mrkdwn: *bold*, _italic_, `code`, ```code blocks```, > blockquotes, - bullet lists, and <url|label> links. Should clearly explain what you need help with or what decision is required.",
      "type": "string"
    },
    "agent_session_url": {
      "description": "Your agent session URL so the human can view the full context. Use the session URL from your system prompt.",
      "type": "string"
    },
    "cc": {
      "anyOf": [
        {
          "items": {
            "type": "string"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional list of additional people to tag on the message. Each entry uses the same identifier format as target_person."
    },
    "pr_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional URL to a related pull request for the 'View PR' button."
    },
    "issue_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional URL to a related GitHub issue for the 'View Issue' button."
    },
    "additional_actions": {
      "anyOf": [
        {
          "additionalProperties": {
            "type": "string"
          },
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional dictionary of label -> URL pairs for extra action buttons. Example: {'Start Workflow': 'https://github.com/...actions/...'}."
    },
    "approval_requested": {
      "default": false,
      "description": "Set to True to add 'Approve' and 'Reject' buttons that post back to the Slack app. Each button includes a confirmation dialog (if approval_request_summary is provided). When either button is clicked, both buttons morph into non-interactive status text (e.g. ':white_check_mark: Approved by @user' or ':x: Rejected by @user').",
      "type": "boolean"
    },
    "approval_request_summary": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Short description of what the user is approving, shown in a Slack confirmation dialog before the Approve button fires. Rendered as a blockquote. MUST be at most 280 characters; over-limit or unbalanced-backtick inputs are rejected at call time. Keep it to the minimum info the approver needs to identify what they are approving. Example: 'Pinning source-hubspot prerelease 4.5.3-preview to workspace for testing'."
    },
    "approval_request_detail_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional URL where the reviewer can read full details of what they are being asked to approve. Rendered as a 'View Details' button in the Slack message."
    },
    "connector_name": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional connector name to display prominently in the Slack message. For example, when combined with request_type='action', the header may be rendered as '\ud83d\udd27 Action Requested \u2014 source-salesloft'. If request_type is omitted, the header remains the generic '\ud83d\ude4b Human-in-the-loop request' and the connector name is still included for additional context. Always provide this when the escalation is about a specific connector."
    },
    "request_type": {
      "anyOf": [
        {
          "description": "Type of escalation request, controlling the Slack header emoji and label.",
          "enum": [
            "action",
            "review",
            "input",
            "guidance",
            "approval",
            "blocked"
          ],
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Type of escalation request. Controls the Slack message header emoji and label. Accepted values: 'action' (\ud83d\udd27 Action Requested), 'review' (\ud83d\udc40 Review Requested), 'input' (\u2753 Input Needed), 'guidance' (\ud83e\udded Guidance Needed), 'approval' (\u2705 Approval Requested), 'blocked' (\ud83d\udeab Still Blocked). When omitted, defaults to the generic 'Human-in-the-loop request' header."
    }
  },
  "required": [
    "target_person",
    "message",
    "agent_session_url"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the human-in-the-loop escalation tool.",
  "properties": {
    "success": {
      "description": "Whether the workflow was triggered successfully",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message",
      "type": "string"
    },
    "slack_channel_url": {
      "default": "https://airbytehq-team.slack.com/archives/C0AEXV81Q7N",
      "description": "Direct URL to the #human-in-the-loop Slack channel",
      "type": "string"
    },
    "workflow_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "URL to view the GitHub Actions workflow file"
    },
    "run_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "GitHub Actions workflow run ID"
    },
    "run_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Direct URL to the GitHub Actions workflow run"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

list_team_roster

Hints: read-only ยท idempotent

List the full Airbyte internal team roster.

Returns all members from the daily-generated roster artifact. The roster is sorted by Slack display name for easy scanning. Use lookup_person for targeted searches instead of scanning the full list.

Parameters:

_No parameters._

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {},
  "type": "object"
}

Show output JSON schema

{
  "description": "Response listing the full internal team roster.",
  "properties": {
    "total_members": {
      "description": "Total number of members in the roster",
      "type": "integer"
    },
    "members": {
      "description": "All person records in the roster",
      "items": {
        "description": "A person in the internal team roster.",
        "properties": {
          "slack_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Slack user ID"
          },
          "slack_display_name": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Slack display name"
          },
          "slack_email": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Email address from Slack profile"
          },
          "github_id": {
            "anyOf": [
              {
                "type": "integer"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "GitHub numeric user ID"
          },
          "github_handle": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "GitHub username"
          },
          "github_public_email": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Public email from GitHub profile"
          }
        },
        "type": "object"
      },
      "type": "array"
    }
  },
  "required": [
    "total_members",
    "members"
  ],
  "type": "object"
}

lookup_person

Hints: read-only ยท idempotent

Look up a person in the Airbyte internal team roster.

Searches the daily-generated roster artifact by any field value. The roster is built from Slack and GitHub org membership data, cross-referenced by email address.

Use this to find someone's Slack ID for messaging, GitHub handle for code review, or to cross-reference identities across platforms.

Parameters:

Name Type Required Default Description
query string yes โ€” Search query to match against any field: email address, Slack display name, Slack user ID, GitHub handle, or GitHub user ID. Case-insensitive partial matching for strings, exact match for numeric IDs.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "query": {
      "description": "Search query to match against any field: email address, Slack display name, Slack user ID, GitHub handle, or GitHub user ID. Case-insensitive partial matching for strings, exact match for numeric IDs.",
      "type": "string"
    }
  },
  "required": [
    "query"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from a people lookup query.",
  "properties": {
    "query": {
      "description": "The search query that was used",
      "type": "string"
    },
    "total_matches": {
      "description": "Number of matching records",
      "type": "integer"
    },
    "matches": {
      "description": "Matching person records",
      "items": {
        "description": "A person in the internal team roster.",
        "properties": {
          "slack_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Slack user ID"
          },
          "slack_display_name": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Slack display name"
          },
          "slack_email": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Email address from Slack profile"
          },
          "github_id": {
            "anyOf": [
              {
                "type": "integer"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "GitHub numeric user ID"
          },
          "github_handle": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "GitHub username"
          },
          "github_public_email": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Public email from GitHub profile"
          }
        },
        "type": "object"
      },
      "type": "array"
    }
  },
  "required": [
    "query",
    "total_matches",
    "matches"
  ],
  "type": "object"
}

post_slack_newsletter

Hints: open-world

Post a formatted newsletter digest to a Slack channel.

Converts Slack mrkdwn text into Block Kit blocks locally (with validation), then dispatches them to a GitHub Actions workflow for posting. The workflow receives finished Block Kit JSON โ€” it does not perform any markdown conversion.

Dry-run modes:

  • local: returns blocks JSON for local rendering โ€” no workflow triggered
  • slack_test_channel: posts to a test channel for formatting verification

Parameters:

Name Type Required Default Description
message_text string yes โ€” The formatted message to post, using Slack mrkdwn syntax: bold, _italic_, code, code blocks, > blockquotes, - bullet lists, and links. Use ## for section headers (translated to Block Kit header blocks) and ### for sub-headers (translated to bold text). Double newlines (\n\n) split the text into separate visual sections in the Slack message. Do NOT include markdown tables โ€” they will be rejected.
newsletter_name string yes โ€” Name of the newsletter to post to. Determines which Slack channel receives the message. Currently only 'Hydra' is supported (posts to #daily-newsletters). Ignored when dry_run is 'slack_test_channel'.
dry_run string no "off" Controls dry-run behaviour. 'off' (default): post to the real channel. 'local': build blocks locally and return blocks JSON plus a Block Kit Builder URL โ€” no workflow is triggered. 'slack_test_channel': trigger the workflow but post to a test channel instead of the production newsletter channel.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "message_text": {
      "description": "The formatted message to post, using Slack mrkdwn syntax: *bold*, _italic_, `code`, ```code blocks```, > blockquotes, - bullet lists, and <url|label> links. Use ## for section headers (translated to Block Kit header blocks) and ### for sub-headers (translated to bold text). Double newlines (\\n\\n) split the text into separate visual sections in the Slack message. Do NOT include markdown tables \u2014 they will be rejected.",
      "type": "string"
    },
    "newsletter_name": {
      "const": "Hydra",
      "description": "Name of the newsletter to post to. Determines which Slack channel receives the message. Currently only 'Hydra' is supported (posts to #daily-newsletters). Ignored when dry_run is 'slack_test_channel'.",
      "type": "string"
    },
    "dry_run": {
      "default": "off",
      "description": "Controls dry-run behaviour. 'off' (default): post to the real channel. 'local': build blocks locally and return blocks JSON plus a Block Kit Builder URL \u2014 no workflow is triggered. 'slack_test_channel': trigger the workflow but post to a test channel instead of the production newsletter channel.",
      "type": "string"
    }
  },
  "required": [
    "message_text",
    "newsletter_name"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the post_slack_newsletter tool.",
  "properties": {
    "success": {
      "description": "Whether the operation completed successfully",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message",
      "type": "string"
    },
    "blocks_json": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Serialised JSON array of Block Kit blocks. Populated in 'local' dry-run mode so the agent can render or inspect the blocks directly."
    },
    "workflow_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "URL to view the GitHub Actions workflow file"
    },
    "run_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "GitHub Actions workflow run ID"
    },
    "channel_name": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Human-readable Slack channel name (e.g. '#daily-newsletters')"
    },
    "run_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Direct URL to the GitHub Actions workflow run"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

  1# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
  2"""MCP tools for human-in-the-loop workflows: escalation to a human, team-roster lookup, and Slack newsletter posting.
  3
  4## MCP reference
  5
  6.. include:: ../../../docs/mcp-generated/human_in_the_loop.md
  7    :start-line: 2
  8"""
  9
 10from __future__ import annotations
 11
 12__all__: list[str] = []
 13
 14import json
 15import logging
 16from enum import Enum, StrEnum
 17from typing import Annotated, Literal
 18
 19from fastmcp import FastMCP
 20from fastmcp_extensions import mcp_tool, register_mcp_tools
 21from pydantic import BaseModel, Field
 22
 23from airbyte_ops_mcp.github_actions import (
 24    WorkflowDispatchResult,
 25    WorkflowRunStatus,
 26    resolve_default_workflow_branch,
 27    trigger_workflow_dispatch,
 28    wait_for_workflow_completion,
 29)
 30from airbyte_ops_mcp.github_api import resolve_ci_trigger_github_token
 31from airbyte_ops_mcp.human_in_the_loop import (
 32    HITL_SLACK_CHANNEL_URL,
 33    dispatch_escalation,
 34)
 35from airbyte_ops_mcp.internal_team_roster import fetch_roster, search_roster
 36from airbyte_ops_mcp.slack_ops.blocks import (
 37    build_blocks,
 38    validate_message,
 39)
 40
 41
 42class PersonRecord(BaseModel):
 43    """A person in the internal team roster."""
 44
 45    slack_id: str | None = Field(default=None, description="Slack user ID")
 46    slack_display_name: str | None = Field(
 47        default=None, description="Slack display name"
 48    )
 49    slack_email: str | None = Field(
 50        default=None, description="Email address from Slack profile"
 51    )
 52    github_id: int | None = Field(default=None, description="GitHub numeric user ID")
 53    github_handle: str | None = Field(default=None, description="GitHub username")
 54    github_public_email: str | None = Field(
 55        default=None, description="Public email from GitHub profile"
 56    )
 57
 58
 59class PeopleLookupResponse(BaseModel):
 60    """Response from a people lookup query."""
 61
 62    query: str = Field(description="The search query that was used")
 63    total_matches: int = Field(description="Number of matching records")
 64    matches: list[PersonRecord] = Field(description="Matching person records")
 65
 66
 67class RosterListResponse(BaseModel):
 68    """Response listing the full internal team roster."""
 69
 70    total_members: int = Field(description="Total number of members in the roster")
 71    members: list[PersonRecord] = Field(description="All person records in the roster")
 72
 73
 74@mcp_tool(
 75    read_only=True,
 76    idempotent=True,
 77)
 78def lookup_person(
 79    query: Annotated[
 80        str,
 81        Field(
 82            description=(
 83                "Search query to match against any field: email address, "
 84                "Slack display name, Slack user ID, GitHub handle, or GitHub user ID. "
 85                "Case-insensitive partial matching for strings, exact match for numeric IDs."
 86            )
 87        ),
 88    ],
 89) -> PeopleLookupResponse:
 90    """Look up a person in the Airbyte internal team roster.
 91
 92    Searches the daily-generated roster artifact by any field value.
 93    The roster is built from Slack and GitHub org membership data,
 94    cross-referenced by email address.
 95
 96    Use this to find someone's Slack ID for messaging, GitHub handle
 97    for code review, or to cross-reference identities across platforms.
 98    """
 99    roster = fetch_roster()
100    matches = search_roster(roster, query)
101    return PeopleLookupResponse(
102        query=query,
103        total_matches=len(matches),
104        matches=[PersonRecord.model_validate(person) for person in matches],
105    )
106
107
108@mcp_tool(
109    read_only=True,
110    idempotent=True,
111)
112def list_team_roster() -> RosterListResponse:
113    """List the full Airbyte internal team roster.
114
115    Returns all members from the daily-generated roster artifact.
116    The roster is sorted by Slack display name for easy scanning.
117    Use lookup_person for targeted searches instead of scanning the full list.
118    """
119    roster = fetch_roster()
120    return RosterListResponse(
121        total_members=len(roster),
122        members=[PersonRecord.model_validate(person) for person in roster],
123    )
124
125
126class RequestType(StrEnum):
127    """Type of escalation request, controlling the Slack header emoji and label."""
128
129    ACTION = "action"
130    REVIEW = "review"
131    INPUT = "input"
132    GUIDANCE = "guidance"
133    APPROVAL = "approval"
134    BLOCKED = "blocked"
135
136
137_REQUEST_TYPE_HEADERS: dict[RequestType, tuple[str, str]] = {
138    RequestType.ACTION: ("๐Ÿ”ง", "Action Requested"),
139    RequestType.REVIEW: ("๐Ÿ‘€", "Review Requested"),
140    RequestType.INPUT: ("โ“", "Input Needed"),
141    RequestType.GUIDANCE: ("๐Ÿงญ", "Guidance Needed"),
142    RequestType.APPROVAL: ("โœ…", "Approval Requested"),
143    RequestType.BLOCKED: ("๐Ÿšซ", "Still Blocked"),
144}
145
146
147class EscalateToHumanResponse(BaseModel):
148    """Response from the human-in-the-loop escalation tool."""
149
150    success: bool = Field(description="Whether the workflow was triggered successfully")
151    message: str = Field(description="Human-readable status message")
152    slack_channel_url: str = Field(
153        default=HITL_SLACK_CHANNEL_URL,
154        description="Direct URL to the #human-in-the-loop Slack channel",
155    )
156    workflow_url: str | None = Field(
157        default=None,
158        description="URL to view the GitHub Actions workflow file",
159    )
160    run_id: int | None = Field(
161        default=None,
162        description="GitHub Actions workflow run ID",
163    )
164    run_url: str | None = Field(
165        default=None,
166        description="Direct URL to the GitHub Actions workflow run",
167    )
168
169
170@mcp_tool(
171    read_only=False,
172    idempotent=False,
173    open_world=True,
174)
175def escalate_to_human(
176    target_person: Annotated[
177        str,
178        "Primary person to notify. Accepts an email address (e.g. 'aj@airbyte.io'), "
179        "a GitHub handle prefixed with @ (e.g. '@aaronsteers'), "
180        "or a Slack user ID (e.g. 'U05AKF1BCC9').",
181    ],
182    message: Annotated[
183        str,
184        "The message body to deliver to the human. Format using Slack mrkdwn: "
185        "*bold*, _italic_, `code`, ```code blocks```, > blockquotes, "
186        "- bullet lists, and <url|label> links. Should clearly explain "
187        "what you need help with or what decision is required.",
188    ],
189    agent_session_url: Annotated[
190        str,
191        "Your agent session URL so the human can view the full context. "
192        "Use the session URL from your system prompt.",
193    ],
194    cc: Annotated[
195        list[str] | None,
196        "Optional list of additional people to tag on the message. "
197        "Each entry uses the same identifier format as target_person.",
198    ] = None,
199    pr_url: Annotated[
200        str | None,
201        "Optional URL to a related pull request for the 'View PR' button.",
202    ] = None,
203    issue_url: Annotated[
204        str | None,
205        "Optional URL to a related GitHub issue for the 'View Issue' button.",
206    ] = None,
207    additional_actions: Annotated[
208        dict[str, str] | None,
209        "Optional dictionary of label -> URL pairs for extra action buttons. "
210        "Example: {'Start Workflow': 'https://github.com/...actions/...'}.",
211    ] = None,
212    approval_requested: Annotated[
213        bool,
214        "Set to True to add 'Approve' and 'Reject' buttons that post back to the Slack app. "
215        "Each button includes a confirmation dialog (if approval_request_summary is provided). "
216        "When either button is clicked, both buttons morph into non-interactive status text "
217        "(e.g. ':white_check_mark: Approved by @user' or ':x: Rejected by @user').",
218    ] = False,
219    approval_request_summary: Annotated[
220        str | None,
221        "Short description of what the user is approving, shown in a Slack "
222        "confirmation dialog before the Approve button fires. Rendered as a "
223        "blockquote. MUST be at most 280 characters; over-limit or "
224        "unbalanced-backtick inputs are rejected at call time. Keep it to the "
225        "minimum info the approver needs to identify what they are approving. "
226        "Example: 'Pinning source-hubspot prerelease 4.5.3-preview to workspace for testing'.",
227    ] = None,
228    approval_request_detail_url: Annotated[
229        str | None,
230        "Optional URL where the reviewer can read full details of what they are "
231        "being asked to approve. Rendered as a 'View Details' button in the Slack message.",
232    ] = None,
233    connector_name: Annotated[
234        str | None,
235        "Optional connector name to display prominently in the Slack message. "
236        "For example, when combined with request_type='action', the header may be "
237        "rendered as '๐Ÿ”ง Action Requested โ€” source-salesloft'. If request_type is omitted, "
238        "the header remains the generic '๐Ÿ™‹ Human-in-the-loop request' and the connector "
239        "name is still included for additional context. Always provide this when the "
240        "escalation is about a specific connector.",
241    ] = None,
242    request_type: Annotated[
243        RequestType | None,
244        "Type of escalation request. Controls the Slack message header emoji and label. "
245        "Accepted values: 'action' (๐Ÿ”ง Action Requested), "
246        "'review' (๐Ÿ‘€ Review Requested), 'input' (โ“ Input Needed), "
247        "'guidance' (๐Ÿงญ Guidance Needed), 'approval' (โœ… Approval Requested), "
248        "'blocked' (๐Ÿšซ Still Blocked). "
249        "When omitted, defaults to the generic 'Human-in-the-loop request' header.",
250    ] = None,
251) -> EscalateToHumanResponse:
252    """Escalate to a human team member via Slack.
253
254    Posts a formatted message to the #human-in-the-loop Slack channel,
255    tagging the specified person(s). The message includes clickable buttons
256    for the Devin session, PR, and issue links when provided, plus any
257    additional freeform action buttons.
258
259    The Slack message is sent by a GitHub Actions workflow so that Slack
260    credentials are never exposed to the calling agent. The workflow
261    resolves person identifiers (email, GitHub handle, or Slack ID) to
262    Slack user IDs using the internal team roster.
263
264    Use this tool when you need human input, approval, or help that you
265    cannot resolve on your own.
266    """
267    # Resolve request_type to header_emoji and header_label
268    header_emoji: str | None = None
269    header_label: str | None = None
270    if request_type is not None:
271        header_emoji, header_label = _REQUEST_TYPE_HEADERS[request_type]
272
273    result = dispatch_escalation(
274        target_person=target_person,
275        message=message,
276        agent_session_url=agent_session_url,
277        cc=cc,
278        pr_url=pr_url,
279        issue_url=issue_url,
280        additional_actions=additional_actions,
281        approval_requested=approval_requested,
282        approval_request_summary=approval_request_summary,
283        approval_request_detail_url=approval_request_detail_url,
284        connector_name=connector_name,
285        header_emoji=header_emoji,
286        header_label=header_label,
287    )
288
289    view_url = result.run_url or result.workflow_url
290    return EscalateToHumanResponse(
291        success=True,
292        message=(
293            f"Escalation sent to '{target_person}' via #human-in-the-loop "
294            f"({HITL_SLACK_CHANNEL_URL}). "
295            f"View progress at: {view_url}"
296        ),
297        workflow_url=result.workflow_url,
298        run_id=result.run_id,
299        run_url=result.run_url,
300    )
301
302
303logger = logging.getLogger(__name__)
304
305_NEWSLETTER_CHANNELS: dict[str, tuple[str, str]] = {
306    "Hydra": ("C0AH48172M6", "#daily-newsletters"),
307}
308
309_REPO_OWNER = "airbytehq"
310
311_REPO_NAME = "airbyte-ops-mcp"
312
313_WORKFLOW_FILE = "slack-post-message.yml"
314
315_DEFAULT_BRANCH = "main"
316
317_DRY_RUN_CHANNEL = "C0AEN317Z7T"
318
319
320class DryRunMode(str, Enum):
321    """Controls how dry-run behaves."""
322
323    off = "off"
324    """Normal mode โ€” post to the real channel."""
325
326    local = "local"
327    """Local-only preview. Builds Block Kit JSON and returns both the
328    blocks JSON (for agent-side rendering) and a Block Kit Builder URL.
329    No workflow is triggered."""
330
331    slack_test_channel = "slack_test_channel"
332    """Triggers the real workflow but posts to a test channel instead of
333    the production newsletter channel."""
334
335
336class PostToSlackChannelResponse(BaseModel):
337    """Response from the post_slack_newsletter tool."""
338
339    success: bool = Field(description="Whether the operation completed successfully")
340    message: str = Field(description="Human-readable status message")
341    blocks_json: str | None = Field(
342        default=None,
343        description=(
344            "Serialised JSON array of Block Kit blocks. "
345            "Populated in 'local' dry-run mode so the agent can "
346            "render or inspect the blocks directly."
347        ),
348    )
349    workflow_url: str | None = Field(
350        default=None,
351        description="URL to view the GitHub Actions workflow file",
352    )
353    run_id: int | None = Field(
354        default=None,
355        description="GitHub Actions workflow run ID",
356    )
357    channel_name: str | None = Field(
358        default=None,
359        description="Human-readable Slack channel name (e.g. '#daily-newsletters')",
360    )
361    run_url: str | None = Field(
362        default=None,
363        description="Direct URL to the GitHub Actions workflow run",
364    )
365
366
367def _format_failure_details(run_status: WorkflowRunStatus) -> str:
368    """Build a human-readable summary of failed jobs from a workflow run."""
369    if not run_status.jobs:
370        return ""
371    failed_jobs = [j for j in run_status.jobs if j.conclusion == "failure"]
372    if not failed_jobs:
373        return ""
374    job_summaries = [f"  - {j.name} (job_id={j.job_id})" for j in failed_jobs]
375    return " Failed jobs:\n" + "\n".join(job_summaries)
376
377
378@mcp_tool(
379    read_only=False,
380    idempotent=False,
381    open_world=True,
382)
383def post_slack_newsletter(
384    message_text: Annotated[
385        str,
386        "The formatted message to post, using Slack mrkdwn syntax: "
387        "*bold*, _italic_, `code`, ```code blocks```, > blockquotes, "
388        "- bullet lists, and <url|label> links. "
389        "Use ## for section headers (translated to Block Kit header blocks) "
390        "and ### for sub-headers (translated to bold text). "
391        "Double newlines (\\n\\n) split the text into separate visual "
392        "sections in the Slack message. "
393        "Do NOT include markdown tables โ€” they will be rejected.",
394    ],
395    newsletter_name: Annotated[
396        Literal["Hydra"],
397        "Name of the newsletter to post to. "
398        "Determines which Slack channel receives the message. "
399        "Currently only 'Hydra' is supported (posts to #daily-newsletters). "
400        "Ignored when dry_run is 'slack_test_channel'.",
401    ],
402    dry_run: Annotated[
403        str,
404        "Controls dry-run behaviour. "
405        "'off' (default): post to the real channel. "
406        "'local': build blocks locally and return blocks JSON plus a "
407        "Block Kit Builder URL โ€” no workflow is triggered. "
408        "'slack_test_channel': trigger the workflow but post to a test "
409        "channel instead of the production newsletter channel.",
410    ] = "off",
411) -> PostToSlackChannelResponse:
412    """Post a formatted newsletter digest to a Slack channel.
413
414    Converts Slack mrkdwn text into Block Kit blocks locally (with
415    validation), then dispatches them to a GitHub Actions workflow for
416    posting.  The workflow receives finished Block Kit JSON โ€” it does
417    not perform any markdown conversion.
418
419    Dry-run modes:
420    - `local`: returns blocks JSON for local rendering โ€” no workflow triggered
421    - `slack_test_channel`: posts to a test channel for formatting verification
422    """
423    try:
424        mode = DryRunMode(dry_run)
425    except ValueError:
426        allowed_values = ", ".join(m.value for m in DryRunMode)
427        return PostToSlackChannelResponse(
428            success=False,
429            message=(
430                f"Invalid dry_run value {dry_run!r}. "
431                f"Allowed values are: {allowed_values}."
432            ),
433        )
434
435    # --- Validate input ---
436    try:
437        validate_message(message_text)
438    except ValueError as exc:
439        return PostToSlackChannelResponse(
440            success=False,
441            message=str(exc),
442        )
443
444    # --- Build Block Kit blocks locally ---
445    blocks_dict = build_blocks(message_text)
446    blocks_json = json.dumps(blocks_dict)
447
448    # --- Local mode: return blocks JSON, no dispatch ---
449    if mode is DryRunMode.local:
450        return PostToSlackChannelResponse(
451            success=True,
452            message=(
453                "Local preview generated. Use the blocks_json field to "
454                "render the message locally."
455            ),
456            blocks_json=blocks_json,
457        )
458
459    # --- Resolve target channel ---
460    if mode is DryRunMode.slack_test_channel:
461        resolved_channel = _DRY_RUN_CHANNEL
462        resolved_channel_name = "#slackbot-testing-channel--ignore-plz"
463    else:
464        resolved_channel, resolved_channel_name = _NEWSLETTER_CHANNELS[newsletter_name]
465
466    resolved_fallback = message_text[:120].replace("\n", " ")
467
468    # --- Dispatch to GitHub Actions ---
469    token = resolve_ci_trigger_github_token()
470    result: WorkflowDispatchResult = trigger_workflow_dispatch(
471        owner=_REPO_OWNER,
472        repo=_REPO_NAME,
473        workflow_file=_WORKFLOW_FILE,
474        ref=resolve_default_workflow_branch(_DEFAULT_BRANCH),
475        inputs={
476            "channel_id": resolved_channel,
477            "blocks_json": blocks_json,
478            "fallback_text": resolved_fallback,
479        },
480        token=token,
481    )
482
483    view_url = result.run_url or result.workflow_url
484    mode_label = (
485        " (dry run โ€” test channel)" if mode is DryRunMode.slack_test_channel else ""
486    )
487
488    # --- Guard: if we couldn't discover the run, return early ---
489    if result.run_id is None:
490        return PostToSlackChannelResponse(
491            success=False,
492            message=(
493                f"Workflow dispatched to {resolved_channel_name}{mode_label} "
494                f"but could not discover the run ID to verify completion. "
495                f"Check manually: {view_url}"
496            ),
497            channel_name=resolved_channel_name,
498            workflow_url=result.workflow_url,
499        )
500
501    # --- Wait for workflow completion and report failures ---
502    run_status: WorkflowRunStatus = wait_for_workflow_completion(
503        owner=_REPO_OWNER,
504        repo=_REPO_NAME,
505        run_id=result.run_id,
506        token=token,
507        poll_interval_seconds=5.0,
508        max_wait_seconds=120.0,
509    )
510
511    if run_status.failed:
512        failure_details = _format_failure_details(run_status)
513        return PostToSlackChannelResponse(
514            success=False,
515            message=(
516                f"Workflow run FAILED (conclusion={run_status.conclusion}) "
517                f"for {resolved_channel_name}{mode_label}. "
518                f"Run: {run_status.run_url or view_url}"
519                f"{failure_details}"
520            ),
521            channel_name=resolved_channel_name,
522            workflow_url=result.workflow_url,
523            run_id=result.run_id,
524            run_url=run_status.run_url or result.run_url,
525        )
526
527    if not run_status.succeeded:
528        return PostToSlackChannelResponse(
529            success=False,
530            message=(
531                f"Workflow run did not complete within 120s "
532                f"(status={run_status.status}, "
533                f"conclusion={run_status.conclusion}){mode_label}. "
534                f"Run: {run_status.run_url or view_url}"
535            ),
536            channel_name=resolved_channel_name,
537            workflow_url=result.workflow_url,
538            run_id=result.run_id,
539            run_url=run_status.run_url or result.run_url,
540        )
541
542    return PostToSlackChannelResponse(
543        success=True,
544        message=(
545            f"Message posted to {resolved_channel_name} "
546            f"({resolved_channel}){mode_label}. "
547            f"Run: {view_url}"
548        ),
549        channel_name=resolved_channel_name,
550        workflow_url=result.workflow_url,
551        run_id=result.run_id,
552        run_url=result.run_url,
553    )
554
555
556def register_human_in_the_loop_tools(app: FastMCP) -> None:
557    """Register human-in-the-loop tools with the FastMCP app."""
558    register_mcp_tools(app, mcp_module=__name__)