// inspector-cli: connect to an MCP server, run one method, disconnect.
//
// Scope note: this is the 2026-07-28 ("modern") era only. There is no legacy
// `initialize` handshake, so a 2025-era server will not answer `server/discover` and
// the run fails cleanly rather than silently falling back.

///|
// Kept in step with mcp-inspector-cli/moon.mod by `just set-version`, and checked by
// scripts/publish.sh. MoonBit cannot read moon.mod at runtime, so this duplication is
// unavoidable -- but a CLI that misreports its own version is worse than the duplication.
let version : String = "0.7.0"

///|
/// Methods `--method` accepts.
///
/// Stream and session-only methods are rejected on purpose: a one-shot CLI that waits
/// for SIGINT is worse than one that says no.
let one_shot_methods : Array[String] = [
  "initialize", "tools/list", "tools/call", "resources/list", "resources/read", "resources/templates/list",
  "prompts/list", "prompts/get", "logging/setLevel",
  // Tasks extension (SEP-2663). `tasks/list` is deliberately absent: the modern
  // redesign dropped it, and a server answers -32601.
   "tasks/get", "tasks/update", "tasks/cancel",
]

///|
/// Catalog methods answered without connecting to anything.
let catalog_methods : Array[String] = ["servers/list", "servers/show"]

///|
/// Streaming methods, which are NOT part of the reference CLI's one-shot surface.
///
/// The reference rejects stream methods outright so its CLI can never sit waiting for
/// SIGINT. This port supports `subscriptions/listen` because it is the modern era's
/// only push mechanism and a debugging tool wants it -- but only in a bounded form:
/// `--max-events` and `--listen-timeout` both default to finite values, so a run always
/// terminates on its own. That makes it safe in a pipeline, which is the property the
/// reference was protecting.
let stream_methods : Array[String] = ["subscriptions/listen"]

///|
let usage_text : String =
  #|Usage: mcp-inspector-cli [target...] [options]
  #|
  #|One-shot MCP client for the 2026-07-28 protocol era.
  #|
  #|Target:
  #|  A server URL, or a command and its arguments. With an explicit `--`,
  #|  everything BEFORE it is the target and everything after is options.
  #|
  #|Options:
  #|  --method            Method to invoke (required)
  #|  --transport           http (default for URLs) | stdio | sse
  #|  --server-url           Server URL, as an alternative to the positional target
  #|  --header <"Name: Value">    HTTP header; repeatable
  #|  --tool-name           Tool name for tools/call
  #|  --tool-arg       Tool argument; repeatable. Values are JSON-coerced,
  #|                              so count=1 is a number and name=hi is a string.
  #|  --tool-args-json      Tool arguments as one JSON object, passed verbatim.
  #|                              Mutually exclusive with --tool-arg.
  #|  --uri                  Resource URI for resources/read
  #|  --prompt-name         Prompt name for prompts/get
  #|  --prompt-args    Prompt arguments; repeatable
  #|  --log-level          Per-request log level to opt into
  #|  --metadata       General _meta, applied to every request; repeatable
  #|  --tool-metadata  _meta for tools/call only; repeatable
  #|  --connect-timeout       Connection timeout; 0 disables (default 15000)
  #|
  #|Watching (--method subscriptions/listen), always bounded so a run terminates:
  #|  --watch-tools               Watch for tools/list_changed
  #|  --watch-prompts             Watch for prompts/list_changed
  #|  --watch-resources           Watch for resources/list_changed
  #|  --watch-resource       Watch one resource for updates; repeatable
  #|  --max-events             Stop after n notifications (default 10, 0 = ack only)
  #|  --listen-timeout        Stop after ms (default 30000, 0 = no timeout)
  #|  Emits NDJSON: one acknowledgement line, then one line per notification.
  #|
  #|Tasks extension (io.modelcontextprotocol/tasks):
  #|  A task-augmented tools/call returns a task seed, not a result. The seed is
  #|  printed as-is unless --follow-task is given.
  #|  --follow-task               Poll tasks/get until the task settles
  #|  --max-polls              Poll budget for --follow-task (default 60)
  #|  --task-id               Task id, for tasks/get | tasks/update | tasks/cancel
  #|  --input-responses     Answers for tasks/update
  #|  --format         text (default, pretty-printed) or json (one line)
  #|  --app-info                  Probe a tool's MCP App UI metadata WITHOUT invoking it.
  #|                              With tools/call: one JSON line; exit 0 has an app,
  #|                              2 no_app, 5 tool_not_found. With tools/list: NDJSON,
  #|                              one line per tool, regardless of --format.
  #|  --content-info              Describe what a result CONTAINS instead of printing it:
  #|                              per block the resolved content type and where that
  #|                              answer came from, plus status, problem and links from
  #|                              org.marianoguerra.mcp/hypermedia. With tools/list:
  #|                              NDJSON of what each tool declares, without invoking it.
  #|  -h, --help                  Show this help
  #|  -V, --version               Show the version
  #|
  #|Exit codes:
  #|  0 ok  1 usage/error  2 no app  3 auth required  4 unreachable  5 tool error
  #|
  #|On any non-zero exit a single JSON line is written to stderr:
  #|  {"error":{"code":...,"message":...,"status":...,"url":...}}

