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