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