// The `bsky-mcp` binary.
//
// Two switches, both explicit, and deliberately orthogonal:
//
// --service URL which AT Protocol service the calls go to
// --auth … what credential goes with them
//
// Neither is inferred from the environment. A server that silently posts as somebody because
// a variable happened to be set is a server that will one day post to a real timeline during
// a demo, so writing takes saying so -- the same rule mcp-slack follows for its token.
//
// Everything defaults to the read-only path: no flags at all gives a working MCP server over
// stdio, reading the public Bluesky AppView with no account and no token. That is a real
// difference from mcp-slack, where the zero-setup path is an in-process mock: atproto serves
// unauthenticated reads, so the default here needs a network but no credential.
///|
/// Where the calls go when there is no session to say otherwise.
///
/// The public AppView answers `app.bsky.*` reads for anybody. It is not a PDS: it holds no
/// repo and accepts no writes, which is exactly why it is safe as a default.
let public_appview : String = "https://public.api.bsky.app"
///|
/// Where a login goes. Entryway, which issues the session and then names the account's real
/// PDS in the DID document -- `@client.Client` follows that on its own.
let default_pds : String = "https://bsky.social"
///|
struct Options {
/// Overrides the service. Anonymous reads default to the AppView, a login to entryway.
service : String?
identifier : String?
app_password : String?
/// Serve streamable HTTP on this address instead of stdio.
http_addr : String?
allowed_origins : Array[String]
}
///|
let usage : String =
#| bsky-mcp — a Bluesky (AT Protocol) MCP server (2026-07-28)
#|
#| bsky-mcp [options]
#|
#| Auth:
#| --auth app-password sign in; needs --identifier and --app-password
#| --identifier HANDLE the account, e.g. alice.bsky.social
#| --app-password PW an app password from Settings → App Passwords.
#| NOT your account password.
#| --service URL override the service
#| (default: https://public.api.bsky.app anonymously,
#| https://bsky.social with --auth)
#|
#| 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 the public Bluesky AppView: no account and
#| no token are involved, every read works, and every write refuses.
///|
/// 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 service : String? = None
let mut identifier : String? = None
let mut app_password : String? = None
let mut http_addr : String? = None
let mut authed = false
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 reading anonymously when someone asked to sign in is the wrong failure.
let value = fn() -> String? {
if i + 1 < argv.length() {
i = i + 1
Some(argv[i])
} else {
println("bsky-mcp: \{arg} needs a value")
None
}
}
match arg {
"-h" | "--help" => {
println(usage)
return None
}
"--auth" =>
match value() {
Some("app-password") => authed = true
Some("none") | Some("anonymous") => authed = false
Some(other) => {
// OAuth is the obvious other answer and the library does not have it, so the
// message says so rather than letting someone conclude they typed it wrong.
println(
"bsky-mcp: --auth must be app-password or none, got '\{other}'. OAuth is not supported: marianoguerra/atproto implements app-password sessions only.",
)
return None
}
None => return None
}
"--identifier" =>
match value() {
Some(v) => identifier = Some(v)
None => return None
}
"--app-password" =>
match value() {
Some(v) => app_password = Some(v)
None => return None
}
"--service" =>
match value() {
Some(v) => service = 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("bsky-mcp: unknown option '\{other}'")
println(usage)
return None
}
}
i = i + 1
}
if authed && (identifier is None || app_password is None) {
println(
"bsky-mcp: --auth app-password needs both --identifier and --app-password. Nothing is read from the environment, on purpose.",
)
return None
}
// Credentials without --auth is a typo with consequences: it would serve anonymously while
// its operator believed it was signed in, and every write would refuse for a reason the
// flags contradict.
if !authed && (identifier is Some(_) || app_password is Some(_)) {
println(
"bsky-mcp: --identifier/--app-password were given without --auth app-password. Add it, or drop them.",
)
return None
}
Some({
service: if authed && service is None {
Some(default_pds)
} else {
service
},
identifier: if authed {
identifier
} else {
None
},
app_password: if authed {
app_password
} else {
None
},
http_addr,
allowed_origins,
})
}
///|
/// Build the backend the tools talk to.
///
/// Anonymous is not a degraded mode here, it is the default: the AppView answers reads for
/// nobody in particular, which is what makes this server demoable with no account at all.
async fn build_backend(opts : Options) -> @tools.Backend? {
let now_micros = () => @env.now().reinterpret_as_int64() * 1000L
let service = opts.service.unwrap_or(public_appview)
let transport = @atproto_http.HttpTransport::new(description=service)
guard opts.identifier is Some(identifier) &&
opts.app_password is Some(app_password) else {
let client = @client.Client::new(transport, service~)
return Some(
@tools.Backend::new(
client,
if service == public_appview {
"the public Bluesky AppView at public.api.bsky.app (anonymous)"
} else {
"\{service} (anonymous)"
},
is_anonymous=true,
now_micros~,
),
)
}
let client = @client.Client::new(transport, service~)
let session = client.login(identifier~, password=app_password) catch {
e => {
// The message from the service is worth repeating verbatim: "Invalid identifier or
// password" and "Rate Limit Exceeded" call for different responses from the operator.
log("could not sign in as \{identifier}: " + e.to_string())
return None
}
}
log("signed in as @\{session.handle} (\{session.did})")
Some(
@tools.Backend::new(
client,
// `Session::endpoint()` and not `service`: entryway issues the session, but the account
// may live on another PDS, and that is where the requests actually go.
session.endpoint(),
is_anonymous=false,
now_micros~,
),
)
}
///|
/// 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("bsky-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)
}
}