use std::collections::BTreeMap;
use serde_json::Value;
use crate::domain::a2a::agent_card::AgentSkill;
use crate::domain::a2a::message::{DataPart, FilePart, Message, Part, TextPart};
use crate::domain::ir::{
AudioContent, Content, EmbeddedResource, ExtensionValue, Extensions, ImageContent, JsonObject,
JsonObjectError, ResourceLinkContent, TextContent,
};
use crate::domain::registry::primitive::{Tool, ToolAnnotations};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Protocol {
A2a,
Mcp,
}
impl Protocol {
#[must_use]
pub const fn other(self) -> Self {
match self {
Self::A2a => Self::Mcp,
Self::Mcp => Self::A2a,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Direction {
ToMcp,
ToA2a,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StepKind {
PassThrough,
Renamed {
from: Option<&'static str>,
to: Option<&'static str>,
},
Dropped {
from: Option<&'static str>,
},
Synthesised {
to: Option<&'static str>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Step {
pub direction: Direction,
pub source: Option<Protocol>,
pub note: StepKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CrossProtocolReport {
steps: Vec<Step>,
}
impl CrossProtocolReport {
#[must_use]
pub const fn new() -> Self {
Self { steps: Vec::new() }
}
pub fn record(&mut self, direction: Direction, source: Option<Protocol>, note: StepKind) {
self.steps.push(Step {
direction,
source,
note,
});
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.steps.is_empty()
}
#[must_use]
pub const fn len(&self) -> usize {
self.steps.len()
}
#[must_use]
pub fn steps(&self) -> &[Step] {
&self.steps
}
pub fn merge(&mut self, other: Self) {
self.steps.extend(other.steps);
}
}
impl core::fmt::Display for CrossProtocolReport {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
if self.steps.is_empty() {
return formatter.write_str("cross-protocol translation: no changes");
}
for (index, step) in self.steps.iter().enumerate() {
if index > 0 {
formatter.write_str("; ")?;
}
core::fmt::Display::fmt(step, formatter)?;
}
Ok(())
}
}
impl core::fmt::Display for Step {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let direction = match self.direction {
Direction::ToMcp => "a2a→mcp",
Direction::ToA2a => "mcp→a2a",
};
let source = self
.source
.map_or_else(|| "neutral".to_owned(), |p| format!("{p:?}").to_lowercase());
write!(formatter, "{direction}@{source}: {}", self.note)
}
}
impl core::fmt::Display for StepKind {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::PassThrough => formatter.write_str("pass-through"),
Self::Renamed { from, to } => {
let from = from.unwrap_or("?");
let to = to.unwrap_or("?");
write!(formatter, "renamed `{from}` → `{to}`")
}
Self::Dropped { from } => {
let from = from.unwrap_or("?");
write!(formatter, "dropped `{from}`")
}
Self::Synthesised { to } => {
let to = to.unwrap_or("?");
write!(formatter, "synthesised `{to}`")
}
}
}
}
pub fn a2a_skill_to_mcp_tool(
skill: &AgentSkill,
report: &mut CrossProtocolReport,
) -> Result<Tool, JsonObjectError> {
JsonObject::new(r#"{"type":"object"}"#)?;
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Synthesised {
to: Some("inputSchema"),
},
);
let mut extensions = Extensions::empty();
if !skill.tags.is_empty() {
let tags_value = Value::Array(
skill
.tags
.iter()
.map(|tag| Value::String(tag.to_string()))
.collect(),
);
extensions.insert("tags".to_owned(), ExtensionValue::from(tags_value));
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Renamed {
from: Some("tags"),
to: Some("extensions[\"tags\"]"),
},
);
}
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Renamed {
from: Some("id"),
to: Some("name"),
},
);
let title = if skill.name.as_ref().is_empty() {
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Dropped { from: Some("name") },
);
None
} else {
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Renamed {
from: Some("name"),
to: Some("title"),
},
);
Some(skill.name.to_string())
};
let description = if skill.description.as_ref().is_empty() {
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Dropped {
from: Some("description"),
},
);
None
} else {
report.record(Direction::ToMcp, Some(Protocol::A2a), StepKind::PassThrough);
Some(skill.description.to_string())
};
let mut tool = Tool::new(skill.id.as_ref(), r#"{"type":"object"}"#)?;
tool.title = title;
tool.description = description;
tool.annotations = Some(ToolAnnotations::default());
tool.extensions = extensions;
Ok(tool)
}
pub fn a2a_message_to_mcp_content(
message: &Message,
report: &mut CrossProtocolReport,
) -> Vec<Content> {
let mut out = Vec::with_capacity(message.parts.len());
for part in &message.parts {
match part {
Part::Text(text) => {
out.push(Content::Text(TextContent::new(text.text.clone())));
report.record(Direction::ToMcp, Some(Protocol::A2a), StepKind::PassThrough);
}
Part::File(file) => {
if let Some(block) = a2a_file_to_mcp_content(file, report) {
out.push(block);
}
}
Part::Data(data) => {
let value = data.data.clone();
let block = EmbeddedResource {
text: Some(value.to_string()),
blob: None,
uri: format!("data:application/json,{value}"),
mime_type: Some("application/json".to_owned()),
annotations: None,
extensions: Extensions::empty(),
};
out.push(Content::EmbeddedResource(block));
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Renamed {
from: Some("DataPart"),
to: Some("EmbeddedResource"),
},
);
}
}
}
out
}
fn a2a_file_to_mcp_content(file: &FilePart, report: &mut CrossProtocolReport) -> Option<Content> {
if let Some(uri) = &file.file.uri {
let mut link = ResourceLinkContent::new(uri.clone(), uri.clone());
link.mime_type.clone_from(&file.file.mime_type);
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Renamed {
from: Some("FilePart.uri"),
to: Some("ResourceLink"),
},
);
Some(Content::ResourceLink(link))
} else if let Some(bytes) = &file.file.bytes {
let mime = file
.file
.mime_type
.as_deref()
.unwrap_or("application/octet-stream");
if mime.starts_with("image/") {
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Renamed {
from: Some("FilePart.bytes(image)"),
to: Some("ImageContent"),
},
);
Some(Content::Image(ImageContent::new(
bytes.clone(),
mime.to_owned(),
)))
} else if mime.starts_with("audio/") {
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Renamed {
from: Some("FilePart.bytes(audio)"),
to: Some("AudioContent"),
},
);
Some(Content::Audio(AudioContent::new(
bytes.clone(),
mime.to_owned(),
)))
} else {
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Dropped {
from: Some("FilePart.bytes"),
},
);
None
}
} else {
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Dropped {
from: Some("FilePart (empty)"),
},
);
None
}
}
pub fn a2a_message_to_mcp_text(
message: &Message,
report: &mut CrossProtocolReport,
) -> Option<TextContent> {
let mut joined = String::new();
let mut saw_non_text = false;
for part in &message.parts {
match part {
Part::Text(text) => {
if !joined.is_empty() {
joined.push('\n');
}
joined.push_str(&text.text);
}
Part::File(_) | Part::Data(_) => saw_non_text = true,
}
}
if saw_non_text {
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Dropped {
from: Some("non-text parts"),
},
);
}
if joined.is_empty() {
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Dropped {
from: Some("text parts"),
},
);
return None;
}
Some(TextContent::new(joined))
}
pub fn mcp_content_to_a2a_part(
content: &Content,
report: &mut CrossProtocolReport,
) -> Option<Part> {
match content {
Content::Text(text) => {
report.record(Direction::ToA2a, Some(Protocol::Mcp), StepKind::PassThrough);
Some(Part::Text(TextPart {
text: text.text.clone(),
metadata: BTreeMap::new(),
}))
}
Content::Image(image) => {
report.record(
Direction::ToA2a,
Some(Protocol::Mcp),
StepKind::Renamed {
from: Some("ImageContent"),
to: Some("FilePart(bytes, image/*)"),
},
);
Some(Part::File(FilePart {
file: a2a_bytes_file(image.mime_type.clone(), image.data.clone()),
metadata: BTreeMap::new(),
}))
}
Content::Audio(audio) => {
report.record(
Direction::ToA2a,
Some(Protocol::Mcp),
StepKind::Renamed {
from: Some("AudioContent"),
to: Some("FilePart(bytes, audio/*)"),
},
);
Some(Part::File(FilePart {
file: a2a_bytes_file(audio.mime_type.clone(), audio.data.clone()),
metadata: BTreeMap::new(),
}))
}
Content::ResourceLink(link) => {
report.record(
Direction::ToA2a,
Some(Protocol::Mcp),
StepKind::Renamed {
from: Some("ResourceLink"),
to: Some("FilePart(uri)"),
},
);
Some(Part::File(FilePart {
file: a2a_uri_file(link.uri.clone(), link.mime_type.clone()),
metadata: BTreeMap::new(),
}))
}
Content::EmbeddedResource(resource) => {
let Ok(value) = serde_json::from_str::<Value>(&resource.uri) else {
report.record(
Direction::ToA2a,
Some(Protocol::Mcp),
StepKind::Dropped {
from: Some("EmbeddedResource.uri"),
},
);
return None;
};
let Value::Object(map) = value else {
report.record(
Direction::ToA2a,
Some(Protocol::Mcp),
StepKind::Dropped {
from: Some("EmbeddedResource.uri (not an object)"),
},
);
return None;
};
report.record(
Direction::ToA2a,
Some(Protocol::Mcp),
StepKind::Renamed {
from: Some("EmbeddedResource"),
to: Some("DataPart"),
},
);
Some(Part::Data(DataPart {
data: Value::Object(map),
metadata: BTreeMap::new(),
}))
}
Content::Unknown(_) => {
report.record(
Direction::ToA2a,
Some(Protocol::Mcp),
StepKind::Dropped {
from: Some("Unknown"),
},
);
None
}
}
}
const fn a2a_bytes_file(mime: String, bytes: String) -> crate::domain::a2a::message::FileContent {
crate::domain::a2a::message::FileContent {
name: None,
mime_type: Some(mime),
bytes: Some(bytes),
uri: None,
}
}
const fn a2a_uri_file(
uri: String,
mime: Option<String>,
) -> crate::domain::a2a::message::FileContent {
crate::domain::a2a::message::FileContent {
name: None,
mime_type: mime,
bytes: None,
uri: Some(uri),
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::domain::a2a::message::{FileContent, FilePart, MessageRole};
enum Failure {
Skill(crate::domain::ir::JsonObjectError),
Mismatch(String),
}
impl core::fmt::Display for Failure {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Skill(err) => core::fmt::Display::fmt(err, formatter),
Self::Mismatch(message) => formatter.write_str(message),
}
}
}
impl core::fmt::Debug for Failure {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
core::fmt::Debug::fmt(&self.mismatch_or_skill(), formatter)
}
}
impl Failure {
fn mismatch_or_skill(&self) -> String {
match self {
Self::Skill(err) => format!("{err}"),
Self::Mismatch(message) => message.clone(),
}
}
}
impl From<crate::domain::ir::JsonObjectError> for Failure {
fn from(err: crate::domain::ir::JsonObjectError) -> Self {
Self::Skill(err)
}
}
fn skill(id: &str, name: &str, description: &str, tags: &[&str]) -> AgentSkill {
AgentSkill {
id: Arc::from(id),
name: Arc::from(name),
description: Arc::from(description),
tags: tags.iter().map(|t| Arc::from(*t)).collect(),
}
}
#[test]
fn skill_to_tool_maps_required_fields_and_records_steps() -> Result<(), Failure> {
let s = skill("lookup", "Lookup", "Look up a record", &["search", "read"]);
let mut report = CrossProtocolReport::new();
let tool = a2a_skill_to_mcp_tool(&s, &mut report)?;
if tool.name.as_str() != "lookup" {
return Err(Failure::Mismatch(format!(
"expected tool.name 'lookup', got {:?}",
tool.name
)));
}
if tool.title.as_deref() != Some("Lookup") {
return Err(Failure::Mismatch(format!(
"expected tool.title 'Lookup', got {:?}",
tool.title
)));
}
if tool.description.as_deref() != Some("Look up a record") {
return Err(Failure::Mismatch(format!(
"expected tool.description, got {:?}",
tool.description
)));
}
if report.is_empty() {
return Err(Failure::Mismatch(
"expected the steps to be recorded, got an empty report".to_owned(),
));
}
if tool.extensions.get("tags").is_none() {
return Err(Failure::Mismatch(
"expected tags to be recorded in extensions".to_owned(),
));
}
Ok(())
}
#[test]
fn skill_to_tool_with_empty_name_drops_title() -> Result<(), Failure> {
let s = skill("lookup", "", "Look up a record", &[]);
let mut report = CrossProtocolReport::new();
let tool = a2a_skill_to_mcp_tool(&s, &mut report)?;
if tool.title.is_some() {
return Err(Failure::Mismatch("expected title to be None".to_owned()));
}
if !report
.steps()
.iter()
.any(|s| matches!(&s.note, StepKind::Dropped { from: Some("name") }))
{
return Err(Failure::Mismatch(
"expected a Dropped(name) step".to_owned(),
));
}
Ok(())
}
#[test]
fn skill_to_tool_with_empty_description_drops_description() -> Result<(), Failure> {
let s = skill("lookup", "Lookup", "", &[]);
let mut report = CrossProtocolReport::new();
let tool = a2a_skill_to_mcp_tool(&s, &mut report)?;
if tool.description.is_some() {
return Err(Failure::Mismatch(
"expected description to be None".to_owned(),
));
}
if !report.steps().iter().any(|s| {
matches!(
&s.note,
StepKind::Dropped {
from: Some("description")
}
)
}) {
return Err(Failure::Mismatch(
"expected a Dropped(description) step".to_owned(),
));
}
Ok(())
}
#[test]
fn message_to_content_text_round_trips() -> Result<(), Failure> {
let m = Message {
role: MessageRole::User,
parts: vec![Part::Text(TextPart {
text: "hello".to_owned(),
metadata: BTreeMap::new(),
})],
metadata: BTreeMap::new(),
};
let mut report = CrossProtocolReport::new();
let blocks = a2a_message_to_mcp_content(&m, &mut report);
if blocks.len() != 1 {
return Err(Failure::Mismatch(format!(
"expected 1 block, got {}",
blocks.len()
)));
}
let Some(first) = blocks.first() else {
return Err(Failure::Mismatch(
"expected at least one content block".to_owned(),
));
};
let Content::Text(t) = first else {
return Err(Failure::Mismatch(format!("expected Text, got {first:?}")));
};
if t.text != "hello" {
return Err(Failure::Mismatch(format!(
"expected 'hello', got {:?}",
t.text
)));
}
if report.is_empty() {
return Err(Failure::Mismatch("expected a pass-through step".to_owned()));
}
Ok(())
}
#[test]
fn message_to_content_image_file_becomes_image_content() -> Result<(), Failure> {
let file = FilePart {
file: FileContent {
name: None,
mime_type: Some("image/png".to_owned()),
bytes: Some("AAAA".to_owned()),
uri: None,
},
metadata: BTreeMap::new(),
};
let m = Message {
role: MessageRole::User,
parts: vec![Part::File(file)],
metadata: BTreeMap::new(),
};
let mut report = CrossProtocolReport::new();
let blocks = a2a_message_to_mcp_content(&m, &mut report);
if !blocks.iter().any(|b| matches!(b, Content::Image(_))) {
return Err(Failure::Mismatch(
"expected at least one ImageContent block".to_owned(),
));
}
if !report.steps().iter().any(|s| {
matches!(
&s.note,
StepKind::Renamed {
from: Some(_),
to: Some("ImageContent")
}
)
}) {
return Err(Failure::Mismatch(
"expected a Renamed → ImageContent step".to_owned(),
));
}
Ok(())
}
#[test]
fn message_to_content_uri_file_becomes_resource_link() -> Result<(), Failure> {
let file = FilePart {
file: FileContent {
name: None,
mime_type: Some("application/pdf".to_owned()),
bytes: None,
uri: Some("https://example.com/x".to_owned()),
},
metadata: BTreeMap::new(),
};
let m = Message {
role: MessageRole::User,
parts: vec![Part::File(file)],
metadata: BTreeMap::new(),
};
let mut report = CrossProtocolReport::new();
let blocks = a2a_message_to_mcp_content(&m, &mut report);
if !blocks.iter().any(|b| matches!(b, Content::ResourceLink(_))) {
return Err(Failure::Mismatch(
"expected at least one ResourceLink block".to_owned(),
));
}
Ok(())
}
#[test]
fn message_to_content_unknown_file_type_is_dropped() -> Result<(), Failure> {
let file = FilePart {
file: FileContent {
name: None,
mime_type: Some("application/x-binary".to_owned()),
bytes: Some("AAAA".to_owned()),
uri: None,
},
metadata: BTreeMap::new(),
};
let m = Message {
role: MessageRole::User,
parts: vec![Part::File(file)],
metadata: BTreeMap::new(),
};
let mut report = CrossProtocolReport::new();
let blocks = a2a_message_to_mcp_content(&m, &mut report);
if !blocks.is_empty() {
return Err(Failure::Mismatch(format!(
"expected no blocks, got {}",
blocks.len()
)));
}
if !report.steps().iter().any(|s| {
matches!(
&s.note,
StepKind::Dropped {
from: Some("FilePart.bytes")
}
)
}) {
return Err(Failure::Mismatch(
"expected a Dropped(FilePart.bytes) step".to_owned(),
));
}
Ok(())
}
#[test]
fn message_to_content_data_becomes_embedded_resource() -> Result<(), Failure> {
let mut map = serde_json::Map::new();
map.insert("k".to_owned(), Value::String("v".to_owned()));
let m = Message {
role: MessageRole::User,
parts: vec![Part::Data(DataPart {
data: Value::Object(map),
metadata: BTreeMap::new(),
})],
metadata: BTreeMap::new(),
};
let mut report = CrossProtocolReport::new();
let blocks = a2a_message_to_mcp_content(&m, &mut report);
if !blocks
.iter()
.any(|b| matches!(b, Content::EmbeddedResource(_)))
{
return Err(Failure::Mismatch(
"expected an EmbeddedResource block".to_owned(),
));
}
Ok(())
}
#[test]
fn message_to_text_joins_text_parts_and_drops_others() -> Result<(), Failure> {
let m = Message {
role: MessageRole::User,
parts: vec![
Part::Text(TextPart {
text: "first".to_owned(),
metadata: BTreeMap::new(),
}),
Part::Text(TextPart {
text: "second".to_owned(),
metadata: BTreeMap::new(),
}),
Part::File(FilePart {
file: FileContent {
name: None,
mime_type: None,
bytes: None,
uri: Some("https://example.com/x".to_owned()),
},
metadata: BTreeMap::new(),
}),
],
metadata: BTreeMap::new(),
};
let mut report = CrossProtocolReport::new();
let joined = a2a_message_to_mcp_text(&m, &mut report);
let Some(joined) = joined else {
return Err(Failure::Mismatch("expected Some(text)".to_owned()));
};
if joined.text != "first\nsecond" {
return Err(Failure::Mismatch(format!(
"expected 'first\\nsecond', got {:?}",
joined.text
)));
}
if !report.steps().iter().any(|s| {
matches!(
&s.note,
StepKind::Dropped {
from: Some("non-text parts")
}
)
}) {
return Err(Failure::Mismatch(
"expected a Dropped(non-text parts) step".to_owned(),
));
}
Ok(())
}
#[test]
fn message_to_text_with_only_non_text_returns_none() -> Result<(), Failure> {
let m = Message {
role: MessageRole::User,
parts: vec![Part::File(FilePart {
file: FileContent {
name: None,
mime_type: None,
bytes: None,
uri: Some("https://example.com/x".to_owned()),
},
metadata: BTreeMap::new(),
})],
metadata: BTreeMap::new(),
};
let mut report = CrossProtocolReport::new();
let joined = a2a_message_to_mcp_text(&m, &mut report);
if joined.is_some() {
return Err(Failure::Mismatch("expected None".to_owned()));
}
Ok(())
}
#[test]
fn content_to_part_text_round_trips() -> Result<(), Failure> {
let block = Content::Text(TextContent::new("hi"));
let mut report = CrossProtocolReport::new();
let part = mcp_content_to_a2a_part(&block, &mut report);
let Some(Part::Text(text)) = part else {
return Err(Failure::Mismatch(format!(
"expected TextPart, got {part:?}"
)));
};
if text.text != "hi" {
return Err(Failure::Mismatch(format!(
"expected 'hi', got {:?}",
text.text
)));
}
Ok(())
}
#[test]
fn content_to_part_image_becomes_file_part() -> Result<(), Failure> {
let block = Content::Image(ImageContent::new("AAAA".to_owned(), "image/png".to_owned()));
let mut report = CrossProtocolReport::new();
let part = mcp_content_to_a2a_part(&block, &mut report);
let Some(Part::File(f)) = part else {
return Err(Failure::Mismatch(format!(
"expected FilePart, got {part:?}"
)));
};
if f.file.mime_type.as_deref() != Some("image/png") {
return Err(Failure::Mismatch(format!(
"expected image/png, got {:?}",
f.file.mime_type
)));
}
if f.file.bytes.is_none() {
return Err(Failure::Mismatch("expected bytes".to_owned()));
}
if f.file.uri.is_some() {
return Err(Failure::Mismatch("expected no uri".to_owned()));
}
Ok(())
}
#[test]
fn content_to_part_resource_link_becomes_uri_file() -> Result<(), Failure> {
let mut link = ResourceLinkContent::new("https://example.com/x".to_owned(), "x".to_owned());
link.mime_type = Some("application/pdf".to_owned());
let block = Content::ResourceLink(link);
let mut report = CrossProtocolReport::new();
let part = mcp_content_to_a2a_part(&block, &mut report);
let Some(Part::File(f)) = part else {
return Err(Failure::Mismatch(format!(
"expected FilePart, got {part:?}"
)));
};
if f.file.uri.as_deref() != Some("https://example.com/x") {
return Err(Failure::Mismatch(format!(
"expected uri, got {:?}",
f.file.uri
)));
}
if f.file.bytes.is_some() {
return Err(Failure::Mismatch("expected no bytes".to_owned()));
}
Ok(())
}
#[test]
fn report_merge_combines_steps() -> Result<(), Failure> {
let mut a = CrossProtocolReport::new();
a.record(Direction::ToMcp, Some(Protocol::A2a), StepKind::PassThrough);
let mut b = CrossProtocolReport::new();
b.record(
Direction::ToA2a,
Some(Protocol::Mcp),
StepKind::Dropped { from: Some("x") },
);
a.merge(b);
if a.len() != 2 {
return Err(Failure::Mismatch(format!(
"expected 2 steps after merge, got {}",
a.len()
)));
}
Ok(())
}
#[test]
fn report_display_shows_steps() -> Result<(), Failure> {
let mut report = CrossProtocolReport::new();
report.record(
Direction::ToMcp,
Some(Protocol::A2a),
StepKind::Dropped { from: Some("name") },
);
let s = report.to_string();
if !s.contains("a2a→mcp") {
return Err(Failure::Mismatch(
"expected 'a2a→mcp' in the report".to_owned(),
));
}
if !s.contains("dropped") {
return Err(Failure::Mismatch(
"expected 'dropped' in the report".to_owned(),
));
}
Ok(())
}
#[test]
fn protocol_other_swaps() -> Result<(), Failure> {
if Protocol::A2a.other() != Protocol::Mcp {
return Err(Failure::Mismatch("A2a.other() should be Mcp".to_owned()));
}
if Protocol::Mcp.other() != Protocol::A2a {
return Err(Failure::Mismatch("Mcp.other() should be A2a".to_owned()));
}
Ok(())
}
}