airbyte_ops_mcp.mcp.slack_messaging
MCP tools for Slack messaging operations.
This module exposes the newsletter posting tool for AI agents.
Newsletter posting — Converts Slack mrkdwn text into Block Kit blocks and dispatches them via a GitHub Actions workflow to a newsletter channel.
MCP reference
MCP primitives registered by the slack_messaging module of the airbyte-internal-ops server: 1 tool(s), 0 prompt(s), 0 resource(s).
Tools (1)
post_slack_newsletter
Hints: open-world
Post a formatted newsletter digest to a Slack channel.
Converts Slack mrkdwn text into Block Kit blocks locally (with validation), then dispatches them to a GitHub Actions workflow for posting. The workflow receives finished Block Kit JSON — it does not perform any markdown conversion.
Dry-run modes:
local: returns blocks JSON for local rendering — no workflow 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 |
string |
yes | — | Name of the newsletter to post to. Determines which Slack channel receives the message. Currently only 'Hydra' is supported (posts to #daily-newsletters). Ignored when dry_run is 'slack_test_channel'. |
dry_run |
string |
no | "off" |
Controls dry-run behaviour. 'off' (default): post to the real channel. 'local': build blocks locally and return blocks JSON plus a Block Kit Builder URL — no workflow is triggered. 'slack_test_channel': trigger the workflow but post to a test channel instead of the production newsletter channel. |
Show input JSON schema
{
"additionalProperties": false,
"properties": {
"message_text": {
"description": "The formatted message to post, using Slack mrkdwn syntax: *bold*, _italic_, `code`, ```code blocks```, > blockquotes, - bullet lists, and <url|label> links. Use ## for section headers (translated to Block Kit header blocks) and ### for sub-headers (translated to bold text). Double newlines (\\n\\n) split the text into separate visual sections in the Slack message. Do NOT include markdown tables \u2014 they will be rejected.",
"type": "string"
},
"newsletter_name": {
"const": "Hydra",
"description": "Name of the newsletter to post to. Determines which Slack channel receives the message. Currently only 'Hydra' is supported (posts to #daily-newsletters). Ignored when dry_run is 'slack_test_channel'.",
"type": "string"
},
"dry_run": {
"default": "off",
"description": "Controls dry-run behaviour. 'off' (default): post to the real channel. 'local': build blocks locally and return blocks JSON plus a Block Kit Builder URL \u2014 no workflow is triggered. 'slack_test_channel': trigger the workflow but post to a test channel instead of the production newsletter channel.",
"type": "string"
}
},
"required": [
"message_text",
"newsletter_name"
],
"type": "object"
}
Show output JSON schema
{
"description": "Response from the post_slack_newsletter tool.",
"properties": {
"success": {
"description": "Whether the operation completed successfully",
"type": "boolean"
},
"message": {
"description": "Human-readable status message",
"type": "string"
},
"blocks_json": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Serialised JSON array of Block Kit blocks. Populated in 'local' dry-run mode so the agent can render or inspect the blocks directly."
},
"workflow_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "URL to view the GitHub Actions workflow file"
},
"run_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "GitHub Actions workflow run ID"
},
"channel_name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Human-readable Slack channel name (e.g. '#daily-newsletters')"
},
"run_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Direct URL to the GitHub Actions workflow run"
}
},
"required": [
"success",
"message"
],
"type": "object"
}
1# Copyright (c) 2025 Airbyte, Inc., all rights reserved. 2"""MCP tools for Slack messaging operations. 3 4This module exposes the newsletter posting tool for AI agents. 5 6**Newsletter posting** — Converts Slack mrkdwn text into Block Kit blocks 7and dispatches them via a GitHub Actions workflow to a newsletter channel. 8 9## MCP reference 10 11.. include:: ../../../docs/mcp-generated/slack_messaging.md 12 :start-line: 2 13""" 14 15from __future__ import annotations 16 17__all__: list[str] = [] 18 19import json 20import logging 21from enum import Enum 22from typing import Annotated, Literal 23 24from fastmcp import FastMCP 25from fastmcp_extensions import mcp_tool, register_mcp_tools 26from pydantic import BaseModel, Field 27 28from airbyte_ops_mcp.github_actions import ( 29 WorkflowDispatchResult, 30 WorkflowRunStatus, 31 trigger_workflow_dispatch, 32 wait_for_workflow_completion, 33) 34from airbyte_ops_mcp.github_api import resolve_ci_trigger_github_token 35from airbyte_ops_mcp.slack_ops.blocks import ( 36 build_blocks, 37 validate_message, 38) 39 40logger = logging.getLogger(__name__) 41 42# --------------------------------------------------------------------------- 43# Newsletter constants 44# --------------------------------------------------------------------------- 45 46# Newsletter name → (channel_id, human-readable channel name) 47_NEWSLETTER_CHANNELS: dict[str, tuple[str, str]] = { 48 "Hydra": ("C0AH48172M6", "#daily-newsletters"), 49} 50 51# Workflow dispatch constants 52_REPO_OWNER = "airbytehq" 53_REPO_NAME = "airbyte-ops-mcp" 54_WORKFLOW_FILE = "slack-post-message.yml" 55_DEFAULT_BRANCH = "main" 56 57# Test channel for dry-run posts (#slackbot-testing-channel--ignore-plz) 58_DRY_RUN_CHANNEL = "C0AEN317Z7T" 59 60 61class DryRunMode(str, Enum): 62 """Controls how dry-run behaves.""" 63 64 off = "off" 65 """Normal mode — post to the real channel.""" 66 67 local = "local" 68 """Local-only preview. Builds Block Kit JSON and returns both the 69 blocks JSON (for agent-side rendering) and a Block Kit Builder URL. 70 No workflow is triggered.""" 71 72 slack_test_channel = "slack_test_channel" 73 """Triggers the real workflow but posts to a test channel instead of 74 the production newsletter channel.""" 75 76 77class PostToSlackChannelResponse(BaseModel): 78 """Response from the post_slack_newsletter tool.""" 79 80 success: bool = Field(description="Whether the operation completed successfully") 81 message: str = Field(description="Human-readable status message") 82 blocks_json: str | None = Field( 83 default=None, 84 description=( 85 "Serialised JSON array of Block Kit blocks. " 86 "Populated in 'local' dry-run mode so the agent can " 87 "render or inspect the blocks directly." 88 ), 89 ) 90 workflow_url: str | None = Field( 91 default=None, 92 description="URL to view the GitHub Actions workflow file", 93 ) 94 run_id: int | None = Field( 95 default=None, 96 description="GitHub Actions workflow run ID", 97 ) 98 channel_name: str | None = Field( 99 default=None, 100 description="Human-readable Slack channel name (e.g. '#daily-newsletters')", 101 ) 102 run_url: str | None = Field( 103 default=None, 104 description="Direct URL to the GitHub Actions workflow run", 105 ) 106 107 108def _format_failure_details(run_status: WorkflowRunStatus) -> str: 109 """Build a human-readable summary of failed jobs from a workflow run.""" 110 if not run_status.jobs: 111 return "" 112 failed_jobs = [j for j in run_status.jobs if j.conclusion == "failure"] 113 if not failed_jobs: 114 return "" 115 job_summaries = [f" - {j.name} (job_id={j.job_id})" for j in failed_jobs] 116 return " Failed jobs:\n" + "\n".join(job_summaries) 117 118 119# --------------------------------------------------------------------------- 120# Newsletter tool 121# --------------------------------------------------------------------------- 122 123 124@mcp_tool( 125 read_only=False, 126 idempotent=False, 127 open_world=True, 128) 129def post_slack_newsletter( 130 message_text: Annotated[ 131 str, 132 "The formatted message to post, using Slack mrkdwn syntax: " 133 "*bold*, _italic_, `code`, ```code blocks```, > blockquotes, " 134 "- bullet lists, and <url|label> links. " 135 "Use ## for section headers (translated to Block Kit header blocks) " 136 "and ### for sub-headers (translated to bold text). " 137 "Double newlines (\\n\\n) split the text into separate visual " 138 "sections in the Slack message. " 139 "Do NOT include markdown tables — they will be rejected.", 140 ], 141 newsletter_name: Annotated[ 142 Literal["Hydra"], 143 "Name of the newsletter to post to. " 144 "Determines which Slack channel receives the message. " 145 "Currently only 'Hydra' is supported (posts to #daily-newsletters). " 146 "Ignored when dry_run is 'slack_test_channel'.", 147 ], 148 dry_run: Annotated[ 149 str, 150 "Controls dry-run behaviour. " 151 "'off' (default): post to the real channel. " 152 "'local': build blocks locally and return blocks JSON plus a " 153 "Block Kit Builder URL — no workflow is triggered. " 154 "'slack_test_channel': trigger the workflow but post to a test " 155 "channel instead of the production newsletter channel.", 156 ] = "off", 157) -> PostToSlackChannelResponse: 158 """Post a formatted newsletter digest to a Slack channel. 159 160 Converts Slack mrkdwn text into Block Kit blocks locally (with 161 validation), then dispatches them to a GitHub Actions workflow for 162 posting. The workflow receives finished Block Kit JSON — it does 163 not perform any markdown conversion. 164 165 Dry-run modes: 166 - `local`: returns blocks JSON for local rendering — no workflow triggered 167 - `slack_test_channel`: posts to a test channel for formatting verification 168 """ 169 try: 170 mode = DryRunMode(dry_run) 171 except ValueError: 172 allowed_values = ", ".join(m.value for m in DryRunMode) 173 return PostToSlackChannelResponse( 174 success=False, 175 message=( 176 f"Invalid dry_run value {dry_run!r}. " 177 f"Allowed values are: {allowed_values}." 178 ), 179 ) 180 181 # --- Validate input --- 182 try: 183 validate_message(message_text) 184 except ValueError as exc: 185 return PostToSlackChannelResponse( 186 success=False, 187 message=str(exc), 188 ) 189 190 # --- Build Block Kit blocks locally --- 191 blocks_dict = build_blocks(message_text) 192 blocks_json = json.dumps(blocks_dict) 193 194 # --- Local mode: return blocks JSON, no dispatch --- 195 if mode is DryRunMode.local: 196 return PostToSlackChannelResponse( 197 success=True, 198 message=( 199 "Local preview generated. Use the blocks_json field to " 200 "render the message locally." 201 ), 202 blocks_json=blocks_json, 203 ) 204 205 # --- Resolve target channel --- 206 if mode is DryRunMode.slack_test_channel: 207 resolved_channel = _DRY_RUN_CHANNEL 208 resolved_channel_name = "#slackbot-testing-channel--ignore-plz" 209 else: 210 resolved_channel, resolved_channel_name = _NEWSLETTER_CHANNELS[newsletter_name] 211 212 resolved_fallback = message_text[:120].replace("\n", " ") 213 214 # --- Dispatch to GitHub Actions --- 215 token = resolve_ci_trigger_github_token() 216 result: WorkflowDispatchResult = trigger_workflow_dispatch( 217 owner=_REPO_OWNER, 218 repo=_REPO_NAME, 219 workflow_file=_WORKFLOW_FILE, 220 ref=_DEFAULT_BRANCH, 221 inputs={ 222 "channel_id": resolved_channel, 223 "blocks_json": blocks_json, 224 "fallback_text": resolved_fallback, 225 }, 226 token=token, 227 ) 228 229 view_url = result.run_url or result.workflow_url 230 mode_label = ( 231 " (dry run — test channel)" if mode is DryRunMode.slack_test_channel else "" 232 ) 233 234 # --- Guard: if we couldn't discover the run, return early --- 235 if result.run_id is None: 236 return PostToSlackChannelResponse( 237 success=False, 238 message=( 239 f"Workflow dispatched to {resolved_channel_name}{mode_label} " 240 f"but could not discover the run ID to verify completion. " 241 f"Check manually: {view_url}" 242 ), 243 channel_name=resolved_channel_name, 244 workflow_url=result.workflow_url, 245 ) 246 247 # --- Wait for workflow completion and report failures --- 248 run_status: WorkflowRunStatus = wait_for_workflow_completion( 249 owner=_REPO_OWNER, 250 repo=_REPO_NAME, 251 run_id=result.run_id, 252 token=token, 253 poll_interval_seconds=5.0, 254 max_wait_seconds=120.0, 255 ) 256 257 if run_status.failed: 258 failure_details = _format_failure_details(run_status) 259 return PostToSlackChannelResponse( 260 success=False, 261 message=( 262 f"Workflow run FAILED (conclusion={run_status.conclusion}) " 263 f"for {resolved_channel_name}{mode_label}. " 264 f"Run: {run_status.run_url or view_url}" 265 f"{failure_details}" 266 ), 267 channel_name=resolved_channel_name, 268 workflow_url=result.workflow_url, 269 run_id=result.run_id, 270 run_url=run_status.run_url or result.run_url, 271 ) 272 273 if not run_status.succeeded: 274 return PostToSlackChannelResponse( 275 success=False, 276 message=( 277 f"Workflow run did not complete within 120s " 278 f"(status={run_status.status}, " 279 f"conclusion={run_status.conclusion}){mode_label}. " 280 f"Run: {run_status.run_url or view_url}" 281 ), 282 channel_name=resolved_channel_name, 283 workflow_url=result.workflow_url, 284 run_id=result.run_id, 285 run_url=run_status.run_url or result.run_url, 286 ) 287 288 return PostToSlackChannelResponse( 289 success=True, 290 message=( 291 f"Message posted to {resolved_channel_name} " 292 f"({resolved_channel}){mode_label}. " 293 f"Run: {view_url}" 294 ), 295 channel_name=resolved_channel_name, 296 workflow_url=result.workflow_url, 297 run_id=result.run_id, 298 run_url=result.run_url, 299 ) 300 301 302# --------------------------------------------------------------------------- 303# Registration 304# --------------------------------------------------------------------------- 305 306 307def register_slack_messaging_tools(app: FastMCP) -> None: 308 """Register all Slack messaging tools with the FastMCP app.""" 309 register_mcp_tools(app, mcp_module=__name__)