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: 5 tool(s), 0 prompt(s), 0 resource(s).

Tools (5)

escalate_to_human

Escalate to a human or Slack usergroup via Slack.

Posts a formatted message to the #human-in-the-loop Slack channel, tagging the specified person(s) or usergroup(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. S-prefixed Slack usergroup IDs bypass roster resolution and render as usergroup mentions.

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'), a Slack user ID (e.g. 'U05AKF1BCC9'), or a Slack usergroup ID (e.g. 'S0BJ4K3LC4X' for @oc-hydra). Plain usergroup handles, S-prefixed IDs, and pasted Slack subteam mentions all work; the ID form is the most precise.
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. MUST be at most 3000 characters; over-limit inputs are rejected at call time. For longer content, move detail behind approval_request_detail_url or split into multiple escalations.
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 or Slack usergroups to tag on the message. Each entry uses the same identifier format as target_person. Plain usergroup handles, S-prefixed IDs, and pasted Slack subteam mentions all work; the ID form (e.g. 'S0BJ4K3LC4X' for @oc-hydra) is the most precise.
pr_url string | null no null Optional URL to a related pull request for the 'View PR' button. Takes one PR. When the message lists several PRs, pass each other PR in additional_actions as {'View PR #<number>': '<url>'} so every listed PR gets a button in the same escalation.
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/...'}. Use this for the second and later PRs or issues of a multi-item escalation, e.g. {'View PR #89553': 'https://github.com/...'}. Slack allows at most 25 buttons per message, counting the PR, issue, approval, and session buttons; past that the post fails, so add buttons only for items that need action and rely on inline links for the rest.
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'), a Slack user ID (e.g. 'U05AKF1BCC9'), or a Slack usergroup ID (e.g. 'S0BJ4K3LC4X' for @oc-hydra). Plain usergroup handles, S-prefixed IDs, and pasted Slack subteam mentions all work; the ID form is the most precise.",
      "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. MUST be at most 3000 characters; over-limit inputs are rejected at call time. For longer content, move detail behind approval_request_detail_url or split into multiple escalations.",
      "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 or Slack usergroups to tag on the message. Each entry uses the same identifier format as target_person. Plain usergroup handles, S-prefixed IDs, and pasted Slack subteam mentions all work; the ID form (e.g. 'S0BJ4K3LC4X' for @oc-hydra) is the most precise."
    },
    "pr_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional URL to a related pull request for the 'View PR' button. Takes one PR. When the message lists several PRs, pass each other PR in `additional_actions` as `{'View PR #<number>': '<url>'}` so every listed PR gets a button in the same escalation."
    },
    "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/...'}. Use this for the second and later PRs or issues of a multi-item escalation, e.g. {'View PR #89553': 'https://github.com/...'}. Slack allows at most 25 buttons per message, counting the PR, issue, approval, and session buttons; past that the post fails, so add buttons only for items that need action and rely on inline links for the rest."
    },
    "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

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

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. Slack usergroup handles and names are searchable too; use the returned usergroup ID for the most precise team reference.

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 person records; usergroups are reported separately",
      "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"
    },
    "usergroup_matches": {
      "description": "Matching Slack usergroups",
      "items": {
        "description": "A Slack usergroup and its mention metadata.",
        "properties": {
          "id": {
            "description": "Slack usergroup ID used in <!subteam^ID|@handle>",
            "type": "string"
          },
          "handle": {
            "description": "Slack usergroup handle without the leading @",
            "type": "string"
          },
          "name": {
            "description": "Slack usergroup display name",
            "type": "string"
          },
          "description": {
            "description": "Slack usergroup description",
            "type": "string"
          },
          "user_count": {
            "description": "Number of members in the usergroup",
            "type": "integer"
          }
        },
        "required": [
          "id",
          "handle",
          "name",
          "description",
          "user_count"
        ],
        "type": "object"
      },
      "type": "array"
    }
  },
  "required": [
    "query",
    "total_matches",
    "matches"
  ],
  "type": "object"
}

lookup_slack_usergroup

Resolve Slack usergroups to IDs for usergroup mentions.

Pass a handle or name to resolve oncall aliases such as @oc-apis, or pass an S-prefixed usergroup ID for the corresponding handle and name. Results include the ID and metadata needed for the <!subteam^ID|@alias> mention syntax used by Slack messages.

Parameters:

Name Type Required Default Description
id_or_handle string yes — Required Slack usergroup handle/name or S-prefixed usergroup ID. Handle/name matching is case-insensitive and partial; a leading @ is ignored. S-prefixed IDs are matched exactly.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "id_or_handle": {
      "description": "Required Slack usergroup handle/name or S-prefixed usergroup ID. Handle/name matching is case-insensitive and partial; a leading @ is ignored. S-prefixed IDs are matched exactly.",
      "type": "string"
    }
  },
  "required": [
    "id_or_handle"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from a Slack usergroup lookup.",
  "properties": {
    "id_or_handle": {
      "description": "The usergroup ID or handle/name lookup",
      "type": "string"
    },
    "total_matches": {
      "description": "Number of matching usergroups",
      "type": "integer"
    },
    "matches": {
      "description": "Matching Slack usergroups",
      "items": {
        "description": "A Slack usergroup and its mention metadata.",
        "properties": {
          "id": {
            "description": "Slack usergroup ID used in <!subteam^ID|@handle>",
            "type": "string"
          },
          "handle": {
            "description": "Slack usergroup handle without the leading @",
            "type": "string"
          },
          "name": {
            "description": "Slack usergroup display name",
            "type": "string"
          },
          "description": {
            "description": "Slack usergroup description",
            "type": "string"
          },
          "user_count": {
            "description": "Number of members in the usergroup",
            "type": "integer"
          }
        },
        "required": [
          "id",
          "handle",
          "name",
          "description",
          "user_count"
        ],
        "type": "object"
      },
      "type": "array"
    }
  },
  "required": [
    "id_or_handle",
    "total_matches",
    "matches"
  ],
  "type": "object"
}

post_slack_newsletter

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. When agent_session_url is provided, appends a linked session footer.

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 enum("DR", "Internal AI", "AJ", "AJ WIP Tracker") yes — Name of the newsletter to post to. Determines which Slack channel receives the message. DR and Internal AI both post to #daily-newsletters; AJ posts to #aj-release-notes; AJ WIP Tracker posts to #aj-wip-tracker. 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.
agent_session_url string | null no null URL of the Devin session posting this newsletter, e.g. 'https://app.devin.ai/sessions/<32-hex-id>'. When provided, a small grey footer line 'Posted by :devin:' linking to the session is appended automatically. Always pass your own session URL when posting from a Devin session.

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": {
      "description": "Name of the newsletter to post to. Determines which Slack channel receives the message. DR and Internal AI both post to #daily-newsletters; AJ posts to #aj-release-notes; AJ WIP Tracker posts to #aj-wip-tracker. Ignored when dry_run is 'slack_test_channel'.",
      "enum": [
        "DR",
        "Internal AI",
        "AJ",
        "AJ WIP Tracker"
      ],
      "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"
    },
    "agent_session_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "URL of the Devin session posting this newsletter, e.g. 'https://app.devin.ai/sessions/<32-hex-id>'. When provided, a small grey footer line 'Posted by <Session Name> :devin:' linking to the session is appended automatically. Always pass your own session URL when posting from a Devin session."
    }
  },
  "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.devin_api import extract_session_id
 24from airbyte_ops_mcp.github_actions import (
 25    WorkflowDispatchResult,
 26    WorkflowRunStatus,
 27    resolve_default_workflow_branch,
 28    trigger_workflow_dispatch,
 29    wait_for_workflow_completion,
 30)
 31from airbyte_ops_mcp.github_api import resolve_ci_trigger_github_token
 32from airbyte_ops_mcp.human_in_the_loop import (
 33    HITL_REPO_NAME,
 34    HITL_REPO_OWNER,
 35    HITL_SLACK_CHANNEL_URL,
 36    dispatch_escalation,
 37)
 38from airbyte_ops_mcp.internal_team_roster import fetch_roster, search_roster
 39from airbyte_ops_mcp.session_namer import generate_friendly_name
 40from airbyte_ops_mcp.slack_api import lookup_slack_usergroup as find_slack_usergroups
 41from airbyte_ops_mcp.slack_ops.blocks import (
 42    _MAX_BLOCKS,
 43    build_blocks,
 44    build_context_footer,
 45    validate_message,
 46)
 47
 48
 49class PersonRecord(BaseModel):
 50    """A person in the internal team roster."""
 51
 52    slack_id: str | None = Field(default=None, description="Slack user ID")
 53    slack_display_name: str | None = Field(
 54        default=None, description="Slack display name"
 55    )
 56    slack_email: str | None = Field(
 57        default=None, description="Email address from Slack profile"
 58    )
 59    github_id: int | None = Field(default=None, description="GitHub numeric user ID")
 60    github_handle: str | None = Field(default=None, description="GitHub username")
 61    github_public_email: str | None = Field(
 62        default=None, description="Public email from GitHub profile"
 63    )
 64
 65
 66class SlackUsergroupRecord(BaseModel):
 67    """A Slack usergroup and its mention metadata."""
 68
 69    id: str = Field(description="Slack usergroup ID used in <!subteam^ID|@handle>")
 70    handle: str = Field(description="Slack usergroup handle without the leading @")
 71    name: str = Field(description="Slack usergroup display name")
 72    description: str = Field(description="Slack usergroup description")
 73    user_count: int = Field(description="Number of members in the usergroup")
 74
 75
 76class PeopleLookupResponse(BaseModel):
 77    """Response from a people lookup query."""
 78
 79    query: str = Field(description="The search query that was used")
 80    total_matches: int = Field(
 81        description="Number of matching person records; usergroups are reported separately"
 82    )
 83    matches: list[PersonRecord] = Field(description="Matching person records")
 84    usergroup_matches: list[SlackUsergroupRecord] = Field(
 85        default_factory=list,
 86        description="Matching Slack usergroups",
 87    )
 88
 89
 90class RosterListResponse(BaseModel):
 91    """Response listing the full internal team roster."""
 92
 93    total_members: int = Field(description="Total number of members in the roster")
 94    members: list[PersonRecord] = Field(description="All person records in the roster")
 95
 96
 97class SlackUsergroupLookupResponse(BaseModel):
 98    """Response from a Slack usergroup lookup."""
 99