///|
/// Valid `--log-level` values.
let log_levels : Array[String] = [
  "debug", "info", "notice", "warning", "error", "critical", "alert", "emergency",
]

///|
/// Default connection timeout for an ad-hoc target, so a black-holed host fails fast.
let default_connect_timeout_ms : Int = 15000

///|
/// Where to connect, resolved from the target and flags.
struct Target {
  config : @inspector.ServerConfig
  /// The URL, when there is one, for error envelopes.
  url : String?
  /// Roots to advertise, from the catalog entry.
  ///
  /// There is deliberately no `--roots` flag, matching the reference: the catalog file
  /// is the only durable way to give a run its roots.
  roots : Array[Json]
}

///|
/// Work out what server the run is about.
///
/// A run targets EITHER a catalog entry or an ad-hoc command/URL, never both: silently
/// preferring one would make a mistyped `--server` quietly connect somewhere else.
async fn resolve_target(args : Args) -> Target {
  let has_adhoc = !args.target.is_empty() || args.opt("--server-url") is Some(_)
  let wants_catalog = args.opt("--server") is Some(_) ||
    args.opt("--config") is Some(_) ||
    args.opt("--catalog") is Some(_)
  if wants_catalog && has_adhoc {
    raise UsageError(
      "A catalog server (--server/--config/--catalog) cannot be combined with a command or URL target.",
    )
  }
  if wants_catalog {
    return resolve_catalog_target(args)
  }
  let transport = args.opt("--transport")
  match transport {
    Some(t) if t != "http" && t != "sse" && t != "stdio" =>
      raise UsageError(
        "Invalid transport type: \{t}. Valid types are: sse, http, stdio.",
      )
    _ => ()
  }
  let headers : Map[String, String] = Map([])
  for h in args.list("--header") {
    let (name, value) = parse_header(h)
    headers[name] = value
  }
  // --server-url wins over the positional target, as in the reference.
  let explicit_url = args.opt("--server-url")
  let target = args.target
  let url = match explicit_url {
    Some(u) => Some(u)
    None =>
      if target.length() == 1 &&
        (target[0].has_prefix("http://") || target[0].has_prefix("https://")) {
        Some(target[0])
      } else {
        None
      }
  }
  match url {
    Some(u) => {
      // SSE is a legacy-era transport; the modern era is POST-only.
      let effective = transport.unwrap_or(infer_transport(u))
      if effective == "sse" {
        raise UsageError(
          "--transport sse is a legacy-era transport and is not supported by this modern-era client.",
        )
      }
      if effective == "stdio" {
        raise UsageError("--transport stdio cannot be used with a URL target.")
      }
      { config: Http(url=u, headers~), url: Some(u), roots: [] }
    }
    None => {
      if target.is_empty() {
        raise UsageError(
          "No server specified. Pass a URL or a command, or use --server-url.",
        )
      }
      let env : Map[String, String] = Map([])
      for pair in args.list("-e") {
        match pair.find("=") {
          Some(eq) => env[pair[:eq].to_owned()] = pair[eq + 1:].to_owned()
          None =>
            raise UsageError("Invalid env format: \{pair}. Use KEY=VALUE.")
        }
      }
      {
        config: Stdio(
          command=target[0],
          args=target[1:].to_owned(),
          env~,
          cwd=args.opt("--cwd"),
        ),
        url: None,
        roots: [],
      }
    }
  }
}

