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 |
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 triggeredslack_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 |
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 |
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__)