use std::sync::{Arc, Mutex};
use protocol_droid::adapters::secondary::codec::result::WireToolResults;
use protocol_droid::application::bridge::{Bridge, BridgeError, BridgePeer, Forwarded, SendFuture};
use protocol_droid::domain::ir::{LowerError, ToolResult};
use protocol_droid::domain::{
Agreement, CapabilitySet, DegradePolicy, Feature, Leg, Negotiation, ProtocolVersion,
TransportKind, resolve,
};
use serde_json::{Map, Value, json};
type TestError = Box<dyn std::error::Error>;
#[derive(Clone)]
struct Upstream {
reply: Map<String, Value>,
sent: Arc<Mutex<Vec<Map<String, Value>>>>,
}
impl Upstream {
fn answering(reply: &str) -> Result<Self, TestError> {
Ok(Self {
reply: serde_json::from_str(reply)?,
sent: Arc::new(Mutex::new(Vec::new())),
})
}
fn sent(&self) -> Result<Vec<Map<String, Value>>, TestError> {
self.sent
.lock()
.map(|sent| sent.clone())
.map_err(|_| -> TestError { "the recorder was poisoned".into() })
}
}
impl BridgePeer for Upstream {
fn send(
&self,
envelope: Map<String, Value>,
) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>> {
Box::pin(async move {
self.sent
.lock()
.map_err(|_| BridgeError::Close("the recorder was poisoned".to_owned()))?
.push(envelope);
Ok(self.reply.clone())
})
}
fn notify(&self, envelope: Map<String, Value>) -> SendFuture<'_, Result<(), BridgeError>> {
Box::pin(async move {
self.send(envelope).await?;
Ok(())
})
}
fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
Box::pin(async { Ok(()) })
}
}
fn full(version: ProtocolVersion) -> Leg {
let caps = CapabilitySet::all_for(version);
Leg::new(
version,
resolve(Negotiation {
version,
client_caps: caps,
server_caps: caps,
transport: TransportKind::Stdio,
}),
)
}
fn call(tool: &str) -> Result<Map<String, Value>, TestError> {
Ok(serde_json::from_value(json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": tool }
}))?)
}
fn bridge_over(
client: Leg,
server: Leg,
policy: DegradePolicy,
reply: &str,
) -> Result<(Bridge<Upstream, WireToolResults>, Upstream), TestError> {
let upstream = Upstream::answering(reply)?;
let bridge = Bridge::new(
Agreement::between(client, server),
policy,
upstream.clone(),
WireToolResults,
);
Ok((bridge, upstream))
}
#[tokio::test]
async fn a_structured_result_reaches_an_older_client_as_text() -> Result<(), TestError> {
let client = full(ProtocolVersion::V2025_03_26);
let (bridge, _) = bridge_over(
client,
full(ProtocolVersion::V2025_06_18),
DegradePolicy::permissive(),
r#"{"jsonrpc":"2.0","id":1,"result":{
"content":[{"type":"text","text":"answer"}],
"structuredContent":{"a":1}}}"#,
)?;
let forwarded = bridge.forward(&call("echo")?).await?;
let result = forwarded
.reply
.get("result")
.ok_or("the reply carried no result")?;
assert!(
result.get("structuredContent").is_none(),
"the client negotiated a revision with no field for it: {result}"
);
let blocks = result
.get("content")
.and_then(Value::as_array)
.ok_or("the lowered result had no content array")?;
let serialised = blocks
.iter()
.filter_map(|block| block.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("");
assert!(
serialised.contains("\"a\":1"),
"the structured content must survive as text: {serialised}"
);
assert!(
!forwarded.report.is_empty(),
"a downgrade that is not recorded is a silent loss"
);
Ok(())
}
#[tokio::test]
async fn a_strict_policy_refuses_a_result_the_client_cannot_read() -> Result<(), TestError> {
let client = full(ProtocolVersion::V2025_03_26);
let (bridge, _) = bridge_over(
client,
full(ProtocolVersion::V2025_06_18),
DegradePolicy::strict(),
r#"{"jsonrpc":"2.0","id":1,"result":{
"content":[{"type":"text","text":"answer"}],
"structuredContent":{"a":1}}}"#,
)?;
let outcome = bridge.forward(&call("echo")?).await;
assert!(
matches!(outcome, Err(BridgeError::NotReadable(_))),
"expected a refusal, got {outcome:?}"
);
let Err(BridgeError::NotReadable(cause)) = outcome else {
return Err("expected a refusal, got something else".into());
};
assert!(
matches!(
cause,
protocol_droid::domain::ir::LowerError::FeatureUnavailable {
feature: Feature::ToolStructuredOutput,
..
}
),
"the refusal must name the feature: {cause}"
);
Ok(())
}
#[tokio::test]
async fn a_reply_the_client_can_read_is_handed_back_byte_identical() -> Result<(), TestError> {
let reply = r#"{"jsonrpc":"2.0","id":1,"result":{
"content":[{"type":"text","text":"answer","annotations":{"audience":["assistant"]}}],
"isError":false,
"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"probe","version":"1"}},
"x-vendor-directive":{"kept":[1,2,3]}}}"#;
let (bridge, _) = bridge_over(
full(ProtocolVersion::V2026_07_28),
full(ProtocolVersion::V2026_07_28),
DegradePolicy::strict(),
reply,
)?;
let sent: Map<String, Value> = serde_json::from_str(reply)?;
let forwarded = bridge.forward(&call("echo")?).await?;
assert_eq!(
forwarded.reply, sent,
"a reply with nothing to translate must not be rebuilt"
);
assert!(
forwarded.report.is_empty(),
"nothing was decided, so nothing should be recorded"
);
Ok(())
}
#[tokio::test]
async fn a_result_this_build_cannot_read_is_forwarded_untouched() -> Result<(), TestError> {
let reply = r#"{"jsonrpc":"2.0","id":1,"result":{
"content":[{"type":"text","text":"answer"},
{"type":"annotation","audience":["assistant"],"priority":0.5}]}}"#;
let (bridge, _) = bridge_over(
full(ProtocolVersion::V2025_03_26),
full(ProtocolVersion::V2026_07_28),
DegradePolicy::permissive(),
reply,
)?;
let sent: Map<String, Value> = serde_json::from_str(reply)?;
let forwarded = bridge.forward(&call("echo")?).await?;
assert_eq!(
forwarded.reply, sent,
"a block this build cannot model must be forwarded, not dropped"
);
Ok(())
}
#[tokio::test]
async fn a_reply_that_is_not_a_tool_result_is_never_rebuilt() -> Result<(), TestError> {
let reply = r#"{"jsonrpc":"2.0","id":1,"result":[{"name":"echo"}]}"#;
let (bridge, _) = bridge_over(
full(ProtocolVersion::V2025_03_26),
full(ProtocolVersion::V2026_07_28),
DegradePolicy::permissive(),
reply,
)?;
let sent: Map<String, Value> = serde_json::from_str(reply)?;
let listed: Map<String, Value> = serde_json::from_value(json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}))?;
let forwarded = bridge.forward(&listed).await?;
assert_eq!(forwarded.reply, sent);
assert!(forwarded.report.is_empty());
Ok(())
}
#[tokio::test]
async fn an_error_reply_is_handed_back_untouched() -> Result<(), TestError> {
let reply = r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"no such tool"}}"#;
let (bridge, _) = bridge_over(
full(ProtocolVersion::V2025_03_26),
full(ProtocolVersion::V2026_07_28),
DegradePolicy::strict(),
reply,
)?;
let sent: Map<String, Value> = serde_json::from_str(reply)?;
let forwarded = bridge.forward(&call("absent")?).await?;
assert_eq!(forwarded.reply, sent);
Ok(())
}
#[tokio::test]
async fn the_notification_path_never_translates() -> Result<(), TestError> {
let (bridge, upstream) = bridge_over(
full(ProtocolVersion::V2025_03_26),
full(ProtocolVersion::V2026_07_28),
DegradePolicy::permissive(),
r#"{"jsonrpc":"2.0","id":1,"result":{"content":[],"structuredContent":{"a":1}}}"#,
)?;
let announced: Map<String, Value> = serde_json::from_value(json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
}))?;
let forwarded: Forwarded = bridge.forward(&announced).await?;
assert!(
forwarded.reply.is_empty(),
"a notification has no reply to carry"
);
assert!(forwarded.report.is_empty());
assert_eq!(
upstream.sent()?.len(),
1,
"the notification was still forwarded"
);
Ok(())
}
#[tokio::test]
async fn a_plain_result_crosses_the_boundary_untouched() -> Result<(), TestError> {
let reply = r#"{"jsonrpc":"2.0","id":1,"result":{
"content":[{"type":"text","text":"answer"}],"isError":false}}"#;
let (bridge, _) = bridge_over(
full(ProtocolVersion::V2026_07_28),
full(ProtocolVersion::V2025_03_26),
DegradePolicy::strict(),
reply,
)?;
let sent: Map<String, Value> = serde_json::from_str(reply)?;
let forwarded = bridge.forward(&call("echo")?).await?;
assert_eq!(forwarded.reply, sent);
assert!(forwarded.report.is_empty());
Ok(())
}
#[tokio::test]
async fn a_result_type_has_no_fallback_and_is_refused_not_dropped() -> Result<(), TestError> {
let reply = r#"{"jsonrpc":"2.0","id":1,"result":{
"content":[{"type":"text","text":"answer"}],"resultType":"search"}}"#;
let (bridge, _) = bridge_over(
full(ProtocolVersion::V2025_03_26),
full(ProtocolVersion::V2026_07_28),
DegradePolicy::permissive(),
reply,
)?;
let outcome = bridge.forward(&call("search")?).await;
let Err(BridgeError::NotReadable(cause)) = outcome else {
return Err("a result with no defined fallback must not be delivered".into());
};
assert!(
matches!(
cause,
LowerError::NoRuleForFeature {
feature: Feature::ToolResultType
}
),
"the refusal must name the feature it has no rule for: {cause}"
);
Ok(())
}
#[test]
fn the_scenario_is_about_a_tool_result() -> Result<(), TestError> {
let result = ToolResult::text("answer").with_structured(r#"{"a":1}"#)?;
assert_eq!(result.content.len(), 1);
Ok(())
}