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 triggered
  • slack_test_channel: posts to a test channel for formatting verification

Parameters:

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

Show input JSON schema

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

Show output JSON schema

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

  1# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
  2"""MCP tools for 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__)