100    id_or_handle: str = Field(description="The usergroup ID or handle/name lookup")
101    total_matches: int = Field(description="Number of matching usergroups")
102    matches: list[SlackUsergroupRecord] = Field(description="Matching Slack usergroups")
103
104
105@mcp_tool(
106    read_only=True,
107    idempotent=True,
108)
109def lookup_person(
110    query: Annotated[
111        str,
112        Field(
113            description=(
114                "Search query to match against any field: email address, "
115                "Slack display name, Slack user ID, GitHub handle, or GitHub user ID. "
116                "Case-insensitive partial matching for strings, exact match for numeric IDs."
117            )
118        ),
119    ],
120) -> PeopleLookupResponse:
121    """Look up a person in the Airbyte internal team roster.
122
123    Searches the daily-generated roster artifact by any field value.
124    The roster is built from Slack and GitHub org membership data,
125    cross-referenced by email address.
126
127    Use this to find someone's Slack ID for messaging, GitHub handle
128    for code review, or to cross-reference identities across platforms.
129    Slack usergroup handles and names are searchable too; use the returned
130    usergroup ID for the most precise team reference.
131    """
132    roster = fetch_roster()
133    matches = search_roster(roster, query)
134    try:
135        usergroup_matches = find_slack_usergroups(query)
136    except Exception as exc:
137        logger.warning("Could not look up Slack usergroups for %s: %s", query, exc)
138        usergroup_matches = []
139    return PeopleLookupResponse(
140        query=query,
141        total_matches=len(matches),
142        matches=[PersonRecord.model_validate(person) for person in matches],
143        usergroup_matches=[
144            SlackUsergroupRecord(
145                id=usergroup.id,
146                handle=usergroup.handle,
147                name=usergroup.name,
148                description=usergroup.description,
149                user_count=usergroup.user_count,
150            )
151            for usergroup in usergroup_matches
152        ],
153    )
154
155
156@mcp_tool(
157    read_only=True,
158    idempotent=True,
159)
160def list_team_roster() -> RosterListResponse:
161    """List the full Airbyte internal team roster.
162
163    Returns all members from the daily-generated roster artifact.
164    The roster is sorted by Slack display name for easy scanning.
165    Use lookup_person for targeted searches instead of scanning the full list.
166    """
167    roster = fetch_roster()
168    return RosterListResponse(
169        total_members=len(roster),
170        members=[PersonRecord.model_validate(person) for person in roster],
171    )
172
173
174@mcp_tool(
175    read_only=True,
176    idempotent=True,
177)
178def lookup_slack_usergroup(
179    id_or_handle: Annotated[
180        str,
181        Field(
182            description=(
183                "Required Slack usergroup handle/name or S-prefixed usergroup ID. "
184                "Handle/name matching is "
185                "case-insensitive and partial; a leading @ is ignored. "
186                "S-prefixed IDs are matched exactly."
187            )
188        ),
189    ],
190) -> SlackUsergroupLookupResponse:
191    """Resolve Slack usergroups to IDs for usergroup mentions.
192
193    Pass a handle or name to resolve oncall aliases such as `@oc-apis`, or
194    pass an S-prefixed usergroup ID for the corresponding handle and name.
195    Results include the ID and metadata needed for the
196    `<!subteam^ID|@alias>` mention syntax used by Slack messages.
197    """
198    usergroups = find_slack_usergroups(id_or_handle)
199    return SlackUsergroupLookupResponse(
200        id_or_handle=id_or_handle,
201        total_matches=len(usergroups),
202        matches=[
203            SlackUsergroupRecord(
204                id=usergroup.id,
205                handle=usergroup.handle,
206                name=usergroup.name,
207                description=usergroup.description,
208                user_count=usergroup.user_count,
209            )
210            for usergroup in usergroups
211        ],
212    )
213
214
215class RequestType(StrEnum):
216    """Type of escalation request, controlling the Slack header emoji and label."""
217
218    ACTION = "action"
219    REVIEW = "review"
220    INPUT = "input"
221    GUIDANCE = "guidance"
222    APPROVAL = "approval"
223    BLOCKED = "blocked"
224
225
226_REQUEST_TYPE_HEADERS: dict[RequestType, tuple[str, str]] = {
227    RequestType.ACTION: ("🔧", "Action Requested"),
228    RequestType.REVIEW: ("👀", "Review Requested"),
229    RequestType.INPUT: ("❓", "Input Needed"),
230    RequestType.GUIDANCE: ("🧭", "Guidance Needed"),
231    RequestType.APPROVAL: ("✅", "Approval Requested"),
232    RequestType.BLOCKED: ("🚫", "Still Blocked"),
233}
234
235
236def _format_failure_details(run_status: WorkflowRunStatus) -> str:
237    """Build a human-readable summary of failed jobs from a workflow run."""
238    if not run_status.jobs:
239        return ""
240    failed_jobs = [j for j in run_status.jobs if j.conclusion == "failure"]
241    if not failed_jobs:
242        return ""
243    job_summaries = [f"  - {j.name} (job_id={j.job_id})" for j in failed_jobs]
244    return " Failed jobs:\n" + "\n".join(job_summaries)
245
246
247class EscalateToHumanResponse(BaseModel):
248    """Response from the human-in-the-loop escalation tool."""
249
250    success: bool = Field(description="Whether the workflow was triggered successfully")
251    message: str = Field(description="Human-readable status message")
252    slack_channel_url: str = Field(
253        default=HITL_SLACK_CHANNEL_URL,
254        description="Direct URL to the #human-in-the-loop Slack channel",
255    )
256    workflow_url: str | None = Field(
257        default=None,
258        description="URL to view the GitHub Actions workflow file",
259    )
260    run_id: int | None = Field(
261        default=None,
262        description="GitHub Actions workflow run ID",
263    )
264    run_url: str | None = Field(
265        default=None,
266        description="Direct URL to the GitHub Actions workflow run",
267    )
268
269
270@mcp_tool(
271    read_only=False,
272    idempotent=False,
273    open_world=True,
274)
275def escalate_to_human(
276    target_person: Annotated[
277        str,
278        "Primary person to notify. Accepts an email address (e.g. 'aj@airbyte.io'), "
279        "a GitHub handle prefixed with @ (e.g. '@aaronsteers'), "
280        "a Slack user ID (e.g. 'U05AKF1BCC9'), or a Slack usergroup ID "
281        "(e.g. 'S0BJ4K3LC4X' for @oc-hydra). Plain usergroup handles, "
282        "S-prefixed IDs, and pasted Slack subteam mentions all work; the ID "
283        "form is the most precise.",
284    ],
285    message: Annotated[
286        str,
287        "The message body to deliver to the human. Format using Slack mrkdwn: "
288        "*bold*, _italic_, `code`, ```code blocks```, > blockquotes, "
289        "- bullet lists, and <url|label> links. Should clearly explain "
290        "what you need help with or what decision is required. "
291        "MUST be at most 3000 characters; over-limit inputs are rejected "
292        "at call time. For longer content, move detail behind "
293        "approval_request_detail_url or split into multiple escalations.",
294    ],
295    agent_session_url: Annotated[
296        str,
297        "Your agent session URL so the human can view the full context. "
298        "Use the session URL from your system prompt.",
299    ],
300    cc: Annotated[
301        list[str] | None,
302        "Optional list of additional people or Slack usergroups to tag on the "
303        "message. Each entry uses the same identifier format as target_person. "
304        "Plain usergroup handles, S-prefixed IDs, and pasted Slack subteam "
305        "mentions all work; the ID form (e.g. 'S0BJ4K3LC4X' for "
306        "@oc-hydra) is the most precise.",
307    ] = None,
308    pr_url: Annotated[
309        str | None,
310        "Optional URL to a related pull request for the 'View PR' button. "
311        "Takes one PR. When the message lists several PRs, pass each other PR "
312        "in `additional_actions` as `{'View PR #<number>': '<url>'}` so every "
313        "listed PR gets a button in the same escalation.",
314    ] = None,
315    issue_url: Annotated[
316        str | None,
317        "Optional URL to a related GitHub issue for the 'View Issue' button.",
318    ] = None,
319    additional_actions: Annotated[
320        dict[str, str] | None,
321        "Optional dictionary of label -> URL pairs for extra action buttons. "
322        "Example: {'Start Workflow': 'https://github.com/...actions/...'}. "
323        "Use this for the second and later PRs or issues of a multi-item "
324        "escalation, e.g. {'View PR #89553': 'https://github.com/...'}. "
325        "Slack allows at most 25 buttons per message, counting the PR, issue, "
326        "approval, and session buttons; past that the post fails, so add "
327        "buttons only for items that need action and rely on inline links "
328        "for the rest.",
329    ] = None,
330    approval_requested: Annotated[
331        bool,
332        "Set to True to add 'Approve' and 'Reject' buttons that post back to the Slack app. "
333        "Each button includes a confirmation dialog (if approval_request_summary is provided). "
334        "When either button is clicked, both buttons morph into non-interactive status text "
335        "(e.g. ':white_check_mark: Approved by @user' or ':x: Rejected by @user').",
336    ] = False,
337    approval_request_summary: Annotated[
338        str | None,
339        "Short description of what the user is approving, shown in a Slack "
340        "confirmation dialog before the Approve button fires. Rendered as a "
341        "blockquote. MUST be at most 280 characters; over-limit or "
342        "unbalanced-backtick inputs are rejected at call time. Keep it to the "
343        "minimum info the approver needs to identify what they are approving. "
344        "Example: 'Pinning source-hubspot prerelease 4.5.3-preview to workspace for testing'.",
345    ] = None,
346    approval_request_detail_url: Annotated[
347        str | None,
348        "Optional URL where the reviewer can read full details of what they are "
349        "being asked to approve. Rendered as a 'View Details' button in the Slack message.",
350    ] = None,
351    connector_name: Annotated[
352        str | None,
353        "Optional connector name to display prominently in the Slack message. "
354        "For example, when combined with request_type='action', the header may be "
355        "rendered as '🔧 Action Requested — source-salesloft'. If request_type is omitted, "
356        "the header remains the generic '🙋 Human-in-the-loop request' and the connector "
357        "name is still included for additional context. Always provide this when the "
358        "escalation is about a specific connector.",
359    ] = None,
360    request_type: Annotated[
361        RequestType | None,
362        "Type of escalation request. Controls the Slack message header emoji and label. "
363        "Accepted values: 'action' (🔧 Action Requested), "
364        "'review' (👀 Review Requested), 'input' (❓ Input Needed), "
365        "'guidance' (🧭 Guidance Needed), 'approval' (✅ Approval Requested), "
366        "'blocked' (🚫 Still Blocked). "
367        "When omitted, defaults to the generic 'Human-in-the-loop request' header.",
368    ] = None,
369) -> EscalateToHumanResponse:
370    """Escalate to a human or Slack usergroup via Slack.
371
372    Posts a formatted message to the #human-in-the-loop Slack channel,
373    tagging the specified person(s) or usergroup(s). The message includes
374    clickable buttons for the Devin session, PR, and issue links when provided,
375    plus any additional freeform action buttons.
376
377    The Slack message is sent by a GitHub Actions workflow so that Slack
378    credentials are never exposed to the calling agent. The workflow
379    resolves person identifiers (email, GitHub handle, or Slack ID) to Slack
380    user IDs using the internal team roster. S-prefixed Slack usergroup IDs
381    bypass roster resolution and render as usergroup mentions.
382
383    Use this tool when you need human input, approval, or help that you
384    cannot resolve on your own.
385    """
386    # Resolve request_type to header_emoji and header_label
387    header_emoji: str | None = None
388    header_label: str | None = None
389    if request_type is not None:
390        header_emoji, header_label = _REQUEST_TYPE_HEADERS[request_type]
391
392    result = dispatch_escalation(
393        target_person=target_person,
394        message=message,
395        agent_session_url=agent_session_url,
396        cc=cc,
397        pr_url=pr_url,
398        issue_url=issue_url,
399        additional_actions=additional_actions,
400        approval_requested=approval_requested,
401        approval_request_summary=approval_request_summary,
402        approval_request_detail_url=approval_request_detail_url,
403        connector_name=connector_name,
404        header_emoji=header_emoji,
405        header_label=header_label,
406    )
407
408    view_url = result.run_url or result.workflow_url
409
410    if result.run_id is None:
411        return EscalateToHumanResponse(
412            success=True,
413            message=(
414                f"Escalation sent to '{target_person}' via #human-in-the-loop "
415                f"({HITL_SLACK_CHANNEL_URL}). "
416                f"View progress at: {view_url}"
417            ),
418            workflow_url=result.workflow_url,
419            run_id=result.run_id,
420            run_url=result.run_url,
421        )
422
423    run_status = wait_for_workflow_completion(
424        owner=HITL_REPO_OWNER,
425        repo=HITL_REPO_NAME,
426        run_id=result.run_id,
427        token=resolve_ci_trigger_github_token(),
428        poll_interval_seconds=5.0,
429        max_wait_seconds=120.0,
430    )
431
432    if run_status.failed:
433        return EscalateToHumanResponse(
434            success=False,
435            message=(
436                f"Escalation workflow FAILED (conclusion={run_status.conclusion}) "
437                f"— the Slack message was NOT posted. "
438                f"Run: {run_status.run_url or view_url}"
439                f"{_format_failure_details(run_status)}"
440            ),
441            workflow_url=result.workflow_url,
442            run_id=result.run_id,
443            run_url=run_status.run_url or result.run_url,
444        )
445
446    if not run_status.succeeded:
447        return EscalateToHumanResponse(
448            success=True,
449            message=(
450                f"Escalation run dispatched to '{target_person}' via "
451                f"#human-in-the-loop ({HITL_SLACK_CHANNEL_URL}) and is still "
452                f"in progress (status={run_status.status}, "
453                f"conclusion={run_status.conclusion}). "
454                f"Run: {run_status.run_url or view_url}. "
455                "Confirm delivery with `check_ci_workflow_status`."
456            ),
457            workflow_url=result.workflow_url,
458            run_id=result.run_id,
459            run_url=run_status.run_url or result.run_url,
460        )
461
462    return EscalateToHumanResponse(
463        success=True,
464        message=(
465            f"Escalation sent to '{target_person}' via #human-in-the-loop "
466            f"({HITL_SLACK_CHANNEL_URL}). "
467            f"View progress at: {view_url}"
468        ),
469        workflow_url=result.workflow_url,
470        run_id=result.run_id,
471        run_url=result.run_url,
472    )
473
474
475logger = logging.getLogger(__name__)
476
477_NEWSLETTER_CHANNELS: dict[str, tuple[str, str]] = {
478    "DR": ("C0AH48172M6", "#daily-newsletters"),
479    "Internal AI": ("C0AH48172M6", "#daily-newsletters"),
480    "AJ": ("C0BLUPJ0X0R", "#aj-release-notes"),
481    "AJ WIP Tracker": ("C0C2PHD33RN", "#aj-wip-tracker"),
482}
483
484_REPO_OWNER = "airbytehq"
485
486_REPO_NAME = "airbyte-ops-mcp"
487
488_WORKFLOW_FILE = "slack-post-message.yml"
489
490_DEFAULT_BRANCH = "main"
491
492_DRY_RUN_CHANNEL = "C0AEN317Z7T"
493
494
495class DryRunMode(str, Enum):
496    """Controls how dry-run behaves."""
497
498    off = "off"
499    """Normal mode — post to the real channel."""
500
501    local = "local"
502    """Local-only preview. Builds Block Kit JSON and returns both the
503    blocks JSON (for agent-side rendering) and a Block Kit Builder URL.
504    No workflow is triggered."""
505
506    slack_test_channel = "slack_test_channel"
507    """Triggers the real workflow but posts to a test channel instead of
508    the production newsletter channel."""
509
510
511class PostToSlackChannelResponse(BaseModel):
512    """Response from the post_slack_newsletter tool."""
513
514    success: bool = Field(description="Whether the operation completed successfully")
515    message: str = Field(description="Human-readable status message")
516    blocks_json: str | None = Field(
517        default=None,
518        description=(
519            "Serialised JSON array of Block Kit blocks. "
520            "Populated in 'local' dry-run mode so the agent can "
521            "render or inspect the blocks directly."
522        ),
523    )
524    workflow_url: str | None = Field(
525        default=None,
526        description="URL to view the GitHub Actions workflow file",
527    )
528    run_id: int | None = Field(
529        default=None,
530        description="GitHub Actions workflow run ID",
531    )
532    channel_name: str | None = Field(
533        default=None,
534        description="Human-readable Slack channel name (e.g. '#daily-newsletters')",
535    )
536    run_url: str | None = Field(
537        default=None,
538        description="Direct URL to the GitHub Actions workflow run",
539    )
540
541
542@mcp_tool(
543    read_only=False,
544    idempotent=False,
545    open_world=True,
546)
547def post_slack_newsletter(
548    message_text: Annotated[
549        str,
550        "The formatted message to post, using Slack mrkdwn syntax: "
551        "*bold*, _italic_, `code`, ```code blocks```, > blockquotes, "
552        "- bullet lists, and <url|label> links. "
553        "Use ## for section headers (translated to Block Kit header blocks) "
554        "and ### for sub-headers (translated to bold text). "
555        "Double newlines (\\n\\n) split the text into separate visual "
556        "sections in the Slack message. "
557        "Do NOT include markdown tables — they will be rejected.",
558    ],
559    newsletter_name: Annotated[
560        Literal["DR", "Internal AI", "AJ", "AJ WIP Tracker"],
561        "Name of the newsletter to post to. "
562        "Determines which Slack channel receives the message. "
563        "DR and Internal AI both post to #daily-newsletters; AJ posts to "
564        "#aj-release-notes; AJ WIP Tracker posts to #aj-wip-tracker. "
565        "Ignored when dry_run is 'slack_test_channel'.",
566    ],
567    dry_run: Annotated[
568        str,
569        "Controls dry-run behaviour. "
570        "'off' (default): post to the real channel. "
571        "'local': build blocks locally and return blocks JSON plus a "
572        "Block Kit Builder URL — no workflow is triggered. "
573        "'slack_test_channel': trigger the workflow but post to a test "
574        "channel instead of the production newsletter channel.",
575    ] = "off",
576    agent_session_url: Annotated[
577        str | None,
578        "URL of the Devin session posting this newsletter, e.g. "
579        "'https://app.devin.ai/sessions/<32-hex-id>'. When provided, a small "
580        "grey footer line 'Posted by <Session Name> :devin:' linking to the "
581        "session is appended automatically. Always pass your own session URL "
582        "when posting from a Devin session.",
583    ] = None,
584) -> PostToSlackChannelResponse:
585    """Post a formatted newsletter digest to a Slack channel.
586
587    Converts Slack mrkdwn text into Block Kit blocks locally (with
588    validation), then dispatches them to a GitHub Actions workflow for
589    posting.  The workflow receives finished Block Kit JSON — it does
590    not perform any markdown conversion.
591    When `agent_session_url` is provided, appends a linked session footer.
592
593    Dry-run modes:
594    - `local`: returns blocks JSON for local rendering — no workflow triggered
595    - `slack_test_channel`: posts to a test channel for formatting verification
596    """
597    try:
598        mode = DryRunMode(dry_run)
599    except ValueError:
600        allowed_values = ", ".join(m.value for m in DryRunMode)
601        return PostToSlackChannelResponse(
602            success=False,
603            message=(
604                f"Invalid dry_run value {dry_run!r}. "
605                f"Allowed values are: {allowed_values}."
606            ),
607        )
608
609    # --- Validate input ---
610    try:
611        validate_message(message_text)
612    except ValueError as exc:
613        return PostToSlackChannelResponse(
614            success=False,
615            message=str(exc),
616        )
617
618    # --- Build Block Kit blocks locally ---
619    blocks_dict = build_blocks(message_text)
620    if agent_session_url is not None:
621        try:
622            session_id = extract_session_id(agent_session_url)
623        except ValueError as exc:
624            return PostToSlackChannelResponse(
625                success=False,
626                message=str(exc),
627            )
628        session_name = generate_friendly_name(session_id)
629        footer = build_context_footer(
630            f"Posted by <https://app.devin.ai/sessions/{session_id}|"
631            f"{session_name}> :devin:"
632        )
633        if len(blocks_dict) >= _MAX_BLOCKS:
634            blocks_dict = [*blocks_dict[: _MAX_BLOCKS - 1], footer]
635        else:
636            blocks_dict.append(footer)
637    blocks_json = json.dumps(blocks_dict)
638
639    # --- Local mode: return blocks JSON, no dispatch ---
640    if mode is DryRunMode.local:
641        return PostToSlackChannelResponse(
642            success=True,
643            message=(
644                "Local preview generated. Use the blocks_json field to "
645                "render the message locally."
646            ),
647            blocks_json=blocks_json,
648        )
649
650    # --- Resolve target channel ---
651    if mode is DryRunMode.slack_test_channel:
652        resolved_channel = _DRY_RUN_CHANNEL
653        resolved_channel_name = "#slackbot-testing-channel--ignore-plz"
654    else:
655        resolved_channel, resolved_channel_name = _NEWSLETTER_CHANNELS[newsletter_name]
656
657    resolved_fallback = message_text[:120].replace("\n", " ")
658
659    # --- Dispatch to GitHub Actions ---
660    token = resolve_ci_trigger_github_token()
661    result: WorkflowDispatchResult = trigger_workflow_dispatch(
662        owner=_REPO_OWNER,
663        repo=_REPO_NAME,
664        workflow_file=_WORKFLOW_FILE,
665        ref=resolve_default_workflow_branch(_DEFAULT_BRANCH),
666        inputs={
667            "channel_id": resolved_channel,
668            "blocks_json": blocks_json,
669            "fallback_text": resolved_fallback,
670        },
671        token=token,
672    )
673
674    view_url = result.run_url or result.workflow_url
675    mode_label = (
676        " (dry run — test channel)" if mode is DryRunMode.slack_test_channel else ""
677    )
678
679    # --- Guard: if we couldn't discover the run, return early ---
680    if result.run_id is None:
681        return PostToSlackChannelResponse(
682            success=False,
683            message=(
684                f"Workflow dispatched to {resolved_channel_name}{mode_label} "
685                f"but could not discover the run ID to verify completion. "
686                f"Check manually: {view_url}"
687            ),
688            channel_name=resolved_channel_name,
689            workflow_url=result.workflow_url,
690        )
691
692    # --- Wait for workflow completion and report failures ---
693    run_status: WorkflowRunStatus = wait_for_workflow_completion(
694        owner=_REPO_OWNER,
695        repo=_REPO_NAME,
696        run_id=result.run_id,
697        token=token,
698        poll_interval_seconds=5.0,
699        max_wait_seconds=120.0,
700    )
701
702    if run_status.failed:
703        failure_details = _format_failure_details(run_status)
704        return PostToSlackChannelResponse(
705            success=False,
706            message=(
707                f"Workflow run FAILED (conclusion={run_status.conclusion}) "
708                f"for {resolved_channel_name}{mode_label}. "
709                f"Run: {run_status.run_url or view_url}"
710                f"{failure_details}"
711            ),
712            channel_name=resolved_channel_name,
713            workflow_url=result.workflow_url,
714            run_id=result.run_id,
715            run_url=run_status.run_url or result.run_url,
716        )
717
718    if not run_status.succeeded:
719        return PostToSlackChannelResponse(
720            success=False,
721            message=(
722                f"Workflow run did not complete within 120s "
723                f"(status={run_status.status}, "
724                f"conclusion={run_status.conclusion}){mode_label}. "
725                f"Run: {run_status.run_url or view_url}"
726            ),
727            channel_name=resolved_channel_name,
728            workflow_url=result.workflow_url,
729            run_id=result.run_id,
730            run_url=run_status.run_url or result.run_url,
731        )
732
733    return PostToSlackChannelResponse(
734        success=True,
735        message=(
736            f"Message posted to {resolved_channel_name} "
737            f"({resolved_channel}){mode_label}. "
738            f"Run: {view_url}"
739        ),
740        channel_name=resolved_channel_name,
741        workflow_url=result.workflow_url,
742        run_id=result.run_id,
743        run_url=result.run_url,
744    )
745
746
747def register_human_in_the_loop_tools(app: FastMCP) -> None:
748    """Register human-in-the-loop tools with the FastMCP app."""
749    register_mcp_tools(app, mcp_module=__name__)