///|
/// Guess the transport from a URL path, matching the reference: /sse means the legacy
/// SSE transport, anything else is streamable HTTP.
fn infer_transport(url : String) -> String {
  if url.has_suffix("/sse") {
    "sse"
  } else {
    "http"
  }
}

///|
/// Build the tool arguments from --tool-arg / --tool-args-json.
fn tool_arguments(args : Args) -> Json raise {
  let pairs = args.list("--tool-arg")
  match args.opt("--tool-args-json") {
    Some(raw) => {
      if !pairs.is_empty() {
        raise UsageError(
          "--tool-args-json cannot be combined with --tool-arg; pick one.",
        )
      }
      let parsed = @json.parse(raw) catch {
        _ => raise UsageError("--tool-args-json must be valid JSON.")
      }
      guard parsed is Object(_) else {
        raise UsageError("--tool-args-json must be a JSON object.")
      }
      parsed
    }
    None => Json::object(collect_kv(pairs))
  }
}

///|
/// Run the requested method against a connected session.
async fn run_method(session : @inspector.Session, args : Args) -> Json {
  let meth = args.opt("--method").unwrap_or("")
  match meth {
    "initialize" => session.initialize_result()
    "tools/list" => session.list_all("tools/list", "tools", capability="tools")
    "resources/list" =>
      session.list_all("resources/list", "resources", capability="resources")
    "resources/templates/list" =>
      session.list_all(
        "resources/templates/list",
        "resourceTemplates",
        capability="resources",
      )
    "prompts/list" =>
      session.list_all("prompts/list", "prompts", capability="prompts")
    "tools/call" => {
      guard args.opt("--tool-name") is Some(name) else {
        raise UsageError("Tool name is required for tools/call method")
      }
      // Resolve client-side first so a typo is `tool_not_found` (exit 5) rather than
      // an opaque server error, matching the reference.
      guard session.find_tool(name) is Some(tool) else {
        raise CliExit(code=exit_tool_error, envelope={
          code: "tool_not_found",
          message: "Tool '\{name}' not found on server.",
          cause: None,
          status: None,
          url: None,
        })
      }
      // --app-info probes the tool's UI declaration and does NOT invoke it. That is
      // the whole point: a caller decides whether to render a widget before running
      // anything.
      if args.flag("--app-info") {
        return session.app_info(tool).to_json()
      }
      let arguments = tool_arguments(args)
      let tool_meta = collect_meta(args.list("--tool-metadata"))
      let called = session.call_tool(name, arguments, tool_meta~, tool~)
      // A task-augmented call returns a seed, not a result: the request finished but
      // the work has not. Show the seed by default -- a debugging tool should not hide
      // that a task was created -- and only poll when asked.
      if @client.is_task_result(called) && args.flag("--follow-task") {
        follow_task_result(session, called, args)
      } else {
        called
      }
    }
    "resources/read" => {
      guard args.opt("--uri") is Some(uri) else {
        raise UsageError("URI is required for resources/read method")
      }
      session.read_resource(uri)
    }
    "prompts/get" => {
      guard args.opt("--prompt-name") is Some(name) else {
        raise UsageError("Prompt name is required for prompts/get method")
      }
      let prompt_args = Json::object(collect_kv(args.list("--prompt-args")))
      session.get_prompt(name, prompt_args)
    }
    "tasks/get" => {
      let task = session.get_task(require_task_id(args))
      task.raw
    }
    "tasks/update" => {
      let responses = match args.opt("--input-responses") {
        Some(raw) =>
          @json.parse(raw) catch {
            _ => raise UsageError("--input-responses must be valid JSON.")
          }
        None =>
          raise UsageError(
            "tasks/update requires --input-responses '' answering the task's inputRequests.",
          )
      }
      session.update_task(require_task_id(args), responses)
    }
    "tasks/cancel" => session.cancel_task(require_task_id(args))
    "logging/setLevel" =>
      // The modern era removed `logging/setLevel`: the level is a per-request `_meta`
      // opt-in instead, which `--log-level` already applies to every request in this
      // run. Nothing is sent, and an empty result is returned. Documented divergence.
      Json::empty_object()
    _ => raise UsageError(unsupported_method_message(meth))
  }
}

///|
fn unsupported_method_message(meth : String) -> String {
  let supported = one_shot_methods + catalog_methods + stream_methods
  let joined = supported.join(", ")
  if meth == "" {
    "Method is required. Supported --cli methods: \{joined}."
  } else {
    "Unsupported method: \{meth}. Supported --cli methods: \{joined}."
  }
}

