1use std::collections::BTreeMap;
35
36use serde_json::Value;
37
38use crate::domain::a2a::agent_card::AgentSkill;
39use crate::domain::a2a::message::{DataPart, FilePart, Message, Part, TextPart};
40use crate::domain::ir::{
41 AudioContent, Content, EmbeddedResource, ExtensionValue, Extensions, ImageContent, JsonObject,
42 JsonObjectError, ResourceLinkContent, TextContent,
43};
44use crate::domain::registry::primitive::{Tool, ToolAnnotations};
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum Protocol {
49 A2a,
52 Mcp,
54}
55
56impl Protocol {
57 #[must_use]
59 pub const fn other(self) -> Self {
60 match self {
61 Self::A2a => Self::Mcp,
62 Self::Mcp => Self::A2a,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub enum Direction {
70 ToMcp,
72 ToA2a,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum StepKind {
85 PassThrough,
87 Renamed {
90 from: Option<&'static str>,
92 to: Option<&'static str>,
94 },
95 Dropped {
98 from: Option<&'static str>,
100 },
101 Synthesised {
105 to: Option<&'static str>,
107 },
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct Step {
113 pub direction: Direction,
115 pub source: Option<Protocol>,
118 pub note: StepKind,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Default)]
124pub struct CrossProtocolReport {
125 steps: Vec<Step>,
126}
127
128impl CrossProtocolReport {
129 #[must_use]
131 pub const fn new() -> Self {
132 Self { steps: Vec::new() }
133 }
134
135 pub fn record(&mut self, direction: Direction, source: Option<Protocol>, note: StepKind) {
137 self.steps.push(Step {
138 direction,
139 source,
140 note,
141 });
142 }
143
144 #[must_use]
147 pub const fn is_empty(&self) -> bool {
148 self.steps.is_empty()
149 }
150
151 #[must_use]
153 pub const fn len(&self) -> usize {
154 self.steps.len()
155 }
156
157 #[must_use]
159 pub fn steps(&self) -> &[Step] {
160 &self.steps
161 }
162
163 pub fn merge(&mut self, other: Self) {
166 self.steps.extend(other.steps);
167 }
168}
169
170impl core::fmt::Display for CrossProtocolReport {
171 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
172 if self.steps.is_empty() {
173 return formatter.write_str("cross-protocol translation: no changes");
174 }
175 for (index, step) in self.steps.iter().enumerate() {
176 if index > 0 {
177 formatter.write_str("; ")?;
178 }
179 core::fmt::Display::fmt(step, formatter)?;
180 }
181 Ok(())
182 }
183}
184
185impl core::fmt::Display for Step {
186 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
187 let direction = match self.direction {
188 Direction::ToMcp => "a2a→mcp",
189 Direction::ToA2a => "mcp→a2a",
190 };
191 let source = self
192 .source
193 .map_or_else(|| "neutral".to_owned(), |p| format!("{p:?}").to_lowercase());
194 write!(formatter, "{direction}@{source}: {}", self.note)
195 }
196}
197
198impl core::fmt::Display for StepKind {
199 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
200 match self {
201 Self::PassThrough => formatter.write_str("pass-through"),
202 Self::Renamed { from, to } => {
203 let from = from.unwrap_or("?");
204 let to = to.unwrap_or("?");
205 write!(formatter, "renamed `{from}` → `{to}`")
206 }
207 Self::Dropped { from } => {
208 let from = from.unwrap_or("?");
209 write!(formatter, "dropped `{from}`")
210 }
211 Self::Synthesised { to } => {
212 let to = to.unwrap_or("?");
213 write!(formatter, "synthesised `{to}`")
214 }
215 }
216 }
217}
218
219pub fn a2a_skill_to_mcp_tool(
240 skill: &AgentSkill,
241 report: &mut CrossProtocolReport,
242) -> Result<Tool, JsonObjectError> {
243 JsonObject::new(r#"{"type":"object"}"#)?;
244 report.record(
245 Direction::ToMcp,
246 Some(Protocol::A2a),
247 StepKind::Synthesised {
248 to: Some("inputSchema"),
249 },
250 );
251
252 let mut extensions = Extensions::empty();
253 if !skill.tags.is_empty() {
254 let tags_value = Value::Array(
255 skill
256 .tags
257 .iter()
258 .map(|tag| Value::String(tag.to_string()))
259 .collect(),
260 );
261 extensions.insert("tags".to_owned(), ExtensionValue::from(tags_value));
262 report.record(
263 Direction::ToMcp,
264 Some(Protocol::A2a),
265 StepKind::Renamed {
266 from: Some("tags"),
267 to: Some("extensions[\"tags\"]"),
268 },
269 );
270 }
271
272 report.record(
273 Direction::ToMcp,
274 Some(Protocol::A2a),
275 StepKind::Renamed {
276 from: Some("id"),
277 to: Some("name"),
278 },
279 );
280
281 let title = if skill.name.as_ref().is_empty() {
282 report.record(
283 Direction::ToMcp,
284 Some(Protocol::A2a),
285 StepKind::Dropped { from: Some("name") },
286 );
287 None
288 } else {
289 report.record(
290 Direction::ToMcp,
291 Some(Protocol::A2a),
292 StepKind::Renamed {
293 from: Some("name"),
294 to: Some("title"),
295 },
296 );
297 Some(skill.name.to_string())
298 };
299
300 let description = if skill.description.as_ref().is_empty() {
301 report.record(
302 Direction::ToMcp,
303 Some(Protocol::A2a),
304 StepKind::Dropped {
305 from: Some("description"),
306 },
307 );
308 None
309 } else {
310 report.record(Direction::ToMcp, Some(Protocol::A2a), StepKind::PassThrough);
311 Some(skill.description.to_string())
312 };
313
314 let mut tool = Tool::new(skill.id.as_ref(), r#"{"type":"object"}"#)?;
315 tool.title = title;
316 tool.description = description;
317 tool.annotations = Some(ToolAnnotations::default());
318 tool.extensions = extensions;
319 Ok(tool)
320}
321
322pub fn a2a_message_to_mcp_content(
333 message: &Message,
334 report: &mut CrossProtocolReport,
335) -> Vec<Content> {
336 let mut out = Vec::with_capacity(message.parts.len());
337 for part in &message.parts {
338 match part {
339 Part::Text(text) => {
340 out.push(Content::Text(TextContent::new(text.text.clone())));
341 report.record(Direction::ToMcp, Some(Protocol::A2a), StepKind::PassThrough);
342 }
343 Part::File(file) => {
344 if let Some(block) = a2a_file_to_mcp_content(file, report) {
345 out.push(block);
346 }
347 }
348 Part::Data(data) => {
349 let value = data.data.clone();
350 let block = EmbeddedResource {
351 text: Some(value.to_string()),
352 blob: None,
353 uri: format!("data:application/json,{value}"),
354 mime_type: Some("application/json".to_owned()),
355 annotations: None,
356 extensions: Extensions::empty(),
357 };
358 out.push(Content::EmbeddedResource(block));
359 report.record(
360 Direction::ToMcp,
361 Some(Protocol::A2a),
362 StepKind::Renamed {
363 from: Some("DataPart"),
364 to: Some("EmbeddedResource"),
365 },
366 );
367 }
368 }
369 }
370 out
371}
372
373fn a2a_file_to_mcp_content(file: &FilePart, report: &mut CrossProtocolReport) -> Option<Content> {
374 if let Some(uri) = &file.file.uri {
375 let mut link = ResourceLinkContent::new(uri.clone(), uri.clone());
376 link.mime_type.clone_from(&file.file.mime_type);
377 report.record(
378 Direction::ToMcp,
379 Some(Protocol::A2a),
380 StepKind::Renamed {
381 from: Some("FilePart.uri"),
382 to: Some("ResourceLink"),
383 },
384 );
385 Some(Content::ResourceLink(link))
386 } else if let Some(bytes) = &file.file.bytes {
387 let mime = file
388 .file
389 .mime_type
390 .as_deref()
391 .unwrap_or("application/octet-stream");
392 if mime.starts_with("image/") {
393 report.record(
394 Direction::ToMcp,
395 Some(Protocol::A2a),
396 StepKind::Renamed {
397 from: Some("FilePart.bytes(image)"),
398 to: Some("ImageContent"),
399 },
400 );
401 Some(Content::Image(ImageContent::new(
402 bytes.clone(),
403 mime.to_owned(),
404 )))
405 } else if mime.starts_with("audio/") {
406 report.record(
407 Direction::ToMcp,
408 Some(Protocol::A2a),
409 StepKind::Renamed {
410 from: Some("FilePart.bytes(audio)"),
411 to: Some("AudioContent"),
412 },
413 );
414 Some(Content::Audio(AudioContent::new(
415 bytes.clone(),
416 mime.to_owned(),
417 )))
418 } else {
419 report.record(
420 Direction::ToMcp,
421 Some(Protocol::A2a),
422 StepKind::Dropped {
423 from: Some("FilePart.bytes"),
424 },
425 );
426 None
427 }
428 } else {
429 report.record(
430 Direction::ToMcp,
431 Some(Protocol::A2a),
432 StepKind::Dropped {
433 from: Some("FilePart (empty)"),
434 },
435 );
436 None
437 }
438}
439
440pub fn a2a_message_to_mcp_text(
453 message: &Message,
454 report: &mut CrossProtocolReport,
455) -> Option<TextContent> {
456 let mut joined = String::new();
457 let mut saw_non_text = false;
458 for part in &message.parts {
459 match part {
460 Part::Text(text) => {
461 if !joined.is_empty() {
462 joined.push('\n');
463 }
464 joined.push_str(&text.text);
465 }
466 Part::File(_) | Part::Data(_) => saw_non_text = true,
467 }
468 }
469 if saw_non_text {
470 report.record(
471 Direction::ToMcp,
472 Some(Protocol::A2a),
473 StepKind::Dropped {
474 from: Some("non-text parts"),
475 },
476 );
477 }
478 if joined.is_empty() {
479 report.record(
480 Direction::ToMcp,
481 Some(Protocol::A2a),
482 StepKind::Dropped {
483 from: Some("text parts"),
484 },
485 );
486 return None;
487 }
488 Some(TextContent::new(joined))
489}
490
491pub fn mcp_content_to_a2a_part(
499 content: &Content,
500 report: &mut CrossProtocolReport,
501) -> Option<Part> {
502 match content {
503 Content::Text(text) => {
504 report.record(Direction::ToA2a, Some(Protocol::Mcp), StepKind::PassThrough);
505 Some(Part::Text(TextPart {
506 text: text.text.clone(),
507 metadata: BTreeMap::new(),
508 }))
509 }
510 Content::Image(image) => {
511 report.record(
512 Direction::ToA2a,
513 Some(Protocol::Mcp),
514 StepKind::Renamed {
515 from: Some("ImageContent"),
516 to: Some("FilePart(bytes, image/*)"),
517 },
518 );
519 Some(Part::File(FilePart {
520 file: a2a_bytes_file(image.mime_type.clone(), image.data.clone()),
521 metadata: BTreeMap::new(),
522 }))
523 }
524 Content::Audio(audio) => {
525 report.record(
526 Direction::ToA2a,
527 Some(Protocol::Mcp),
528 StepKind::Renamed {
529 from: Some("AudioContent"),
530 to: Some("FilePart(bytes, audio/*)"),
531 },
532 );
533 Some(Part::File(FilePart {
534 file: a2a_bytes_file(audio.mime_type.clone(), audio.data.clone()),
535 metadata: BTreeMap::new(),
536 }))
537 }
538 Content::ResourceLink(link) => {
539 report.record(
540 Direction::ToA2a,
541 Some(Protocol::Mcp),
542 StepKind::Renamed {
543 from: Some("ResourceLink"),
544 to: Some("FilePart(uri)"),
545 },
546 );
547 Some(Part::File(FilePart {
548 file: a2a_uri_file(link.uri.clone(), link.mime_type.clone()),
549 metadata: BTreeMap::new(),
550 }))
551 }
552 Content::EmbeddedResource(resource) => {
553 let Ok(value) = serde_json::from_str::<Value>(&resource.uri) else {
554 report.record(
555 Direction::ToA2a,
556 Some(Protocol::Mcp),
557 StepKind::Dropped {
558 from: Some("EmbeddedResource.uri"),
559 },
560 );
561 return None;
562 };
563 let Value::Object(map) = value else {
564 report.record(
565 Direction::ToA2a,
566 Some(Protocol::Mcp),
567 StepKind::Dropped {
568 from: Some("EmbeddedResource.uri (not an object)"),
569 },
570 );
571 return None;
572 };
573 report.record(
574 Direction::ToA2a,
575 Some(Protocol::Mcp),
576 StepKind::Renamed {
577 from: Some("EmbeddedResource"),
578 to: Some("DataPart"),
579 },
580 );
581 Some(Part::Data(DataPart {
582 data: Value::Object(map),
583 metadata: BTreeMap::new(),
584 }))
585 }
586 Content::Unknown(_) => {
587 report.record(
588 Direction::ToA2a,
589 Some(Protocol::Mcp),
590 StepKind::Dropped {
591 from: Some("Unknown"),
592 },
593 );
594 None
595 }
596 }
597}
598
599const fn a2a_bytes_file(mime: String, bytes: String) -> crate::domain::a2a::message::FileContent {
600 crate::domain::a2a::message::FileContent {
601 name: None,
602 mime_type: Some(mime),
603 bytes: Some(bytes),
604 uri: None,
605 }
606}
607
608const fn a2a_uri_file(
609 uri: String,
610 mime: Option<String>,
611) -> crate::domain::a2a::message::FileContent {
612 crate::domain::a2a::message::FileContent {
613 name: None,
614 mime_type: mime,
615 bytes: None,
616 uri: Some(uri),
617 }
618}
619
620#[cfg(test)]
621mod tests {
622 use std::sync::Arc;
623
624 use super::*;
625 use crate::domain::a2a::message::{FileContent, FilePart, MessageRole};
626
627 enum Failure {
630 Skill(crate::domain::ir::JsonObjectError),
631 Mismatch(String),
632 }
633
634 impl core::fmt::Display for Failure {
635 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
636 match self {
637 Self::Skill(err) => core::fmt::Display::fmt(err, formatter),
638 Self::Mismatch(message) => formatter.write_str(message),
639 }
640 }
641 }
642
643 impl core::fmt::Debug for Failure {
644 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
645 core::fmt::Debug::fmt(&self.mismatch_or_skill(), formatter)
646 }
647 }
648
649 impl Failure {
650 fn mismatch_or_skill(&self) -> String {
651 match self {
652 Self::Skill(err) => format!("{err}"),
653 Self::Mismatch(message) => message.clone(),
654 }
655 }
656 }
657
658 impl From<crate::domain::ir::JsonObjectError> for Failure {
659 fn from(err: crate::domain::ir::JsonObjectError) -> Self {
660 Self::Skill(err)
661 }
662 }
663
664 fn skill(id: &str, name: &str, description: &str, tags: &[&str]) -> AgentSkill {
665 AgentSkill {
666 id: Arc::from(id),
667 name: Arc::from(name),
668 description: Arc::from(description),
669 tags: tags.iter().map(|t| Arc::from(*t)).collect(),
670 }
671 }
672
673 #[test]
674 fn skill_to_tool_maps_required_fields_and_records_steps() -> Result<(), Failure> {
675 let s = skill("lookup", "Lookup", "Look up a record", &["search", "read"]);
676 let mut report = CrossProtocolReport::new();
677 let tool = a2a_skill_to_mcp_tool(&s, &mut report)?;
678
679 if tool.name.as_str() != "lookup" {
680 return Err(Failure::Mismatch(format!(
681 "expected tool.name 'lookup', got {:?}",
682 tool.name
683 )));
684 }
685 if tool.title.as_deref() != Some("Lookup") {
686 return Err(Failure::Mismatch(format!(
687 "expected tool.title 'Lookup', got {:?}",
688 tool.title
689 )));
690 }
691 if tool.description.as_deref() != Some("Look up a record") {
692 return Err(Failure::Mismatch(format!(
693 "expected tool.description, got {:?}",
694 tool.description
695 )));
696 }
697 if report.is_empty() {
698 return Err(Failure::Mismatch(
699 "expected the steps to be recorded, got an empty report".to_owned(),
700 ));
701 }
702 if tool.extensions.get("tags").is_none() {
703 return Err(Failure::Mismatch(
704 "expected tags to be recorded in extensions".to_owned(),
705 ));
706 }
707 Ok(())
708 }
709
710 #[test]
711 fn skill_to_tool_with_empty_name_drops_title() -> Result<(), Failure> {
712 let s = skill("lookup", "", "Look up a record", &[]);
713 let mut report = CrossProtocolReport::new();
714 let tool = a2a_skill_to_mcp_tool(&s, &mut report)?;
715 if tool.title.is_some() {
716 return Err(Failure::Mismatch("expected title to be None".to_owned()));
717 }
718 if !report
719 .steps()
720 .iter()
721 .any(|s| matches!(&s.note, StepKind::Dropped { from: Some("name") }))
722 {
723 return Err(Failure::Mismatch(
724 "expected a Dropped(name) step".to_owned(),
725 ));
726 }
727 Ok(())
728 }
729
730 #[test]
731 fn skill_to_tool_with_empty_description_drops_description() -> Result<(), Failure> {
732 let s = skill("lookup", "Lookup", "", &[]);
733 let mut report = CrossProtocolReport::new();
734 let tool = a2a_skill_to_mcp_tool(&s, &mut report)?;
735 if tool.description.is_some() {
736 return Err(Failure::Mismatch(
737 "expected description to be None".to_owned(),
738 ));
739 }
740 if !report.steps().iter().any(|s| {
741 matches!(
742 &s.note,
743 StepKind::Dropped {
744 from: Some("description")
745 }
746 )
747 }) {
748 return Err(Failure::Mismatch(
749 "expected a Dropped(description) step".to_owned(),
750 ));
751 }
752 Ok(())
753 }
754
755 #[test]
756 fn message_to_content_text_round_trips() -> Result<(), Failure> {
757 let m = Message {
758 role: MessageRole::User,
759 parts: vec![Part::Text(TextPart {
760 text: "hello".to_owned(),
761 metadata: BTreeMap::new(),
762 })],
763 metadata: BTreeMap::new(),
764 };
765 let mut report = CrossProtocolReport::new();
766 let blocks = a2a_message_to_mcp_content(&m, &mut report);
767 if blocks.len() != 1 {
768 return Err(Failure::Mismatch(format!(
769 "expected 1 block, got {}",
770 blocks.len()
771 )));
772 }
773 let Some(first) = blocks.first() else {
774 return Err(Failure::Mismatch(
775 "expected at least one content block".to_owned(),
776 ));
777 };
778 let Content::Text(t) = first else {
779 return Err(Failure::Mismatch(format!("expected Text, got {first:?}")));
780 };
781 if t.text != "hello" {
782 return Err(Failure::Mismatch(format!(
783 "expected 'hello', got {:?}",
784 t.text
785 )));
786 }
787 if report.is_empty() {
788 return Err(Failure::Mismatch("expected a pass-through step".to_owned()));
789 }
790 Ok(())
791 }
792
793 #[test]
794 fn message_to_content_image_file_becomes_image_content() -> Result<(), Failure> {
795 let file = FilePart {
796 file: FileContent {
797 name: None,
798 mime_type: Some("image/png".to_owned()),
799 bytes: Some("AAAA".to_owned()),
800 uri: None,
801 },
802 metadata: BTreeMap::new(),
803 };
804 let m = Message {
805 role: MessageRole::User,
806 parts: vec![Part::File(file)],
807 metadata: BTreeMap::new(),
808 };
809 let mut report = CrossProtocolReport::new();
810 let blocks = a2a_message_to_mcp_content(&m, &mut report);
811 if !blocks.iter().any(|b| matches!(b, Content::Image(_))) {
812 return Err(Failure::Mismatch(
813 "expected at least one ImageContent block".to_owned(),
814 ));
815 }
816 if !report.steps().iter().any(|s| {
817 matches!(
818 &s.note,
819 StepKind::Renamed {
820 from: Some(_),
821 to: Some("ImageContent")
822 }
823 )
824 }) {
825 return Err(Failure::Mismatch(
826 "expected a Renamed → ImageContent step".to_owned(),
827 ));
828 }
829 Ok(())
830 }
831
832 #[test]
833 fn message_to_content_uri_file_becomes_resource_link() -> Result<(), Failure> {
834 let file = FilePart {
835 file: FileContent {
836 name: None,
837 mime_type: Some("application/pdf".to_owned()),
838 bytes: None,
839 uri: Some("https://example.com/x".to_owned()),
840 },
841 metadata: BTreeMap::new(),
842 };
843 let m = Message {
844 role: MessageRole::User,
845 parts: vec![Part::File(file)],
846 metadata: BTreeMap::new(),
847 };
848 let mut report = CrossProtocolReport::new();
849 let blocks = a2a_message_to_mcp_content(&m, &mut report);
850 if !blocks.iter().any(|b| matches!(b, Content::ResourceLink(_))) {
851 return Err(Failure::Mismatch(
852 "expected at least one ResourceLink block".to_owned(),
853 ));
854 }
855 Ok(())
856 }
857
858 #[test]
859 fn message_to_content_unknown_file_type_is_dropped() -> Result<(), Failure> {
860 let file = FilePart {
861 file: FileContent {
862 name: None,
863 mime_type: Some("application/x-binary".to_owned()),
864 bytes: Some("AAAA".to_owned()),
865 uri: None,
866 },
867 metadata: BTreeMap::new(),
868 };
869 let m = Message {
870 role: MessageRole::User,
871 parts: vec![Part::File(file)],
872 metadata: BTreeMap::new(),
873 };
874 let mut report = CrossProtocolReport::new();
875 let blocks = a2a_message_to_mcp_content(&m, &mut report);
876 if !blocks.is_empty() {
877 return Err(Failure::Mismatch(format!(
878 "expected no blocks, got {}",
879 blocks.len()
880 )));
881 }
882 if !report.steps().iter().any(|s| {
883 matches!(
884 &s.note,
885 StepKind::Dropped {
886 from: Some("FilePart.bytes")
887 }
888 )
889 }) {
890 return Err(Failure::Mismatch(
891 "expected a Dropped(FilePart.bytes) step".to_owned(),
892 ));
893 }
894 Ok(())
895 }
896
897 #[test]
898 fn message_to_content_data_becomes_embedded_resource() -> Result<(), Failure> {
899 let mut map = serde_json::Map::new();
900 map.insert("k".to_owned(), Value::String("v".to_owned()));
901 let m = Message {
902 role: MessageRole::User,
903 parts: vec![Part::Data(DataPart {
904 data: Value::Object(map),
905 metadata: BTreeMap::new(),
906 })],
907 metadata: BTreeMap::new(),
908 };
909 let mut report = CrossProtocolReport::new();
910 let blocks = a2a_message_to_mcp_content(&m, &mut report);
911 if !blocks
912 .iter()
913 .any(|b| matches!(b, Content::EmbeddedResource(_)))
914 {
915 return Err(Failure::Mismatch(
916 "expected an EmbeddedResource block".to_owned(),
917 ));
918 }
919 Ok(())
920 }
921
922 #[test]
923 fn message_to_text_joins_text_parts_and_drops_others() -> Result<(), Failure> {
924 let m = Message {
925 role: MessageRole::User,
926 parts: vec![
927 Part::Text(TextPart {
928 text: "first".to_owned(),
929 metadata: BTreeMap::new(),
930 }),
931 Part::Text(TextPart {
932 text: "second".to_owned(),
933 metadata: BTreeMap::new(),
934 }),
935 Part::File(FilePart {
936 file: FileContent {
937 name: None,
938 mime_type: None,
939 bytes: None,
940 uri: Some("https://example.com/x".to_owned()),
941 },
942 metadata: BTreeMap::new(),
943 }),
944 ],
945 metadata: BTreeMap::new(),
946 };
947 let mut report = CrossProtocolReport::new();
948 let joined = a2a_message_to_mcp_text(&m, &mut report);
949 let Some(joined) = joined else {
950 return Err(Failure::Mismatch("expected Some(text)".to_owned()));
951 };
952 if joined.text != "first\nsecond" {
953 return Err(Failure::Mismatch(format!(
954 "expected 'first\\nsecond', got {:?}",
955 joined.text
956 )));
957 }
958 if !report.steps().iter().any(|s| {
959 matches!(
960 &s.note,
961 StepKind::Dropped {
962 from: Some("non-text parts")
963 }
964 )
965 }) {
966 return Err(Failure::Mismatch(
967 "expected a Dropped(non-text parts) step".to_owned(),
968 ));
969 }
970 Ok(())
971 }
972
973 #[test]
974 fn message_to_text_with_only_non_text_returns_none() -> Result<(), Failure> {
975 let m = Message {
976 role: MessageRole::User,
977 parts: vec![Part::File(FilePart {
978 file: FileContent {
979 name: None,
980 mime_type: None,
981 bytes: None,
982 uri: Some("https://example.com/x".to_owned()),
983 },
984 metadata: BTreeMap::new(),
985 })],
986 metadata: BTreeMap::new(),
987 };
988 let mut report = CrossProtocolReport::new();
989 let joined = a2a_message_to_mcp_text(&m, &mut report);
990 if joined.is_some() {
991 return Err(Failure::Mismatch("expected None".to_owned()));
992 }
993 Ok(())
994 }
995
996 #[test]
997 fn content_to_part_text_round_trips() -> Result<(), Failure> {
998 let block = Content::Text(TextContent::new("hi"));
999 let mut report = CrossProtocolReport::new();
1000 let part = mcp_content_to_a2a_part(&block, &mut report);
1001 let Some(Part::Text(text)) = part else {
1002 return Err(Failure::Mismatch(format!(
1003 "expected TextPart, got {part:?}"
1004 )));
1005 };
1006 if text.text != "hi" {
1007 return Err(Failure::Mismatch(format!(
1008 "expected 'hi', got {:?}",
1009 text.text
1010 )));
1011 }
1012 Ok(())
1013 }
1014
1015 #[test]
1016 fn content_to_part_image_becomes_file_part() -> Result<(), Failure> {
1017 let block = Content::Image(ImageContent::new("AAAA".to_owned(), "image/png".to_owned()));
1018 let mut report = CrossProtocolReport::new();
1019 let part = mcp_content_to_a2a_part(&block, &mut report);
1020 let Some(Part::File(f)) = part else {
1021 return Err(Failure::Mismatch(format!(
1022 "expected FilePart, got {part:?}"
1023 )));
1024 };
1025 if f.file.mime_type.as_deref() != Some("image/png") {
1026 return Err(Failure::Mismatch(format!(
1027 "expected image/png, got {:?}",
1028 f.file.mime_type
1029 )));
1030 }
1031 if f.file.bytes.is_none() {
1032 return Err(Failure::Mismatch("expected bytes".to_owned()));
1033 }
1034 if f.file.uri.is_some() {
1035 return Err(Failure::Mismatch("expected no uri".to_owned()));
1036 }
1037 Ok(())
1038 }
1039
1040 #[test]
1041 fn content_to_part_resource_link_becomes_uri_file() -> Result<(), Failure> {
1042 let mut link = ResourceLinkContent::new("https://example.com/x".to_owned(), "x".to_owned());
1043 link.mime_type = Some("application/pdf".to_owned());
1044 let block = Content::ResourceLink(link);
1045 let mut report = CrossProtocolReport::new();
1046 let part = mcp_content_to_a2a_part(&block, &mut report);
1047 let Some(Part::File(f)) = part else {
1048 return Err(Failure::Mismatch(format!(
1049 "expected FilePart, got {part:?}"
1050 )));
1051 };
1052 if f.file.uri.as_deref() != Some("https://example.com/x") {
1053 return Err(Failure::Mismatch(format!(
1054 "expected uri, got {:?}",
1055 f.file.uri
1056 )));
1057 }
1058 if f.file.bytes.is_some() {
1059 return Err(Failure::Mismatch("expected no bytes".to_owned()));
1060 }
1061 Ok(())
1062 }
1063
1064 #[test]
1065 fn report_merge_combines_steps() -> Result<(), Failure> {
1066 let mut a = CrossProtocolReport::new();
1067 a.record(Direction::ToMcp, Some(Protocol::A2a), StepKind::PassThrough);
1068 let mut b = CrossProtocolReport::new();
1069 b.record(
1070 Direction::ToA2a,
1071 Some(Protocol::Mcp),
1072 StepKind::Dropped { from: Some("x") },
1073 );
1074 a.merge(b);
1075 if a.len() != 2 {
1076 return Err(Failure::Mismatch(format!(
1077 "expected 2 steps after merge, got {}",
1078 a.len()
1079 )));
1080 }
1081 Ok(())
1082 }
1083
1084 #[test]
1085 fn report_display_shows_steps() -> Result<(), Failure> {
1086 let mut report = CrossProtocolReport::new();
1087 report.record(
1088 Direction::ToMcp,
1089 Some(Protocol::A2a),
1090 StepKind::Dropped { from: Some("name") },
1091 );
1092 let s = report.to_string();
1093 if !s.contains("a2a→mcp") {
1094 return Err(Failure::Mismatch(
1095 "expected 'a2a→mcp' in the report".to_owned(),
1096 ));
1097 }
1098 if !s.contains("dropped") {
1099 return Err(Failure::Mismatch(
1100 "expected 'dropped' in the report".to_owned(),
1101 ));
1102 }
1103 Ok(())
1104 }
1105
1106 #[test]
1107 fn protocol_other_swaps() -> Result<(), Failure> {
1108 if Protocol::A2a.other() != Protocol::Mcp {
1109 return Err(Failure::Mismatch("A2a.other() should be Mcp".to_owned()));
1110 }
1111 if Protocol::Mcp.other() != Protocol::A2a {
1112 return Err(Failure::Mismatch("Mcp.other() should be A2a".to_owned()));
1113 }
1114 Ok(())
1115 }
1116}