// 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)
  }
}