///|
/// Do the work; return the exit code.
async fn run(argv : Array[String]) -> Int {
  let mut url_for_errors : String? = None
  try {
    let args = parse_args(argv)
    if args.flag("--help") || args.flag("-h") {
      @stdio.stdout.write(usage_text + "\n")
      return exit_ok
    }
    if args.flag("--version") || args.flag("-V") {
      @stdio.stdout.write(version + "\n")
      return exit_ok
    }
    let meth = args.opt("--method").unwrap_or("")
    // The catalog methods answer from the file alone, so they are accepted here and
    // short-circuited below, before anything is dialled.
    if !one_shot_methods.contains(meth) &&
      !catalog_methods.contains(meth) &&
      !stream_methods.contains(meth) {
      raise UsageError(unsupported_method_message(meth))
    }
    let format = match args.opt("--format") {
      Some(f) if f != "text" && f != "json" =>
        raise UsageError("--format must be 'text' or 'json'.")
      Some(f) => f
      None => "text"
    }
    let log_level = match args.opt("--log-level") {
      Some(l) if !log_levels.contains(l) =>
        raise UsageError(
          "Invalid log level: \{l}. Valid levels are: \{log_levels.join(", ")}.",
        )
      other => other
    }
    let connect_timeout = match args.opt("--connect-timeout") {
      Some(raw) =>
        match
          (try @string.parse_int(raw) catch {
            _ => None
          } noraise {
            n => Some(n)
          }) {
          Some(n) if n >= 0 => n
          _ =>
            raise UsageError("--connect-timeout must be a non-negative number.")
        }
      None => default_connect_timeout_ms
    }
    reject_unimplemented(args)
    // Validate the argument shape BEFORE opening a connection, so a bad flag combo
    // reports the usage error rather than whatever the network happens to say first.
    let _ = tool_arguments(args)
    // `servers/list` and `servers/show` answer from the catalog file alone, so they
    // run before anything is dialled and work against a server that is down.
    if catalog_methods.contains(meth) {
      let catalog = @catalog.load(catalog_source(args))
      let result = if meth == "servers/list" {
        catalog.list_result()
      } else {
        guard args.opt("--server") is Some(name) else {
          raise UsageError("servers/show requires --server .")
        }
        catalog.show_result(name)
      }
      emit_result(result, format)
      return exit_ok
    }
    let target = resolve_target(args)
    url_for_errors = target.url
    let metadata = collect_meta(args.list("--metadata"))
    let identity : @inspector.ClientIdentity = {
      name: "mcp-inspector-cli",
      version,
    }
    // Structured concurrency owns the connection: whatever happens inside, the task
    // group tears the transport down on the way out.
    @async.with_task_group(group => {
      let connect = () => {
        @inspector.Session::connect(
          group,
          target.config,
          identity,
          roots=target.roots,
          metadata~,
          log_level?,
        )
      }
      let session = if connect_timeout > 0 {
        @async.with_timeout(
          connect_timeout,
          connect,
          error=UsageError("Connection timed out after \{connect_timeout}ms"),
        )
      } else {
        connect()
      }
      defer session.close()
      if stream_methods.contains(meth) {
        run_listen(session, args)
        return exit_ok
      }
      // `tools/list --app-info` probes every tool over one connection and emits NDJSON,
      // one line per tool, REGARDLESS of --format: the per-tool shape is fixed, because
      // a stream of results has no single document to wrap.
      if args.flag("--app-info") && meth == "tools/list" {
        run_app_info_list(session)
        return exit_ok
      }
      // `tools/list --content-info` answers "what will each of these return" from the
      // declarations alone, for the same reason: a pipeline choosing a tool wants to know
      // it yields a chart or a table before it commits to running one.
      if args.flag("--content-info") && meth == "tools/list" {
        run_content_info_list(session)
        return exit_ok
      }
      let result = run_method(session, args)
      // The modern envelope fields never reach stdout; the reference strips them and
      // scripts diffing our output against it would otherwise see spurious keys.
      // `ttlMs` is overloaded: on most results it is a caching hint the reference
      // drops, but on a task it is the task's own time-to-live and dropping it would
      // lose real information. Keep the hints for `resources/read` (measured) and for
      // anything task-shaped (semantic).
      // A single --app-info probe prints one line and decides the exit code on whether
      // the tool has an app at all -- 0 or 2, distinct from a missing tool's 5.
      if args.flag("--app-info") {
        emit_app_info(result, format)
        return exit_ok
      }
      // --content-info describes what came back instead of printing it. Unlike --app-info
      // the call HAS been made by this point: a widget's posture is declared ahead of
      // time, but what a result actually contains can only be known once it exists.
      if args.flag("--content-info") {
        emit_content_info(session, result, args, format)
      } else {
        let printable = @meta.strip_result_envelope(
          result,
          keep_cache_hints=meth == "resources/read" ||
            @client.is_task_shaped(result),
        )
        emit_result(printable, format)
      }
      // A tool that reports failure still prints its payload, but must not let an
      // `&&` chain continue.
      if is_error_result(result) {
        raise CliExit(code=exit_tool_error, envelope={
          code: "tool_is_error",
          message: "Tool '\{args.opt("--tool-name").unwrap_or("")}' returned isError:true.",
          cause: None,
          status: None,
          url: None,
        })
      }
      exit_ok
    })
  } catch {
    e => {
      let (code, envelope) = classify(e, url?=url_for_errors)
      emit_error(envelope) catch {
        _ => ()
      }
      code
    }
  }
}

