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