// The `slack-mcp` binary.
//
// Two switches, both explicit, and deliberately orthogonal:
//
// --backend mock|real where the Slack calls go
// --data demo|… what the mock workspace contains
//
// Neither is inferred from the environment. A server that silently talks to a real workspace
// because a variable happened to be set is a server that will one day post to a real channel
// during a demo, so reaching real Slack takes saying so.
//
// Everything defaults to the offline path: no flags at all gives a working MCP server over
// stdio, backed by an in-process workspace with users, channels, a thread, a DM and a file,
// and no account, token or network anywhere.
///|
/// Which Slack the calls reach.
enum WhichBackend {
MockWorkspace
RealSlack
} derive(Eq)
///|
/// What the mock workspace is filled with.
///
/// `Demo` is synthetic, offline and byte-stable — the default, and the only one CI uses.
/// `Live` fetches real Feeling-of-Computing history from the atproto bridge at startup.
/// `Snapshot` replays a recorded fetch, which is how a live load becomes reproducible.
enum WhichData {
Demo
Live
Snapshot(String)
} derive(Eq)
///|
struct Options {
backend : WhichBackend
data : WhichData
token : String?
base_url : String?
/// Serve streamable HTTP on this address instead of stdio.
http_addr : String?
allowed_origins : Array[String]
/// How far back `--data live` reaches.
days : Int
/// Write the fetched records here, so a live load can be replayed offline.
record_to : String?
}
///|
let usage : String =
#| slack-mcp — a Slack MCP server (2026-07-28)
#|
#| slack-mcp [options]
#|
#| Backend:
#| --backend mock|real which Slack to talk to (default: mock)
#| --token TOKEN Slack token; required for --backend real
#| --base-url URL override https://slack.com/api/ (a proxy or recorder)
#|
#| Data (mock backend only):
#| --data demo the built-in demo workspace (default; offline, deterministic)
#| --data live fetch real Feeling-of-Computing history from the atproto bridge
#| --data snapshot FILE replay a recorded fetch (offline, deterministic)
#| --days N how far back --data live reaches (default: 90)
#| --record FILE save what --data live fetched, for replay
#|
#| Transport:
#| --http ADDR serve streamable HTTP on ADDR instead of stdio
#| --allow-origin ORIGIN permit this Origin; repeatable
#|
#| -h, --help this message
#|
#| With no options it serves stdio against an in-process mock workspace: no Slack
#| account, no token and no network are involved.
///|
/// Parse argv. Returns None when the caller asked for help or got something wrong; the
/// message has already been printed in that case.
fn parse_args(argv : Array[String]) -> Options? {
let mut backend = MockWorkspace
let mut data = Demo
let mut days = 90
let mut record_to : String? = None
let mut token : String? = None
let mut base_url : String? = None
let mut http_addr : String? = None
let allowed_origins : Array[String] = []
let mut i = 0
while i < argv.length() {
let arg = argv[i]
// A flag needing a value that does not have one is an error rather than a default:
// silently serving the mock when someone asked for real Slack is the wrong failure.
let value = fn() -> String? {
if i + 1 < argv.length() {
i = i + 1
Some(argv[i])
} else {
println("slack-mcp: \{arg} needs a value")
None
}
}
match arg {
"-h" | "--help" => {
println(usage)
return None
}
"--backend" =>
match value() {
Some("mock") => backend = MockWorkspace
Some("real") => backend = RealSlack
Some(other) => {
println("slack-mcp: --backend must be mock or real, got '\{other}'")
return None
}
None => return None
}
"--data" =>
match value() {
Some("demo") => data = Demo
Some("live") => data = Live
Some("snapshot") =>
match value() {
Some(path) => data = Snapshot(path)
None => return None
}
Some(other) => {
println(
"slack-mcp: --data must be demo, live or snapshot FILE, got '\{other}'",
)
return None
}
None => return None
}
"--days" =>
match value() {
Some(v) => {
let n = @string.parse_int(v) catch { _ => -1 }
if n <= 0 {
println("slack-mcp: --days needs a positive number, got '\{v}'")
return None
}
days = n
}
None => return None
}
"--record" =>
match value() {
Some(v) => record_to = Some(v)
None => return None
}
"--token" =>
match value() {
Some(v) => token = Some(v)
None => return None
}
"--base-url" =>
match value() {
Some(v) => base_url = Some(v)
None => return None
}
"--http" =>
match value() {
Some(v) => http_addr = Some(v)
None => return None
}
"--allow-origin" =>
match value() {
Some(v) => allowed_origins.push(v)
None => return None
}
other => {
println("slack-mcp: unknown option '\{other}'\n")
println(usage)
return None
}
}
i = i + 1
}
Some({
backend,
data,
token,
base_url,
http_addr,
allowed_origins,
days,
record_to,
})
}
///|
/// Build the Slack side.
///
/// The whole mock/real switch is the `match` below: both arms produce a `&@api.Transport`,
/// and everything above them is identical. That is the property the slack package's own
/// `ext/scenario` relies on, reused here.
async fn build_backend(opts : Options) -> @tools.Backend? {
match opts.backend {
MockWorkspace => {
let (ws, token, description) = match opts.data {
Demo =>
(
@mock.Workspace::demo(),
@mock.demo_token,
"an in-process mock workspace (built-in demo data)",
)
Live =>
match load_live(opts) {
Some(triple) => triple
// A showcase that will not start without a network is not much of a showcase,
// so a failed fetch degrades to the demo workspace rather than exiting. It says
// so on stderr, and in the instructions, so nobody mistakes one for the other.
None =>
(
@mock.Workspace::demo(),
@mock.demo_token,
"an in-process mock workspace (built-in demo data — the live fetch failed)",
)
}
Snapshot(path) =>
match load_snapshot(path) {
Some(triple) => triple
None => return None
}
}
let api = @typed.Api::new(ws.transport(), token)
Some(@tools.Backend::new(api, description, is_mock=true))
}
RealSlack => {
guard opts.token is Some(token) else {
println(
"slack-mcp: --backend real needs --token. Nothing is read from the environment, on purpose.",
)
return None
}
let where_ = opts.base_url.unwrap_or("https://slack.com/api/")
let api = @typed.Api::new(
@slack_http.HttpTransport::new(description=where_),
token,
base_url?=opts.base_url,
)
Some(@tools.Backend::new(api, where_, is_mock=false))
}
}
}
///|
/// Fetch the last `days` of Feeling-of-Computing history and seed a workspace with it.
///
/// Three collections across two PDSes, whose hosts are resolved rather than hardcoded --
/// bsky.network reshards accounts, and the two host names the at-foc reader has baked in are
/// true today and need not stay true.
async fn load_live(opts : Options) -> (@mock.Workspace, String, String)? {
// The clock comes from the caller so the cutoff is a decision, not a side effect. Seconds
// since the epoch, to microseconds.
let now_micros = @env.now().reinterpret_as_int64() * 1000L
let cutoff = now_micros - opts.days.to_int64() * 86400L * 1000000L
let (channels, messages, reactions) = fetch_bridge(cutoff) catch {
e => {
log("could not read the atproto bridge: \{e}")
return None
}
}
match opts.record_to {
Some(path) => write_snapshot(path, channels, messages, reactions, opts.days)
None => ()
}
let loaded = @foc.load(channels, messages, reactions)
log("loaded \{loaded.summary()} from the Feeling-of-Computing bridge")
Some(
(
loaded.workspace,
loaded.token,
"a mock workspace seeded from the last \{opts.days} days of the Feeling-of-Computing Slack, via its atproto bridge",
),
)
}
///|
async fn fetch_bridge(
cutoff : Int64,
) -> (Array[@atproto.Record], Array[@atproto.Record], Array[@atproto.Record]) {
let bot_pds = @atproto.resolve_pds(@foc.bot_did)
let owner_pds = @atproto.resolve_pds(@foc.owner_did)
// Channel keys are not TIDs, and there are seventeen of them, so the whole collection is
// fetched rather than windowed.
let channels = @atproto.fetch_all(
owner_pds, @foc.owner_did, @foc.channel_collection,
)
let messages = @atproto.fetch_since(
bot_pds, @foc.bot_did, @foc.message_collection, cutoff,
)
// Reaction keys carry their *target's* microsecond, so the same window selects the
// reactions belonging to the messages just fetched.
let reactions = @atproto.fetch_since(
bot_pds, @foc.bot_did, @foc.reaction_collection, cutoff,
)
(channels, messages, reactions)
}
///|
async fn write_snapshot(
path : String,
channels : Array[@atproto.Record],
messages : Array[@atproto.Record],
reactions : Array[@atproto.Record],
days : Int,
) -> Unit {
let snapshot = @foc.Snapshot::{
channels,
messages,
reactions,
taken_at: @env.now().to_string(),
days,
}
@fs.write_file(
path,
snapshot.to_json().stringify(),
create_mode=CreateOrTruncate,
) catch {
e => {
log("could not write \{path}: \{e}")
return
}
}
log("wrote \{path}")
}
///|
async fn load_snapshot(path : String) -> (@mock.Workspace, String, String)? {
let text = @fs.read_file(path).text() catch {
e => {
log("could not read \{path}: \{e}")
return None
}
}
let body = @json.parse(text) catch {
e => {
log("\{path} is not JSON: \{e}")
return None
}
}
let snapshot = @foc.Snapshot::from_json(body) catch {
e => {
log("\{path}: \{e}")
return None
}
}
let loaded = snapshot.load()
log("replayed \{loaded.summary()} from \{path}")
Some((loaded.workspace, loaded.token, snapshot.describe()))
}
///|
/// Diagnostics go to stderr, never stdout.
///
/// On stdio, stdout IS the protocol: one stray line there corrupts the NDJSON stream and the
/// client stops being able to parse anything.
async fn log(message : String) -> Unit {
@stdio.stderr.write("slack-mcp: " + message + "\n") catch {
_ => ()
}
}
///|
async fn main {
let argv = @env.args()
// argv[0] is the program itself.
let rest = if argv.length() > 1 { argv[1:].to_owned() } else { [] }
guard parse_args(rest) is Some(opts) else { return }
guard build_backend(opts) is Some(backend) else { return }
let srv = @tools.build(backend)
match opts.http_addr {
Some(addr) =>
@transport_http.serve(srv, addr, allowed_origins=opts.allowed_origins)
None => @transport_stdio.serve(srv)
}
}