// Output, exit codes and the machine-readable error envelope.
//
// The exit-code map and envelope shape are a contract that scripts branch on, so they
// are reproduced exactly from the reference. See docs/reference-behaviour.md section 3
// for the measured behaviour these mirror.
///|
pub let exit_ok : Int = 0
///|
/// Usage or unexpected error -- the catch-all.
pub let exit_usage : Int = 1
///|
/// `--app-info` found no MCP App on the tool.
pub let exit_no_app : Int = 2
///|
/// The server requires authentication.
pub let exit_auth_required : Int = 3
///|
/// The server is unreachable.
pub let exit_unreachable : Int = 4
///|
/// A tool returned `isError:true`, or the named tool does not exist.
pub let exit_tool_error : Int = 5
///|
/// A failure classified for a programmatic caller.
pub(all) struct ErrorEnvelope {
/// Stable identifier for the failure class.
code : String
message : String
cause : String?
status : Int?
url : String?
} derive(Debug)
///|
pub fn ErrorEnvelope::to_json(self : Self) -> Json {
let inner : Map[String, Json] = Map([])
inner["code"] = Json::string(self.code)
inner["message"] = Json::string(self.message)
match self.cause {
Some(c) => inner["cause"] = Json::string(c)
None => ()
}
match self.status {
Some(s) => inner["status"] = Json::number(s.to_double())
None => ()
}
match self.url {
Some(u) => inner["url"] = Json::string(u)
None => ()
}
Json::object({ "error": Json::object(inner) })
}
///|
/// Raised to request a specific exit code without going through classification.
pub(all) suberror CliExit {
CliExit(code~ : Int, envelope~ : ErrorEnvelope)
} derive(Debug)
///|
/// Substrings that mean "we never reached the server".
///
/// Ported from the reference's UNREACHABLE_PATTERN. Structural classification handles
/// most cases; this catches what only shows up in an OS error string.
let unreachable_markers : Array[String] = [
"ENOTFOUND", "ECONNREFUSED", "ECONNRESET", "EAI_AGAIN", "ETIMEDOUT", "fetch failed",
"getaddrinfo", "connect timed out", "aborted", "Connection refused", "Name or service not known",
"No route to host", "Network is unreachable", "timed out",
]
///|
fn looks_unreachable(msg : String) -> Bool {
for marker in unreachable_markers {
if msg.contains(marker) {
return true
}
}
false
}
///|
/// Map an error onto an exit code and envelope.
///
/// Classification is structural where it can be: a `TransportError::Network` is
/// unreachable because of what it is, not because its text matched a regex.
pub fn classify(e : Error, url? : String) -> (Int, ErrorEnvelope) {
match e {
CliExit(code~, envelope~) => (code, envelope)
UsageError(msg) =>
(
exit_usage,
{ code: "error", message: msg, cause: None, status: None, url },
)
@client.Transport(t) => classify_transport(t, url)
@client.Protocol(rpc) =>
(
exit_usage,
{ code: "error", message: rpc.message, cause: None, status: None, url },
)
@client.InputRefused(..) as m =>
(
exit_usage,
{
code: "error",
message: m.describe_error(),
cause: None,
status: None,
url,
},
)
@client.InputRoundsExceeded(_) as m =>
(
exit_usage,
{
code: "error",
message: m.describe_error(),
cause: None,
status: None,
url,
},
)
@client.Mismatched(_) as m =>
(
exit_usage,
{
code: "error",
message: m.describe_error(),
cause: None,
status: None,
url,
},
)
@client.StreamEnded as m =>
(
exit_usage,
{
code: "error",
message: m.describe_error(),
cause: None,
status: None,
url,
},
)
@transport.Http(..) as t => classify_transport(t, url)
@transport.Network(_) as t => classify_transport(t, url)
@transport.Framing(_) as t => classify_transport(t, url)
@transport.Closed as t => classify_transport(t, url)
_ => {
let msg = e.to_string()
let code = if looks_unreachable(msg) {
exit_unreachable
} else {
exit_usage
}
let slug = if code == exit_unreachable { "unreachable" } else { "error" }
(code, { code: slug, message: msg, cause: None, status: None, url })
}
}
}
///|
fn classify_transport(
t : @transport.TransportError,
url : String?,
) -> (Int, ErrorEnvelope) {
match t {
@transport.Http(status~, body~, url=target) => {
// 401/403 mean "authenticate", which is its own exit code so CI can tell a
// credential problem from a broken server.
let code = if status == 401 || status == 403 {
exit_auth_required
} else {
exit_usage
}
let slug = if code == exit_auth_required {
"auth_required"
} else {
"error"
}
// The reference reports a non-200 with its raw body, which is how a 404
// carrying a JSON-RPC -32601 stays legible.
let message = match body {
Some(b) => "Error POSTing to endpoint: \{b.stringify()}"
None => "HTTP \{status} from \{target}"
}
(
code,
{
code: slug,
message,
cause: None,
status: Some(status),
url: Some(target),
},
)
}
@transport.Network(msg) =>
(
exit_unreachable,
{ code: "unreachable", message: msg, cause: None, status: None, url },
)
_ =>
(
exit_usage,
{
code: "error",
message: t.describe_error(),
cause: None,
status: None,
url,
},
)
}
}
///|
/// Write the result to stdout in the requested format.
///
/// `text` is pretty-printed JSON, `json` is a single-line `{"result": ...}` envelope
/// so the whole output pipes into jq.
pub async fn emit_result(
result : Json,
format : String,
app_info? : Json,
) -> Unit {
if format == "json" {
let envelope : Map[String, Json] = Map([])
envelope["result"] = result
match app_info {
Some(info) => envelope["appInfo"] = info
None => ()
}
@stdio.stdout.write(Json::object(envelope).stringify() + "\n")
} else {
@stdio.stdout.write(result.stringify(indent=2) + "\n")
}
}
///|
/// Write the one-line error envelope to stderr.
pub async fn emit_error(envelope : ErrorEnvelope) -> Unit {
@stdio.stderr.write(envelope.to_json().stringify() + "\n")
}