fetchAndStoreUsers always enriched with Slack Connect users, which
rides client.userBoot — a webclient endpoint on the per-workspace
domain that only browser-session tokens can call. With an OAuth token
it always fails, and the error propagated, so the user cache was never
written: every display name stayed unresolved and behind an egress
allowlist the call also logged a 403 per run.
Enrichment now runs only for session tokens. OAuth tokens keep the
users.list result they already fetched.
An egress proxy can hand the CLI an opaque sentinel and swap the real
credential onto the wire, so the token value says nothing about the
credential kind. Sniffing `xoxb-`/`xoxp-` prefixes then misread a bot
token as a browser session, which:
- rebased the standard API onto https://<team>.slack.com/api/
- routed conversations/users through the edge client (same domain)
- offered session-only tools (saved items, unreads)
Behind Sinatra's egress proxy every one of those calls was rejected
403 host_not_allowed, since the allowlist only knows slack.com.
tokenKind now derives the kind from which variable supplied the token
(config.Apply exports profile credentials into the same variables) and
only falls back to the prefix. The per-workspace domain is applied to
the standard client for session tokens only — it also silently
overrode GovSlack before.
Verified against a TLS-intercepted fake Slack whose auth.test returns
a team URL: with SLACK_MCP_XOXB_TOKEN=sin_… every request now stays on
slack.com; before the fix conversations.replies and users.info went to
sinatra-dev.slack.com.
slack-go's postForm puts the token in an x-www-form-urlencoded `token`
field and sends no Authorization header. Slack accepts either, but a
body-carried credential is invisible to anything that inspects headers:
Sinatra's egress proxy swaps an opaque `sin_` sentinel for the real bot
token on the way out, saw no header to rewrite, passed the sentinel
through verbatim, and every sandbox call came back `invalid_auth`.
Promote the `token` form field to `Authorization: Bearer` in the shared
HTTP client, so every Web API call authenticates the way brokers, MITM
proxies, and audit tooling expect. Content-Length and GetBody are kept
honest so slack-go's retries replay the rewritten body.
Browser-session tokens (`xoxc-`, paired with the `d` cookie) stay in the
body — they are not bearer credentials and the edge API wants them there.
Unlisted Slack apps get 1 req/min and 15 msgs/page on
conversations.history and conversations.replies. Wait Retry-After,
cap the page, and share that slot across CLI processes.
Shell heredocs/backticks mangle multi-line conversations add bodies
(bullets collapse, command substitution). Prefer --blocks via env var;
document chat.delete for cleanup since there is no CLI delete.
* fix: retrieve image attachment bytes via `attachments get -o`
Image files come back from the upstream FilesGet handler as an out-of-band
MCP ImageContent block; the CLI's toolcall adapter extracted only
TextContent, so `attachments get <image_id>` emitted only metadata and
silently dropped the image bytes (even with --raw). Non-image binaries
were unaffected (single base64-in-JSON text envelope).
CLI-layer fix (pkg/handler stays byte-for-byte upstream for clean merges):
- toolcall.InvokeResult captures ImageContent into Result.Images; Invoke
is now a thin text-only wrapper, so existing callers are unchanged.
- `attachments get` gains -o/--output <path>: decodes the bytes straight
to a file and prints a small metadata confirmation, keeping stdout free
of a multi-MB base64 blob. Without -o, image bytes are folded into the
same {..,"encoding":"base64","content":..} envelope as non-image
binaries, so `jq -r .content | base64 --decode` works uniformly.
- Update SKILL.md and README with the -o flag and decode recipes.
Adds unit tests for InvokeResult image capture and the attachment
rendering paths (recover-in-envelope, -o writes raw bytes, stdout has no
blob).
* fix: always enable `attachments get` (drop SLACK_MCP_ATTACHMENT_TOOL gate)
Downloading an attachment is a read-only operation, not a mutation like
posting or reacting, so requiring an opt-in env var was needless friction.
The upstream FilesGet handler reads SLACK_MCP_ATTACHMENT_TOOL from the
environment at parse time; the CLI now sets it to "true" in-process before
invoking (same in-process-env pattern as auth/govslack), so `attachments
get` always works. pkg/handler stays byte-for-byte upstream.
Verified live against the real workspace: `attachments get F0000000000`
with no env var now returns Slack `file_not_found` instead of the
"attachment_get_data tool is disabled" gate error.
Docs: move `attachments get` from the write/sensitive section to the read
commands and drop the env-var prefix from recipes (SKILL.md + README).
Add a top-level ## Output section and fix the intro, which still claimed
output matched the MCP server's tools (it's now JSON, not CSV). Trims the
redundant note from Channels/IDs. Documents the string-typed values, jq
tonumber, and --raw CSV escape hatch.
CLI list/table commands (channels, messages, users, saved items, user
groups) now print a JSON array of objects instead of CSV, so agents can
pipe straight to jq. Conversion lives in the CLI output layer
(internal/output): tabular commands call emitTable, which parses the
handler's CSV into a JSON array (column order preserved, HTML escaping
off so Slack <@U>/links/& stay legible). JSON-returning handlers are
re-indented; plain-text status lines pass through. --raw still prints the
original CSV/text verbatim.
pkg/handler is untouched, so the bundled MCP server's output is unchanged
and upstream merges stay clean. Values are strings (CSV carries no types);
use jq's tonumber when you need numbers.
* feat: add slack-cli, a no-daemon CLI over the slack-mcp-server engine
Turn the forked slack-mcp-server into a CLI so running many agents no
longer means one resident MCP process each. Every command is a
short-lived process that reads the shared on-disk cache.
- rename module to github.com/paymog/slack-cli (go install/homebrew/ldflags)
- internal/toolcall: invoke the upstream tool handlers in-process; the only
mcp-go coupling lives here, so pkg/handler and pkg/provider are reused
byte-for-byte (clean upstream merges, fork-and-extend)
- internal/{cli,cmds,config,credstore,runtime,output}: cobra command tree,
keyring-backed credential profiles, provider bootstrap, result printing
- 21 tools as subcommands (channels, conversations, users, usergroups,
saved, reactions, attachments, cache); write tools keep their env gating
- goreleaser + homebrew release workflow; ships a skills/slack-cli skill
- unit tests for config/credstore/toolcall; MCP server still builds
The MCP server (cmd/slack-mcp-server) is kept intact.
* chore(napkin): record real-workspace verification
* docs: explain how the CLI works (in-process handler invocation, shared cache)
The integration test added in #252 was reading row[0], which is the channel
ID column in channels_list CSV output, not the channel name. The CI for
#252 never ran (status checks were empty at merge time), so this slipped
through. Now resolves to the Name column by header lookup, matching the
convention used elsewhere in this file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When emails are forwarded to Slack channels, message content is stored in
files[] with filetype "email" rather than in text or blocks. This adds
FilesToText() to extract From, CC, and Subject metadata as a fallback
when msg.Text is empty, so these messages no longer appear as blank rows
in conversations_history output.
Closes#191
Resolves the two HIGH-severity findings from the Trivy filesystem scan:
- CVE-2026-32285 (github.com/buger/jsonparser, fixed in v1.1.2)
- CVE-2026-34986 (github.com/go-jose/go-jose/v3, fixed in v3.0.5)
Both are indirect deps, bumped via `go get` + `go mod tidy`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sort=popularity option forced fetching every channel the user belongs
to before sorting client-side — hundreds of API calls on large workspaces.
The Slack API doesn't support server-side sorting, so this was inherently
expensive. Remove it and always stop fetching once we have enough results,
using the Slack API's native cursor for pagination.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When sort is not "popularity", stop paginating the Slack API as soon as
we have enough results for the requested limit, and pass through the
API's native cursor. On large workspaces this avoids hundreds of API
calls when only a small number of channels are requested.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a new MCP tool to list channels the calling user is a member of,
using the users.conversations API. Follows the same pattern as
usergroups_me vs usergroups_list.
Unlike channels_list which returns all workspace channels, channels_me
returns only channels the user has joined — useful on large workspaces
where channels_list returns thousands of results.
Supports channel_types, sort (by popularity), limit, and cursor
parameters.
The test was added by #272 (d3f7ca3) when AttachmentToText still applied
`(` → `[` and `)` → `]` substitutions, which produced `[text][url]` output.
#281 (5c095ac) removed those substitutions, so AttachmentToText now emits
proper markdown `[text](url)`. The test wasn't updated in #281's merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix infinite loop on cold start when API returns zero results: return
error instead of nil when no existing cache is available, so the
watcher calls Fatal rather than spinning IsReady() forever
- Secure temp file handling: use os.CreateTemp for unpredictable names
(prevents symlink attacks) and clean up temp files on any failure
- Restrict cache file permissions from 0644 to 0600 and cache directory
from 0755 to 0700 (cache contains user PII)
- Extract atomicWriteFile helper to deduplicate temp+rename pattern
- Fix stale docstring: getCacheTTL default is 24h not 1h
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Restore IsReady() polling loop before ServeStdio() to fix cold-start
regression where tool calls fail for 60-90s on first run (no cache)
- Add fetchUsersMu/fetchChannelsMu mutexes to serialize fetchAndStore*
calls, preventing race between ForceRefresh and background refresh
- Use atomic file writes (temp + os.Rename) to prevent corrupt cache
files on crash
- Guard against empty API results overwriting valid cache
- Guard against empty cache files being treated as valid data
- Fix typo: TestRefreshingFlagPreventsConucrrentRefreshes → Concurrent
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On large workspaces (41K+ users), the server blocks for ~90 seconds
during startup while fetching all users/channels from the Slack API,
exceeding MCP client connection timeouts.
Changes:
- Load expired cache files immediately, mark server ready, then refresh
in background via goroutine (stale-while-revalidate pattern)
- Convert usersReady/channelsReady to atomic.Bool for race-free reads
- Add refreshingUsers/refreshingChannels atomic.Bool to coalesce
concurrent background refreshes via CompareAndSwap
- Remove stdio IsReady() polling loop (no longer needed)
- Increase default cache TTL from 1h to 24h
- Document SLACK_MCP_CACHE_TTL and SLACK_MCP_MIN_REFRESH_INTERVAL
env vars in docs/03-configuration-and-usage.md
Fixes startup timeout on large workspaces. Server now starts in under
1 second regardless of workspace size when a cache file exists.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a user ID is not in the in-memory cache, message rendering
degrades to raw IDs and paramFormatUser fails the tool call
entirely. This is common on Enterprise Grid workspaces where the
user cache (50K+ users) can be hours stale.
On cache miss, fetch the single user via users.info and patch the
snapshot atomically. This costs one API call instead of rebuilding
the entire user cache. Disk persistence is skipped; the next full
refresh cycle handles it.
Fixes#268
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>