# @arvoretech/slack-advanced-mcp

Advanced Slack MCP server: semantic user search, smart DMs, style analysis, thread extraction, audio transcription and image analysis. Every call runs as the authenticated user (`SLACK_USER_TOKEN`, an `xoxp-` token).

## Text formatting

Every tool that carries text takes a `format`:

| format | what happens |
| --- | --- |
| `markdown` | `**bold**`, `*italic*`, `[label](url)`, lists and quotes are converted to Slack mrkdwn |
| `mrkdwn` | the text reaches Slack exactly as written, for callers that already speak `*bold*` and `<url\|label>` |

The default is `markdown` for the text-only tools (`send_dm`, `send_channel_message`, `edit_message`, `create_group_dm`) and `mrkdwn` for the caption of the upload tools (`send_image`, `send_file`, `send_audio`), which is the behaviour each of them already had.

Two things the markdown converter protects that a plain markdown-to-mrkdwn pass destroys:

- **links with a scheme other than http, https or mailto.** `[RFC](hive://shelf/x)` used to come out as the bare word `RFC`, with the address dropped and nothing to notice it by.
- **links already written in mrkdwn.** `<https://x|PR>` used to be converted a second time into `<https://x%7CPR|https://x|PR>`, which renders as a link to a 404.

`send_dm`, `send_channel_message` and `edit_message` echo `sent_text`: the exact string Slack stored, so a caller can check the result without reading the channel back.

`content_type` is still accepted on `send_channel_message` and maps to `format` (`text/plain` to `mrkdwn`, `text/markdown` to `markdown`).

## Reading messages back

Slack rewrites a message's top-level `text` field into a single flat line whenever the message carries blocks, which is every message this server sends. The readers (`get_dm_history`, `list_channel_messages`, `get_thread_from_link`) therefore return the text of the message block instead, so line breaks survive the round trip.

## Drafts

`create_draft` leaves a message in the user's Slack composer instead of sending it. Bold, italic, strikethrough, inline code, code blocks, links, lists and quotes are carried over as real formatting.

Slack's API only lets a user token create a draft. Reading, editing and deleting one are not available, so a draft can only be changed from the Slack app, and Slack keeps at most one draft per conversation.

## Searching and scheduling

`search_messages` runs Slack's own search (modifiers such as `in:#channel`, `from:@user`, `after:2026-01-01` work) and needs the `search:read` scope. `list_channels` lists channels with id, privacy, membership and topic, paginated by `next_cursor`.

`schedule_message` posts later, up to 120 days ahead. `post_at` is Unix seconds or an ISO 8601 date with a timezone; a date without one is refused, because it would be read in the server's clock zone. `delete_scheduled_message` cancels it with the returned `scheduled_message_id`.

## People from other organizations (Slack Connect)

`users.list` only returns people from the user's own workspace, so someone who talks to the team over Slack Connect never shows up there. The user directory therefore also reads the members of every externally shared channel and group DM the user is in, plus the other side of every open DM, and looks each unknown id up with `users.info`. Those people come back from `search_users` with `external: true` and their `team_id`.

Matching ignores case, accents and punctuation, and looks at the username, real name, display name and email, so `nimbus`, `pat (nimbus)` and `Pat Rivera` all find "Pat Rivera (Nimbus)". A name that does not resolve returns `USER_NOT_FOUND` with the closest people, typos included, and nothing is sent.

`send_dm`, `get_dm_history` and `create_draft` also take a DM id (`D...`) or a link to it, which reaches the person even when they share no channel with the user.

## Slack app scopes

The server runs on a user token (`xoxp-`). The Slack app behind it needs these User Token Scopes:

