use rmcp::model::CallToolResult;
use salvor_core::SuspensionKind;
use serde_json::Value;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::outcome::{Sleep, Suspension};
const NAMESPACE: &str = "salvor";
const SUSPEND: &str = "suspend";
const SLEEP_UNTIL: &str = "sleep_until";
#[derive(Debug)]
pub(super) enum ParkRequest {
Suspend(Suspension),
Sleep(Sleep),
}
pub(super) fn park_request(result: &CallToolResult) -> Result<Option<ParkRequest>, String> {
let Some(namespace) = result
.meta
.as_ref()
.and_then(|meta| meta.0.get(NAMESPACE))
.filter(|value| !value.is_null())
else {
return Ok(None);
};
if result.is_error == Some(true) {
return Err("`_meta.salvor` asks to park the run on a result flagged `isError`. A call that failed parks nothing: return the park on a successful result, or drop the `_meta.salvor` key and let the failure stand".to_owned());
}
decode(namespace).map(Some)
}
fn decode(namespace: &Value) -> Result<ParkRequest, String> {
let Some(map) = namespace.as_object() else {
return Err(format!(
"`_meta.salvor` must be a JSON object naming one park request, got {}",
describe(namespace)
));
};
let suspend = map.get(SUSPEND).filter(|value| !value.is_null());
let sleep = map.get(SLEEP_UNTIL).filter(|value| !value.is_null());
for key in map.keys() {
if key != SUSPEND && key != SLEEP_UNTIL {
return Err(format!(
"`_meta.salvor` has an unknown key `{key}`. The only keys are `{SUSPEND}` and `{SLEEP_UNTIL}`"
));
}
}
match (suspend, sleep) {
(Some(_), Some(_)) => Err(format!(
"`_meta.salvor` names both `{SUSPEND}` and `{SLEEP_UNTIL}`. A run parks one way at a time: on an input it waits for, or on an instant it waits until"
)),
(Some(suspend), None) => decode_suspend(suspend).map(ParkRequest::Suspend),
(None, Some(sleep)) => decode_sleep(sleep).map(ParkRequest::Sleep),
(None, None) => Err(format!(
"`_meta.salvor` names no park request. It must carry exactly one of `{SUSPEND}` or `{SLEEP_UNTIL}`"
)),
}
}
fn decode_suspend(value: &Value) -> Result<Suspension, String> {
let Some(map) = value.as_object() else {
return Err(format!(
"`_meta.salvor.{SUSPEND}` must be a JSON object, got {}",
describe(value)
));
};
for key in map.keys() {
if !matches!(key.as_str(), "reason" | "input_schema" | "kind") {
return Err(format!(
"`_meta.salvor.{SUSPEND}` has an unknown key `{key}`. The keys are `reason`, `input_schema`, and an optional `kind`"
));
}
}
let reason = match map.get("reason") {
None | Some(Value::Null) => {
return Err(format!(
"`_meta.salvor.{SUSPEND}` is missing `reason`, the line the person or the operator reads to know what the run is waiting for"
));
}
Some(Value::String(reason)) if reason.trim().is_empty() => {
return Err(format!(
"`_meta.salvor.{SUSPEND}.reason` is empty. A park nobody can explain is a park nobody can answer"
));
}
Some(Value::String(reason)) => reason.clone(),
Some(other) => {
return Err(format!(
"`_meta.salvor.{SUSPEND}.reason` must be a string, got {}",
describe(other)
));
}
};
let input_schema = match map.get("input_schema") {
None | Some(Value::Null) => {
return Err(format!(
"`_meta.salvor.{SUSPEND}` is missing `input_schema`, the JSON Schema the resume input is validated against"
));
}
Some(schema) if schema.is_object() || schema.is_boolean() => schema.clone(),
Some(other) => {
return Err(format!(
"`_meta.salvor.{SUSPEND}.input_schema` must be a JSON Schema object, got {}",
describe(other)
));
}
};
let kind = match map.get("kind") {
None | Some(Value::Null) => None,
Some(kind) => Some(
serde_json::from_value::<SuspensionKind>(kind.clone()).map_err(|_| {
format!(
"`_meta.salvor.{SUSPEND}.kind` must be `\"signal\"` when present, got {}. Omit it for the ordinary case, a person deciding",
describe(kind)
)
})?,
),
};
Ok(Suspension {
reason,
input_schema,
kind,
})
}
fn decode_sleep(value: &Value) -> Result<Sleep, String> {
let Some(text) = value.as_str() else {
return Err(format!(
"`_meta.salvor.{SLEEP_UNTIL}` must be an RFC 3339 timestamp string, got {}",
describe(value)
));
};
let wake_at = OffsetDateTime::parse(text, &Rfc3339).map_err(|error| {
format!(
"`_meta.salvor.{SLEEP_UNTIL}` is not an RFC 3339 timestamp: `{text}` ({error}). A duration is not accepted here: the server holds the clock and states the instant"
)
})?;
Ok(Sleep::until(wake_at))
}
fn describe(value: &Value) -> String {
match value {
Value::Null => "null".to_owned(),
Value::Bool(flag) => format!("the boolean `{flag}`"),
Value::Number(number) => format!("the number `{number}`"),
Value::String(text) => format!("the string `{text}`"),
Value::Array(_) => "an array".to_owned(),
Value::Object(_) => "an object".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rmcp::model::{ContentBlock, Meta};
use serde_json::json;
fn with_namespace(value: Value) -> CallToolResult {
let mut meta = Meta::new();
meta.insert(NAMESPACE.to_owned(), value);
CallToolResult::success(vec![ContentBlock::text("waiting on the bank")])
.with_meta(Some(meta))
}
fn refusal(value: Value) -> String {
park_request(&with_namespace(value))
.err()
.unwrap_or_else(|| {
panic!("a malformed `_meta.salvor` is refused, never treated as output")
})
}
#[test]
fn a_result_without_the_namespace_is_not_a_park() {
let plain = CallToolResult::success(vec![ContentBlock::text("done")]);
assert!(matches!(park_request(&plain), Ok(None)));
let mut meta = Meta::new();
meta.insert("some.other.host".to_owned(), json!({"suspend": {}}));
let foreign = plain.with_meta(Some(meta));
assert!(
matches!(park_request(&foreign), Ok(None)),
"another host's `_meta` namespace is none of our business"
);
}
#[test]
fn the_two_shapes_decode() {
let schema = json!({"type": "object", "properties": {"paid": {"type": "boolean"}}});
let request = park_request(&with_namespace(json!({
"suspend": {"reason": "waiting on the webhook", "input_schema": schema, "kind": "signal"}
})))
.expect("a well-formed suspension decodes")
.expect("it is a park");
match request {
ParkRequest::Suspend(suspension) => {
assert_eq!(suspension.reason, "waiting on the webhook");
assert_eq!(suspension.input_schema, schema);
assert_eq!(suspension.kind, Some(SuspensionKind::Signal));
}
ParkRequest::Sleep(_) => panic!("a suspension request is not a sleep"),
}
let gate = park_request(&with_namespace(json!({
"suspend": {"reason": "needs sign-off", "input_schema": {"type": "object"}}
})))
.expect("a gate decodes")
.expect("it is a park");
match gate {
ParkRequest::Suspend(suspension) => assert_eq!(
suspension.kind, None,
"an unnamed kind is the human gate, as it is everywhere else"
),
ParkRequest::Sleep(_) => panic!("a suspension request is not a sleep"),
}
let sleep = park_request(&with_namespace(
json!({"sleep_until": "2026-08-14T11:00:00+02:00"}),
))
.expect("a well-formed sleep decodes")
.expect("it is a park");
match sleep {
ParkRequest::Sleep(sleep) => assert_eq!(
sleep.wake_at,
time::macros::datetime!(2026-08-14 09:00:00 UTC),
"the instant is the instant, whichever offset the server wrote it in"
),
ParkRequest::Suspend(_) => panic!("a sleep request is not a suspension"),
}
}
#[test]
fn every_malformed_request_names_the_key_and_the_problem() {
let schema = json!({"type": "object"});
for (namespace, expected) in [
(json!("suspend"), "must be a JSON object"),
(json!({}), "names no park request"),
(
json!({"suspend": {"reason": "r", "input_schema": schema}, "sleep_until": "2026-08-14T09:00:00Z"}),
"names both",
),
(
json!({"sleepUntil": "2026-08-14T09:00:00Z"}),
"unknown key `sleepUntil`",
),
(
json!({"sleep_until": "tomorrow"}),
"is not an RFC 3339 timestamp",
),
(
json!({"sleep_until": 1_800}),
"must be an RFC 3339 timestamp string",
),
(json!({"suspend": "please"}), "must be a JSON object"),
(
json!({"suspend": {"input_schema": schema}}),
"is missing `reason`",
),
(
json!({"suspend": {"reason": " ", "input_schema": schema}}),
"is empty",
),
(
json!({"suspend": {"reason": 7, "input_schema": schema}}),
"must be a string",
),
(
json!({"suspend": {"reason": "r"}}),
"is missing `input_schema`",
),
(
json!({"suspend": {"reason": "r", "input_schema": "an object please"}}),
"must be a JSON Schema object",
),
(
json!({"suspend": {"reason": "r", "input_schema": schema, "kind": "webhook"}}),
"must be `\"signal\"` when present",
),
(
json!({"suspend": {"reason": "r", "inputSchema": schema}}),
"unknown key `inputSchema`",
),
] {
let message = refusal(namespace.clone());
assert!(
message.starts_with("`_meta.salvor"),
"the message leads with the key, got: {message}"
);
assert!(
message.contains(expected),
"expected `{expected}` in the refusal of {namespace}, got: {message}"
);
}
}
#[test]
fn a_failed_result_may_not_park() {
let mut meta = Meta::new();
meta.insert(
NAMESPACE.to_owned(),
json!({"sleep_until": "2026-08-14T09:00:00Z"}),
);
let result = CallToolResult::error(vec![ContentBlock::text("the bank refused")])
.with_meta(Some(meta));
let message = park_request(&result).expect_err("a failing call parks nothing");
assert!(
message.contains("`isError`") && message.starts_with("`_meta.salvor`"),
"the refusal names both halves of the contradiction, got: {message}"
);
}
}