///|
fn is_error_result(result : Json) -> Bool {
  match result {
    Object(o) => o.get("isError") is Some(True)
    _ => false
  }
}

///|
/// Exit with a specific status.
///
/// Called only after `with_task_group` has unwound, so spawned children are already
/// reaped and stdout is flushed -- `exit(3)` would otherwise skip both.
extern "c" fn c_exit(code : Int) -> Unit = "exit"

///|
async fn main {
  let argv = @env.args()
  let code = run(argv[1:].to_owned())
  if code != exit_ok {
    c_exit(code)
  }
}

///|
/// Decide which catalog file the run reads.
///
/// `--catalog` and `--config` are mutually exclusive: one is a writable catalog whose
/// absence is fine, the other a read-only session file whose absence is an error, and
/// silently preferring one would hide a typo.
///
/// `$MCP_CATALOG_PATH` is honoured only when no ad-hoc target was given, so a shell
/// that exports it can still run one-off invocations without tripping the
/// catalog-versus-ad-hoc conflict.
fn catalog_source(args : Args) -> @catalog.Source raise {
  match (args.opt("--catalog"), args.opt("--config")) {
    (Some(_), Some(_)) =>
      raise UsageError("--catalog and --config cannot be combined; pick one.")
    (_, Some(path)) => Config(path)
    (Some(path), None) => Catalog(path)
    (None, None) => {
      let has_adhoc = !args.target.is_empty() ||
        args.opt("--server-url") is Some(_) ||
        args.opt("--transport") is Some(_)
      match @env.get_env_var("MCP_CATALOG_PATH") {
        Some(path) if !has_adhoc => Catalog(path)
        _ => Catalog(default_catalog_path())
      }
    }
  }
}

///|
fn default_catalog_path() -> String {
  let home = @env.get_env_var("HOME").unwrap_or(".")
  "\{home}/.mcp-inspector/mcp.json"
}

