Skip to main content

protocol_droid/domain/bridge/
a2a_mcp.rs

1//! Cross-protocol mapping rules: A2A v1.0 ↔ MCP (any negotiated revision).
2//!
3//! # What this module is
4//!
5//! Pure functions that translate between an A2A concept (skill, message) and
6//! the corresponding MCP concept (tool, content block). The functions are
7//! total where the mapping is 1:1 and partial where it is not — when an
8//! A2A concept has no MCP shape, the translation records the loss in a
9//! [`CrossProtocolReport`] rather than dropping the value silently.
10//!
11//! # What this module is *not*
12//!
13//! It is not the bridge. The bridge (in `application/bridge/`) is the loop
14//! that drives these translations against a live peer on each side. This
15//! module is the pure half: the *what* of the translation, kept separate
16//! from the *when* so the rules can be unit-tested without a transport and
17//! the transport can be exercised against a test fixture of rules.
18//!
19//! # Why the report accumulates rather than summarising
20//!
21//! A count of "3 rules fired" loses the *which*. Two translations that both
22//! dropped a content block can be perfectly fine and a serious bug, and the
23//! difference is whether it was a file part the caller agreed to lose or an
24//! audio block nobody mentioned. [`CrossProtocolReport`] keeps the steps in
25//! the order they were applied and lets the reviewer read what each one was.
26//!
27//! # Conventions
28//!
29//! A function whose name is `*_to_*` translates the first protocol's
30//! concept into the second's. A function whose name is `*_from_*` is the
31//! inverse. A pair of directions is exposed together so the consumer can
32//! read both shapes without hunting.
33
34use 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/// The two protocols the mapping crosses.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum Protocol {
49    /// A2A v1.0. The `major.minor` shape is a private concern of the A2A
50    /// module; this enum is a tag, not a value object.
51    A2a,
52    /// MCP at the revision a peer negotiated.
53    Mcp,
54}
55
56impl Protocol {
57    /// The other side of the crossing.
58    #[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/// The two directions a translation can take.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub enum Direction {
70    /// A2A → MCP.
71    ToMcp,
72    /// MCP → A2A.
73    ToA2a,
74}
75
76/// The reason a step fired.
77///
78/// Each variant is a statement a reviewer can act on. The trivial case
79/// (`PassThrough`) is recorded when the report needs to show *what* was
80/// carried so the absence of any other step is meaningful; a translation
81/// that did not need to record anything reports an empty
82/// [`CrossProtocolReport`].
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum StepKind {
85    /// A field round-tripped unchanged.
86    PassThrough,
87    /// A field was mapped onto a different field that carries the same
88    /// information. The report names which side lost its native shape.
89    Renamed {
90        /// The A2A field the value came from, when the rule is A2A-side.
91        from: Option<&'static str>,
92        /// The MCP field the value went to, when the rule is MCP-side.
93        to: Option<&'static str>,
94    },
95    /// A field has no peer-side shape and the value was dropped. The
96    /// caller will see the step and decide whether the drop is acceptable.
97    Dropped {
98        /// The A2A field that was dropped, when the rule is A2A-side.
99        from: Option<&'static str>,
100    },
101    /// A field was filled with a placeholder because the source had no
102    /// shape to map. The placeholder is documented on the function that
103    /// produced it.
104    Synthesised {
105        /// The MCP field that was filled, when the rule is MCP-side.
106        to: Option<&'static str>,
107    },
108}
109
110/// One step the translation took, and what it cost.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct Step {
113    /// Which direction the step went.
114    pub direction: Direction,
115    /// The A2A concept the step touched, when the step's rule is
116    /// A2A-specific. `None` for a step whose rule is protocol-neutral.
117    pub source: Option<Protocol>,
118    /// What was done, named for the operator reading the report.
119    pub note: StepKind,
120}
121
122/// What one translation changed.
123#[derive(Debug, Clone, PartialEq, Eq, Default)]
124pub struct CrossProtocolReport {
125    steps: Vec<Step>,
126}
127
128impl CrossProtocolReport {
129    /// A report with nothing in it yet.
130    #[must_use]
131    pub const fn new() -> Self {
132        Self { steps: Vec::new() }
133    }
134
135    /// Records a step.
136    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    /// Whether anything was recorded. An empty report is a lossless
145    /// translation; a non-empty report names the changes.
146    #[must_use]
147    pub const fn is_empty(&self) -> bool {
148        self.steps.is_empty()
149    }
150
151    /// The number of recorded steps.
152    #[must_use]
153    pub const fn len(&self) -> usize {
154        self.steps.len()
155    }
156
157    /// The steps, in the order they were applied.
158    #[must_use]
159    pub fn steps(&self) -> &[Step] {
160        &self.steps
161    }
162
163    /// Merges `other` into this one. Used by the bridge to combine the
164    /// report from a forward translation with the report from the reply.
165    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
219/// A2A `AgentSkill` → MCP `Tool`.
220///
221/// # Mapping
222///
223/// - `id` → `Tool.name`
224/// - `name` → `Tool.title` (an empty name is recorded as a dropped step)
225/// - `description` → `Tool.description` (an empty description is recorded)
226/// - `tags` → `extensions["tags"]` (MCP has no first-class tag field)
227/// - `inputSchema` is **synthesised** as `{"type":"object"}` because A2A
228///   skills carry no schema and MCP requires one at every revision. The
229///   synthesis is a recorded step.
230///
231/// # Errors
232///
233/// [`Tool::new`] parses the synthesised schema and refuses a value that is
234/// not a JSON Schema object. The empty-object schema is always parseable, so
235/// the only way this function errors is if [`JsonObject::new`] itself fails,
236/// which it does not for `{"type":"object"}`; the signature keeps the
237/// fallibility explicit so a future synthesised schema is honest about the
238/// cost of being parseable.
239pub 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
322/// A2A `Message` → a list of MCP `Content` blocks.
323///
324/// The mapping is one-to-one for the common case (`TextPart` → `Text`); the
325/// `FilePart` and `DataPart` cases each get a best-effort mapping with a
326/// recorded step. A `FilePart` whose MIME type is an image becomes an
327/// `ImageContent` block, an audio MIME type becomes `AudioContent`, and a
328/// URI becomes a `ResourceLink`. A `FilePart` whose MIME type is neither
329/// and whose content is a URI is treated as a `ResourceLink`; a `FilePart`
330/// with no URI and a non-image/audio MIME type is dropped with a recorded
331/// step. A `DataPart` becomes an `EmbeddedResource` with a JSON MIME type.
332pub 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
440/// A2A `Message` → a single text payload, the join of every `TextPart`.
441///
442/// A2A messages are multimodal — the `Message` carries any number of
443/// `Part` variants — but an MCP `tools/call` arguments payload is a single
444/// JSON object. The conservative translation is to join every `TextPart`
445/// into one block and drop the rest, recording the loss in `report`. A
446/// caller that wants the full translation uses
447/// [`a2a_message_to_mcp_content`] and a tool whose `inputSchema` accepts
448/// an array of content blocks.
449///
450/// Returns `None` when the message has no text parts; the bridge treats
451/// `None` as a refusal and surfaces a `-32602 Invalid params` to the peer.
452pub 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
491/// MCP `Content` → A2A `Part`.
492///
493/// The inverse of [`a2a_message_to_mcp_content`]. The mapping is
494/// best-effort: every content block has a candidate A2A shape, but the
495/// round-trip is lossy when the original A2A `Part` carried information
496/// MCP has no slot for (the per-part `name` / `metadata` in a `FilePart`,
497/// for example). The steps recorded in `report` are the loss summary.
498pub 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    /// All the error types the tests below can return. A single enum keeps
628    /// `From` impls narrow and the test signatures short.
629    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}