Skip to content

Slack (ticket source)

@gaby/mcp-slack

Slack ticket-source MCP server for Gaby. Connects to Slack over Socket Mode (an outbound WebSocket — no public URL, no inbound webhook, no signing secret), drains messages as RawTickets via the fetch_new_tickets tool, and posts agent replies back on the thread.

Apache 2.0.

Install

Eventually:

npx -y @gaby/mcp-slack

Until published: build locally and point Gaby at dist/server.js.

pnpm install
pnpm build

Create the Slack app (~2 minutes)

Use the bundled slack-app-manifest.yaml:

  1. https://api.slack.com/appsCreate New AppFrom a manifest → pick your workspace → paste the YAML.
  2. Basic Information → App-Level Tokens → Generate Token and Scopes → add connections:write → copy the xapp-… token → that's SLACK_APP_TOKEN.
  3. OAuth & Permissions → Install to Workspace → copy the Bot User OAuth Token (xoxb-…) → that's SLACK_BOT_TOKEN.
  4. In Gaby, add a Slack (MCP) ticket source and paste the two tokens.
  5. Invite the bot to your support channel: /invite @Gaby.

Every top-level message in a channel the bot is in becomes a ticket; thread replies are pulled in when Gaby investigates, and Gaby replies in-thread.

Environment

Var Required Default Notes
SLACK_APP_TOKEN xapp-… app-level token with connections:write (Basic Information → App-Level Tokens). Authenticates the Socket Mode WebSocket.
SLACK_BOT_TOKEN xoxb-… bot token (OAuth & Permissions).
SLACK_CHANNEL_IDS (all) Optional comma-separated channel IDs (C01234,C05678) to restrict to. Empty = ingest every channel the bot is invited to — the intended "invite Gaby to #support" model.
GABY_QUEUE_CAPACITY 10000 Ring buffer size; oldest dropped on overflow.