///|
/// Build a target from a named catalog entry.
async fn resolve_catalog_target(args : Args) -> Target {
  let catalog = @catalog.load(catalog_source(args))
  guard args.opt("--server") is Some(name) else {
    let known = catalog.names().join(", ")
    let hint = if known.is_empty() { "" } else { " Known servers: \{known}." }
    raise UsageError(
      "--server  is required with --config/--catalog.\{hint}",
    )
  }
  let entry = catalog.select(name)
  guard entry.config is Object(cfg) else {
    raise UsageError("Server '\{name}' has a malformed entry.")
  }
  // A catalog entry may pin a protocol era. This client speaks only the modern one, so
  // an entry that asks for legacy is refused rather than connected to and then failed
  // obscurely on the first request.
  match cfg.get("protocolEra") {
    Some(String(era)) if era != "modern" =>
      raise UsageError(
        "Server '\{name}' pins protocolEra '\{era}'; this client speaks only the modern era (2026-07-28).",
      )
    _ => ()
  }
  let typ = match cfg.get("type") {
    Some(String(t)) => t
    _ => "stdio"
  }
  match typ {
    "sse" =>
      raise UsageError(
        "Server '\{name}' uses the sse transport, which is legacy-era and not supported by this client.",
      )
    "streamable-http" => {
      guard cfg.get("url") is Some(String(url)) else {
        raise UsageError("Server '\{name}' has no url.")
      }
      // Headers from the file are the baseline; a --header flag overrides that entry
      // for this run, matching the reference.
      let headers : Map[String, String] = Map([])
      match cfg.get("headers") {
        Some(Object(hs)) =>
          for k, v in hs {
            if v is String(text) {
              headers[k] = text
            }
          }
        _ => ()
      }
      for h in args.list("--header") {
        let (hname, value) = parse_header(h)
        headers[hname] = value
      }
      { config: Http(url~, headers~), url: Some(url), roots: entry_roots(cfg) }
    }
    "stdio" => {
      guard cfg.get("command") is Some(String(command)) else {
        raise UsageError("Server '\{name}' has no command.")
      }
      let cmd_args : Array[String] = []
      match cfg.get("args") {
        Some(Array(list)) =>
          for a in list {
            if a is String(text) {
              cmd_args.push(text)
            }
          }
        _ => ()
      }
      let env : Map[String, String] = Map([])
      match cfg.get("env") {
        Some(Object(vars)) =>
          for k, v in vars {
            if v is String(text) {
              env[k] = text
            }
          }
        _ => ()
      }
      for pair in args.list("-e") {
        match pair.find("=") {
          Some(eq) => env[pair[:eq].to_owned()] = pair[eq + 1:].to_owned()
          None =>
            raise UsageError("Invalid env format: \{pair}. Use KEY=VALUE.")
        }
      }
      let cwd = match args.opt("--cwd") {
        Some(d) => Some(d)
        None =>
          match cfg.get("cwd") {
            Some(String(d)) => Some(d)
            _ => None
          }
      }
      {
        config: Stdio(command~, args=cmd_args, env~, cwd~),
        url: None,
        roots: entry_roots(cfg),
      }
    }
    other => raise UsageError("Server '\{name}' has unknown type '\{other}'.")
  }
}

///|
/// Flags the reference supports that this port does not implement yet.
///
/// These are rejected rather than ignored. Unknown options are dropped silently (that
/// is commander's `allowUnknownOption`, which the reference sets), but these are not
/// unknown -- they are recognised and would change auth or output behaviour. Accepting
/// `--use-stored-auth` and then sending no credential, or accepting `--app-info` and
/// then printing an ordinary result, is worse than saying plainly that it is missing.
let unimplemented_flags : Array[String] = [
  "--use-stored-auth", "--list-stored-auth", "--print-handoff", "--relogin",
]

///|
let unimplemented_options : Array[String] = [
  "--wait-for-auth", "--client-id", "--client-secret", "--client-metadata-url", "--callback-url",
  "--client-config",
]

///|
fn reject_unimplemented(args : Args) -> Unit raise {
  for flag in unimplemented_flags {
    if args.flag(flag) {
      raise UsageError(
        "\{flag} is not implemented yet in this MoonBit port. See the README for what is supported.",
      )
    }
  }
  for opt in unimplemented_options {
    if args.opt(opt) is Some(_) {
      raise UsageError(
        "\{opt} is not implemented yet in this MoonBit port. See the README for what is supported.",
      )
    }
  }
  // `--stored-auth-only` asks the CLI never to start interactive OAuth. This port never
  // does, so the flag is already satisfied and is accepted as a no-op rather than
  // rejected -- a CI pipeline that passes it defensively should keep working.
}

///|
/// Default bounds for `--method subscriptions/listen`.
///
/// Both are finite so the command always terminates. A stream that ran until SIGINT
/// would be unusable from a script, which is exactly why the reference CLI refuses
/// stream methods rather than exposing them unbounded.
let default_max_events : Int = 10

///|
let default_listen_timeout_ms : Int = 30000