| scope | used by |
| --- | --- |
| `users:read` | the user directory, `get_user_info`, presence |
| `users:read.email` | finding someone by email, and emails in search results |
| `channels:read`, `groups:read`, `mpim:read` | channel names, shared channels and their members |
| `im:read` | finding Slack Connect people through open DMs |
| `channels:history`, `groups:history`, `im:history`, `mpim:history` | reading messages, threads and `wait_for_reply` |
| `chat:write` | sending, editing, deleting and scheduling messages |
| `im:write`, `mpim:write` | opening a DM or a group DM |
| `channels:write`, `groups:write`, `channels:write.invites` | creating channels, inviting people, topic and purpose |
| `reactions:write` | `add_reaction`, `remove_reaction` |
| `files:read`, `files:write` | reading and uploading images, audio and files |
| `search:read` | `search_messages` |

When a scope is missing the call fails with `MISSING_SCOPE` and the message names the scope Slack asked for. Add it to the app, reinstall it and replace `SLACK_USER_TOKEN` with the new token. Finding people never fails for a missing scope: a source the token cannot read is skipped and the rest still works.

## Safety

- **The token only goes to Slack.** `analyze_image`, `transcribe_audio` and `download_file` accept a `file_url`, and the token is sent with it only when it is https on `slack.com` or `slack-edge.com`. A redirect off Slack is refused too. Any other address fails with `UNTRUSTED_FILE_URL`.
- **Local files.** `send_image`, `send_file` and `send_audio` read `file_path`, and `download_file` writes `output_path`. Without `SLACK_FILE_ALLOWED_DIRS`, credential and system locations are refused (`~/.ssh`, `~/.aws`, `~/.config`, `~/.kube`, `.env*`, private keys, `/etc` and others), symlinks included. With it, only the listed folders are allowed.
- **No guessing people.** A name that matches more than one user returns `AMBIGUOUS_USER` with the candidates, and nothing is sent. `create_group_dm` opens nothing if any name fails.
- **Errors are errors.** A failed call comes back with `isError: true` and a `code`, so the agent never reads a failure as a sent message.
- **Retries never duplicate.** Rate limits (429) are retried on every call. Network errors and 5xx are retried only on reads; a write that fails that way is reported as possibly applied instead of being sent again. Every call has a timeout (30 s, 120 s for file transfers).

## Thread replies come from the Hive bot

A reply posted with the user token shows the user's name and photo, so an agent's answer reads as if the person wrote it, even with the attribution line. When the machine's Hive is linked to Slack (`slack-link.json` in `HIVE_HOME`, `~/.hive` by default), `send_channel_message` with a `thread_ts` posts through the Hive's `hive-slack-relay` instead, as the Hive bot, and answers `sent_as: "hive_bot"`.

The relay only lets the bot write in a thread the user is part of, and passes plain text, so formatting is the mrkdwn the converter produced and `metadata` is dropped. When there is no link or the relay refuses, the reply goes out as the user, as before, with `sent_as: "user"` and the reason in `hive_bot_skipped`. When the relay does not answer at all, nothing is sent with the user token, because the bot may already have posted; the call fails with `MAYBE_POSTED`.

A reply sent as the bot cannot be changed with `edit_message` or `delete_message`, which run on the user token.

## Environment

| variable | required | what for |
| --- | --- | --- |
| `SLACK_USER_TOKEN` | yes | Slack user token (`xoxp-...`) |
| `ELEVENLABS_API_KEY` | no | audio transcription and text to speech |
| `ELEVENLABS_DEFAULT_VOICE_ID` | no | default voice for `send_audio` |
| `SLACK_USERS_CACHE_PATH` | no | where the user directory is cached |
| `SLACK_USERS_CACHE_TTL_MINUTES` | no | cache lifetime, 240 by default; the first load after it expires also looks up Slack Connect people and takes a few seconds |
| `SLACK_FILE_ALLOWED_DIRS` | no | folders (separated by `:` or `,`) that file tools may read and write; when unset, only credential and system locations are refused |
| `HIVE_HOME` | no | where the Hive keeps `slack-link.json`, `~/.hive` by default |
| `SLACK_AI_ATTRIBUTION` | no | `false` removes the "Mensagem gerada e enviada por um agente de IA" line from sent messages; on by default |