Socket Mode lifecycle

  1. Start via node dist/server.js.
  2. The server opens the Socket Mode WebSocket before answering initialize (connect error → process exit, which Gaby surfaces as HandshakeError).
  3. Slack pushes events over the socket:
  4. only top-level human message events are enqueued (no subtypes, no bot posts, no thread replies),
  5. filtered to SLACK_CHANNEL_IDS when set (otherwise all member channels),
  6. each event is ack()-ed promptly (Slack's 3 s SLA).
  7. Overflow: oldest event dropped, droppedSinceBoot counter surfaces in future healthchecks.
  8. healthcheck.webhook_bound reports the socket connection state (the contract field name is kept for compatibility).

Contract conformance

Passes connectors/_contract/test_ticket_source_contract.py v1 — see the contract spec for the full 5-tool wire shape.

Queue state is in-memory only. On restart the buffer is empty and the first fetch_new_tickets response sets cursor_reset: true so Gaby's bridge re-bootstraps from null.

Slack app scopes

Bot scopes (encoded in the manifest):

  • channels:history / groups:history — read messages + thread replies for get_ticket
  • channels:read / groups:read — resolve channel info
  • chat:writepost_reply
  • reactions:writemark_resolved (adds :white_check_mark:)
  • users:read — populates the customer display name
  • users:read.email — populates requester_email. Slack gates email behind this separate scope; with only users:read you get a name and no address, and Gaby cannot resolve which customer is asking

App-level scope: connections:write (Socket Mode). Event subscriptions: message.channels, message.groups.

Troubleshooting

Nothing is ingested, and nothing errors. This is the common failure, and it is almost always the Slack app rather than Gaby: the app was created from scratch or from a Slack template instead of the manifest, so the bot token has no read scopes. Slack returns the granted scopes in a response header, so you can check a token directly:

curl -sS -D- -o /dev/null https://slack.com/api/auth.test \
  -H "Authorization: Bearer xoxb-…" | grep -i x-oauth-scopes

Expect channels:history, channels:read, groups:history, groups:read, chat:write, reactions:write, users:read, users:read.email. A token showing only assistant:write, chat:write came from the AI-assistant template and cannot read a single message. A token with users:read but not users:read.email reads messages fine but yields tickets with a name and no address, which is enough for Gaby to greet someone and not enough to find their account.

Symptom Cause Fix
No tickets, no errors anywhere bot token missing read scopes add the scopes, reinstall, paste the new xoxb-…
missing_scope from any Slack call same same
Works in public channels, silent in a private one groups:history / groups:read missing, or the bot was never invited add the scopes and /invite @Gaby
Still silent after a reinstall SLACK_CHANNEL_IDS names a different channel clear it (ingest every invited channel) or correct the id
Only messages sent after a restart arrive queue is in-memory expected: the first fetch sets cursor_reset: true
Gaby escalates with "customer cannot be identified" users:read.email not granted, so tickets carry a name but no email add the scope, reinstall, update the token

Changing scopes never upgrades an existing token. You must reinstall the app and update the token stored in the Gaby ticket source.

Operations — notes

  • No public surface. Socket Mode dials out to Slack, so there's no inbound endpoint to rate-limit or protect — the previous Events API webhook (and its reverse-proxy/ngrok requirement) is gone.
  • Queue is in-memory only. A crash drops all unreplied events. This is deliberate (no disk state, no config to back up) but means rapid restarts under load will lose messages.
  • post_reply has no dedupe. Retries on the Gaby side will post duplicate messages on the thread. The bridge's idempotent snapshot guarantee makes retries rare in practice.

App manifest

Create the app with From a manifest and paste this verbatim. Do not use "From scratch" or a Slack template: the AI-assistant template installs with only assistant:write + chat:write, which cannot read a message, and Gaby then ingests nothing without reporting an error.

# Gaby — Slack app manifest (Socket Mode)
#
# Create your Slack app from this in ~2 minutes:
#   1. https://api.slack.com/apps → "Create New App" → "From a manifest"
#      Use "From a manifest" — NOT "From scratch" and not one of Slack's
#      templates. The AI-assistant template installs with only
#      `assistant:write` + `chat:write`, which cannot read a single message,
#      and Gaby then ingests nothing with no error anywhere.
#   2. Pick your workspace, paste this YAML, Create.
#   3. Basic Information → App-Level Tokens → "Generate Token and Scopes"
#      → add scope `connections:write` → copy the `xapp-…` token.
#   4. OAuth & Permissions → "Install to Workspace" → copy the
#      Bot User OAuth Token (`xoxb-…`).
#   5. In Gaby: add a Slack ticket source, paste the two tokens.
#   6. Invite the bot to your support channel:  /invite @Gaby
#      A PRIVATE channel needs the bot invited AND the groups:* scopes below.
#
# Changing scopes later does NOT update an existing token: you must
# reinstall the app (OAuth & Permissions → Reinstall) and paste the new
# `xoxb-…` into Gaby, otherwise the old token keeps the old scopes.
#
# Socket Mode means Slack pushes events to Gaby over an outbound WebSocket —
# no public URL, no inbound webhook, no signing secret.
#
# The operator-approval scopes (im:write, commands) and interactivity are
# consumed by the v0.6 operator bot: Gaby DMs your ops channel when a draft
# needs review, with Send / Discard / Open-in-Gaby buttons riding the same
# Socket Mode connection. Installing with them now avoids a reinstall later;
# they are inert until that bot ships. (/gaby before then answers nothing.)
display_information:
  name: Gaby Support
  description: AI support agent that turns Slack messages into investigated, grounded replies.
  background_color: "#4f46e5"
features:
  bot_user:
    display_name: Gaby
    always_online: true
  slash_commands:
    - command: /gaby
      description: Ask Gaby (read-only) or manage a pending draft
      usage_hint: "ask <question> · pending"
      should_escape: false
oauth_config:
  scopes:
    bot:
      - channels:history   # read messages + thread replies in public channels
      - channels:read      # resolve channel info
      - groups:history     # same, for private channels the bot is invited to
      - groups:read
      - chat:write         # post replies in-thread + operator notifications
      - reactions:write    # mark_resolved adds :white_check_mark:
      - users:read         # resolve the requester's display name
      # Slack gates email behind its own scope. Without it users.info returns
      # a name and no email, the ticket carries no identifying address, and an
      # investigation cannot work out which customer is asking: it escalates
      # with "cannot be identified in the system".
      - users:read.email   # resolve the requester's email (tenant lookup key)
      - im:write           # v0.6 operator bot — open DMs for draft approvals
      - commands           # v0.6 operator bot — the /gaby slash command
settings:
  event_subscriptions:
    bot_events:
      - message.channels   # new messages in public channels the bot is in
      - message.groups     # new messages in private channels the bot is in
      # NOTE: message.im is deliberately NOT subscribed yet — the current
      # ticket-source server doesn't handle DM events. The v0.6 operator
      # bot adds it (Settings → Event Subscriptions → reinstall).
  interactivity:
    is_enabled: true       # block actions (Send/Discard buttons) over Socket Mode
  org_deploy_enabled: false
  socket_mode_enabled: true
  token_rotation_enabled: false