///|
/// Drive a `subscriptions/listen` stream, emitting NDJSON until a bound is hit.
///
/// Output is one JSON object per line rather than a single document: the point of a
/// stream is that a consumer sees each event as it arrives, and a document could only
/// be written after the stream ended.
async fn run_listen(session : @inspector.Session, args : Args) -> Unit {
  let filter = @client.SubscriptionFilter::new(
    tools_list_changed=args.flag("--watch-tools"),
    prompts_list_changed=args.flag("--watch-prompts"),
    resources_list_changed=args.flag("--watch-resources"),
    resource_subscriptions=args.list("--watch-resource"),
  )
  if filter.is_empty() {
    raise UsageError(
      "subscriptions/listen needs at least one of --watch-tools, --watch-prompts, --watch-resources or --watch-resource .",
    )
  }
  // Refuse a watch the server cannot honour, rather than opening a stream that would
  // silently carry nothing.
  let missing = session.unsupported_watches(filter)
  if !missing.is_empty() {
    raise UsageError("This server cannot honour: \{missing.join("; ")}.")
  }
  let max_events = match args.opt("--max-events") {
    Some(raw) =>
      match parse_non_negative(raw) {
        Some(n) => n
        None => raise UsageError("--max-events must be a non-negative number.")
      }
    None => default_max_events
  }
  let timeout_ms = match args.opt("--listen-timeout") {
    Some(raw) =>
      match parse_non_negative(raw) {
        Some(n) => n
        None =>
          raise UsageError("--listen-timeout must be a non-negative number.")
      }
    None => default_listen_timeout_ms
  }
  let subscription = session.listen(filter)
  defer subscription.close()
  // The acknowledgement is emitted as the first line so a consumer can confirm what
  // the server actually subscribed it to -- which is not always what was asked for.
  @stdio.stdout.write(
    Json::object({
      "type": Json::string("acknowledged"),
      "subscriptionId": subscription.id(),
      "notifications": subscription.acknowledged_filter(),
    }).stringify() +
    "\n",
  )
  if max_events == 0 {
    return
  }
  let mut seen = 0
  let pump = () => {
    while subscription.next() is Some(notification) {
      @stdio.stdout.write(
        Json::object({
          "type": Json::string("notification"),
          "method": Json::string(notification.meth),
          "params": notification.params,
        }).stringify() +
        "\n",
      )
      seen = seen + 1
      if seen >= max_events {
        return
      }
    }
  }
  // Timing out is a normal end to a watch, not a failure: a quiet server is the
  // common case, so exit 0 either way.
  if timeout_ms > 0 {
    let _ = @async.with_timeout_opt(timeout_ms, pump)
  } else {
    pump()
  }
}

///|
fn parse_non_negative(raw : String) -> Int? {
  match
    (try @string.parse_int(raw) catch {
      _ => None
    } noraise {
      n => Some(n)
    }) {
    Some(n) if n >= 0 => Some(n)
    _ => None
  }
}

///|
/// Roots declared by a catalog entry, advertised to the server at connect.
///
/// A server that wants to know the workspace asks for them via MRTR `roots/list`, and
/// answering with an empty list when the catalog declared some is silently wrong rather
/// than visibly missing -- which is why this is wired from the entry rather than left
/// at the default.
///
/// Entries are passed through as-is apart from requiring a `uri`: the shape is the
/// spec's `{uri, name?}` and a malformed one is better dropped than sent.
fn entry_roots(cfg : Map[String, Json]) -> Array[Json] {
  guard cfg.get("roots") is Some(Array(items)) else { return [] }
  let out : Array[Json] = []
  for item in items {
    if item is Object(o) && o.get("uri") is Some(String(_)) {
      out.push(item)
    }
  }
  out
}

///|
fn require_task_id(args : Args) -> String raise {
  match args.opt("--task-id") {
    Some(id) if id != "" => id
    _ => raise UsageError("This method requires --task-id .")
  }
}

///|
/// Default poll budget for `--follow-task`.
///
/// Finite so a task that never settles cannot hang the run. The reference ships
/// `modern_loop_task` to exercise exactly that.
let default_max_polls : Int = 60

///|
/// Poll a task seed to a settled state and return what to print.
///
/// The printed value is the final `DetailedTask`, which inlines the tool's own result
/// once completed -- so following a task yields the same shape a plain call would have,
/// wrapped in the task that produced it.
async fn follow_task_result(
  session : @inspector.Session,
  seed : Json,
  args : Args,
) -> Json {
  let task = @client.Task::parse(seed)
  let max_polls = match args.opt("--max-polls") {
    Some(raw) =>
      match parse_non_negative(raw) {
        Some(n) => n
        None => raise UsageError("--max-polls must be a non-negative number.")
      }
    None => default_max_polls
  }
  let outcome = session.follow_task(task.task_id, max_polls~)
  match outcome {
    Terminal(final_task) => final_task.raw
    // A task asking for input needs a capability this client does not advertise, for
    // the same reason MRTR elicitation is refused: there is nobody to ask. Report it
    // rather than polling a task that will never move.
    NeedsInput(stuck) =>
      raise CliExit(code=exit_usage, envelope={
        code: "error",
        message: "Task \{stuck.task_id} is waiting for input this client cannot provide (it advertises no elicitation capability). Answer it with --method tasks/update --task-id \{stuck.task_id} --input-responses ''.",
        cause: None,
        status: None,
        url: None,
      })
    PollLimit(last) =>
      raise CliExit(code=exit_usage, envelope={
        code: "error",
        message: "Task \{last.task_id} was still '\{last.status}' after \{max_polls} polls; giving up.",
        cause: None,
        status: None,
        url: None,
      })
  }
}

///|
/// Emit one app-info line and set the exit code from whether the tool has an app.
///
/// `--format json` wraps it as `{"appInfo": …}` so it is a sibling of the `result` key a
/// normal run emits; without it the bare object is printed.
async fn emit_app_info(info : Json, format : String) -> Unit {
  let line = if format == "json" {
    let envelope : Map[String, Json] = Map([])
    envelope["appInfo"] = info
    Json::object(envelope)
  } else {
    info
  }
  @stdio.stdout.write(line.stringify() + "\n")
  let has_app = match info {
    Object(o) => o.get("hasApp") is Some(True)
    _ => false
  }
  if !has_app {
    let name = match info {
      Object(o) =>
        match o.get("toolName") {
          Some(String(n)) => n
          _ => ""
        }
      _ => ""
    }
    // Exit 2, not 1: "this tool has no UI" is an answer, not a failure, and a caller
    // deciding whether to open a browser must tell it apart from a typo (5).
    raise CliExit(code=exit_no_app, envelope={
      code: "no_app",
      message: "Tool '\{name}' has no MCP App UI resource (_meta.ui.resourceUri).",
      cause: None,
      status: None,
      url: None,
    })
  }
}

///|
/// Probe every tool and emit NDJSON, one line per tool.
///
/// Exit is 0 even when no tool has an app: the question asked was "what does this server
/// offer", and "nothing" is a complete answer. Per-tool failures ride in `resourceError`
/// rather than aborting, so one bad widget cannot hide the rest.
async fn run_app_info_list(session : @inspector.Session) -> Unit {
  let listed = session.list_all("tools/list", "tools", capability="tools")
  guard listed is Object(o) else { return }
  guard o.get("tools") is Some(Array(tools)) else { return }
  for tool in tools {
    let info = session.app_info(tool)
    @stdio.stdout.write(info.to_json().stringify() + "\n")
  }
}

///|
/// Describe a result's contents instead of printing it.
///
/// The exit code is deliberately untouched: this flag changes what stdout says, not what
/// happened. A tool reporting `isError:true` still exits 5 through the usual path, and a
/// result with no content blocks is an answer rather than a failure, so it prints an empty
/// list and exits 0. `--app-info` owns exit 2; there is no equivalent here to own.
///
/// `--format json` wraps the descriptor as `{"contentInfo": …}` so it is a sibling of the
/// `result` key a normal run emits.
async fn emit_content_info(
  session : @inspector.Session,
  result : Json,
  args : Args,
  format : String,
) -> Unit {
  // Re-resolving the tool costs another `tools/list`, which is why it happens only under
  // this opt-in diagnostic flag: a tool may declare the content type its results carry,
  // and dropping that would report `default` where the server had in fact said something.
  let tool = match args.opt("--tool-name") {
    Some(name) => session.find_tool(name)
    None => None
  }
  let info = @inspector.content_info(result, tool?)
  if format == "json" {
    @stdio.stdout.write(
      Json::object({ "contentInfo": info }).stringify() + "\n",
    )
  } else {
    @stdio.stdout.write(info.stringify(indent=2) + "\n")
  }
}

///|
/// Emit NDJSON, one line per tool, of what each declares about its own output.
///
/// Nothing is invoked. Like the app-info listing, the per-tool shape is fixed regardless
/// of `--format`: a stream of lines has no single document to wrap.
async fn run_content_info_list(session : @inspector.Session) -> Unit {
  let listed = session.list_all("tools/list", "tools", capability="tools")
  guard listed is Object(o) else { return }
  guard o.get("tools") is Some(Array(tools)) else { return }
  for tool in tools {
    @stdio.stdout.write(@inspector.tool_content_info(tool).stringify() + "\n")
  }
}