Skip to main content

protocol_droid/application/bridge/
a2a_mcp_codec.rs

1//! The A2A v1.0 ↔ MCP `SendMessage` ↔ `tools/call` codec.
2//!
3//! # What this codec handles
4//!
5//! One method pair, in both directions:
6//!
7//! - **A2A → MCP**: an MCP client calling `tools/call` for a tool that
8//!   maps to an A2A skill becomes an A2A `SendMessage` whose
9//!   `message.parts` carry the tool's name and arguments in a metadata
10//!   field, and the A2A agent uses the metadata to dispatch to the
11//!   right skill.
12//! - **MCP → A2A**: an A2A client calling `SendMessage` (a skill on the
13//!   advertised agent card) becomes an MCP `tools/call` whose `name`
14//!   is the skill and whose `arguments` are the message parts.
15//!
16//! The reply path uses the same A2A `Task` → MCP `ToolResult` mapping
17//! rules in [`crate::domain::bridge::a2a_mcp`] for both directions.
18//!
19//! # What this codec does *not* handle
20//!
21//! The other ten A2A v1.0 methods (`GetTask`, `ListTasks`, `CancelTask`,
22//! `SubscribeToTask`, the four `*PushNotificationConfig` methods, and
23//! `GetExtendedAgentCard`) are not modelled. A bridge that receives
24//! one of them refuses with `CrossProtocolError::MethodNotBridged` and
25//! the loop returns `-32601` to the client. The codec that adds them
26//! is a follow-up commit; this one ships the shape of the integration
27//! and the path for the rest to follow.
28//!
29//! # Why a metadata field carries the skill name on the A2A side
30//!
31//! A2A `SendMessage` has no `skill` parameter — the agent dispatches
32//! the message to whichever skill it thinks fits. A bridge that wants
33//! an MCP `tools/call` to land on a specific skill has to either inject
34//! the skill name into the message and hope the A2A agent reads it,
35//! or run a separate A2A server per skill and route by URL.
36//!
37//! The metadata-field approach is the cheaper of the two and matches
38//! the A2A spec's own use of `metadata` for "per-request" hints. An
39//! A2A agent that does not read it will still try to dispatch, and
40//! will get a different answer than the bridge meant — but the report
41//! will still show the bridge took a guess. The convention is
42//! documented in the doc comment on [`bridge_skill_metadata_key`].
43
44use std::collections::BTreeMap;
45use std::sync::Arc;
46
47use serde_json::{Map, Value};
48
49use crate::application::bridge::cross_protocol::{
50    CrossProtocolCodec, CrossProtocolError, ProtocolSide, RequestCodecReport, RequestCodecStep,
51};
52use crate::domain::bridge::a2a_mcp::a2a_message_to_mcp_content;
53
54/// The metadata key the A2A side of the bridge uses to name a skill.
55///
56/// An A2A agent that wants to be bridge-friendly reads this from the
57/// `metadata` bag and dispatches to the named skill. An A2A agent that
58/// does not will see the same message it would have seen without the
59/// bridge.
60const BRIDGE_SKILL_KEY: &str = "bridge_skill";
61
62/// The codec that turns `SendMessage` ↔ `tools/call` into the bridge's
63/// canonical envelopes.
64///
65/// Cheap to construct (no allocations beyond the struct itself), safe
66/// to share behind an `Arc` — the trait's `Send + Sync` bound is
67/// satisfied without any interior mutability, because the codec is a
68/// pure function of its inputs.
69#[derive(Debug, Default, Clone, Copy)]
70pub struct A2aMcpCodec;
71
72impl A2aMcpCodec {
73    /// A codec the bridge holds by value.
74    #[must_use]
75    pub const fn new() -> Self {
76        Self
77    }
78}
79
80impl CrossProtocolCodec for A2aMcpCodec {
81    fn downstream_protocol(&self) -> ProtocolSide {
82        // The codec is symmetric — it is the *bridge* that picks which
83        // direction to drive. A `SendMessage` from the downstream is
84        // either the A2A client calling an A2A method, or the MCP
85        // client calling `tools/call`. The codec's job is to handle
86        // both, and the bridge calls the right method based on the
87        // downstream leg's protocol.
88        ProtocolSide::Mcp
89    }
90
91    fn encode_request(
92        &self,
93        downstream_side: ProtocolSide,
94        ir_method: &str,
95        raw_envelope: &Map<String, Value>,
96        report: &mut RequestCodecReport,
97    ) -> Result<Map<String, Value>, CrossProtocolError> {
98        let Some(upstream_method) = downstream_side.wire_method(ir_method) else {
99            return Err(CrossProtocolError::MethodNotBridged(
100                ir_method.to_owned(),
101                downstream_side,
102            ));
103        };
104
105        let mut outbound = raw_envelope.clone();
106        // The envelope is what the bridge forwards, and the bridge's
107        // own `lower_envelope` is the right level for the JSON-RPC
108        // scaffolding. The codec only changes the method name and the
109        // params; it leaves `jsonrpc`, `id`, and the unmodelled
110        // extensions alone.
111        if let Some(params) = outbound.get_mut("params") {
112            *params = translate_params_outbound(downstream_side, ir_method, params, report);
113        } else if downstream_side != ir_method_side(ir_method) {
114            // A `SendMessage` request from an MCP client has no `params`
115            // — MCP `tools/call` is the only call this codec produces
116            // for the MCP→A2A direction — so a missing-`params` case
117            // is a caller mistake. An A2A `SendMessage` always has
118            // `params`, so the same is true on the A2A→MCP side.
119            return Err(CrossProtocolError::MethodNotBridged(
120                ir_method.to_owned(),
121                downstream_side,
122            ));
123        }
124
125        if let Some(method) = outbound.get_mut("method") {
126            *method = Value::String(upstream_method.to_owned());
127        }
128
129        report.record(RequestCodecStep::MethodRenamed {
130            from: ir_method_static(ir_method),
131            to: method_name_static(upstream_method),
132        });
133        Ok(outbound)
134    }
135
136    fn decode_reply(
137        &self,
138        downstream_side: ProtocolSide,
139        ir_method: &str,
140        reply: &Map<String, Value>,
141        report: &mut RequestCodecReport,
142    ) -> Result<Map<String, Value>, CrossProtocolError> {
143        if downstream_side.wire_method(ir_method).is_none() {
144            return Err(CrossProtocolError::MethodNotBridged(
145                ir_method.to_owned(),
146                downstream_side,
147            ));
148        }
149
150        let mut outbound = reply.clone();
151        let Some(result) = outbound.get_mut("result") else {
152            // An error reply is not a result and needs no translation.
153            // The bridge forwards it as-is.
154            return Ok(outbound);
155        };
156
157        *result = match downstream_side {
158            // The reply arrived in the upstream's language (MCP for an
159            // A2A downstream, A2A for an MCP downstream) and must be
160            // lowered into the downstream's language. The naming
161            // `*_to_<side>_result` reads "incoming upstream shape to
162            // <side> shape", so the `Mcp` arm produces an A2A reply
163            // and the `A2a` arm produces an MCP reply.
164            ProtocolSide::A2a => mcp_to_a2a_result(result),
165            ProtocolSide::Mcp => a2a_to_mcp_result(result),
166        };
167
168        report.record(RequestCodecStep::ReplyTranslated {
169            from: reply_label(downstream_side, "upstream"),
170            to: reply_label(downstream_side, "downstream"),
171        });
172
173        Ok(outbound)
174    }
175}
176
177fn ir_method_side(ir_method: &str) -> ProtocolSide {
178    if ir_method == "SendMessage" {
179        ProtocolSide::A2a
180    } else if ir_method == "tools/call" {
181        ProtocolSide::Mcp
182    } else {
183        // Unreachable: the codec only knows these two. The caller
184        // already rejected other methods with `MethodNotBridged`.
185        ProtocolSide::A2a
186    }
187}
188
189fn ir_method_static(method: &str) -> &'static str {
190    // The codec only knows two IR method names, and they are the
191    // canonical ones the bridge tracks. Leaking them is the only way
192    // to get a `&'static str` for the report step, and the lookup is
193    // bounded to the two strings the codec actually emits.
194    match method {
195        "SendMessage" => "SendMessage",
196        "tools/call" => "tools/call",
197        // Unreachable: the codec refuses other methods.
198        _ => "unknown",
199    }
200}
201
202fn method_name_static(method: &str) -> &'static str {
203    // Same as `ir_method_static`, but for the upstream wire name.
204    match method {
205        "SendMessage" => "SendMessage",
206        "SendStreamingMessage" => "SendStreamingMessage",
207        "GetTask" => "GetTask",
208        "tools/call" => "tools/call",
209        "tools/list" => "tools/list",
210        // Other upstream names; safe to fall through to a marker.
211        _ => "unknown",
212    }
213}
214
215fn reply_label(side: ProtocolSide, direction: &str) -> &'static str {
216    // The label names the shape in human terms, not the literal JSON
217    // type, so an operator reading the report sees the bridge's
218    // intent rather than its implementation.
219    if direction == "upstream" {
220        if side == ProtocolSide::A2a {
221            "A2A Task/Message"
222        } else {
223            "MCP CallToolResult"
224        }
225    } else if side == ProtocolSide::A2a {
226        "MCP CallToolResult"
227    } else {
228        "A2A Task/Message"
229    }
230}
231
232fn translate_params_outbound(
233    side: ProtocolSide,
234    ir_method: &str,
235    params: &Value,
236    report: &mut RequestCodecReport,
237) -> Value {
238    match (side, ir_method) {
239        (ProtocolSide::A2a, "SendMessage") => a2a_to_mcp_tools_call_params(params, report),
240        (ProtocolSide::Mcp, "tools/call") => mcp_to_a2a_send_message_params(params, report),
241        _ => params.clone(),
242    }
243}
244
245fn mcp_to_a2a_send_message_params(params: &Value, report: &mut RequestCodecReport) -> Value {
246    // MCP `tools/call` params: `{"name": "<skill>", "arguments": {...}}`.
247    let Some(object) = params.as_object() else {
248        return params.clone();
249    };
250    let skill = object.get("name").and_then(Value::as_str).unwrap_or("");
251    let arguments = object.get("arguments").cloned().unwrap_or(Value::Null);
252    let mut metadata = BTreeMap::new();
253    if !skill.is_empty() {
254        metadata.insert(BRIDGE_SKILL_KEY.to_owned(), Value::String(skill.to_owned()));
255    }
256    let mut message = serde_json::Map::new();
257    message.insert("role".to_owned(), Value::String("user".to_owned()));
258    if arguments.is_null() {
259        message.insert("parts".to_owned(), Value::Array(Vec::new()));
260    } else {
261        let mut data_part = serde_json::Map::new();
262        data_part.insert("data".to_owned(), arguments);
263        let mut part = serde_json::Map::new();
264        part.insert("kind".to_owned(), Value::String("data".to_owned()));
265        part.insert("data".to_owned(), Value::Object(data_part));
266        message.insert("parts".to_owned(), Value::Array(vec![Value::Object(part)]));
267    }
268    let mut out = serde_json::Map::new();
269    out.insert("message".to_owned(), Value::Object(message));
270    if !metadata.is_empty() {
271        let meta: Map<String, Value> = metadata.into_iter().collect();
272        out.insert("metadata".to_owned(), Value::Object(meta));
273    }
274    report.record(RequestCodecStep::ParamsTranslated {
275        from: "MCP tools/call{name, arguments}",
276        to: "A2A SendMessage{message, metadata}",
277    });
278    Value::Object(out)
279}
280
281fn a2a_to_mcp_tools_call_params(params: &Value, report: &mut RequestCodecReport) -> Value {
282    // A2A `SendMessage` params: `{"message": {...}, "metadata": {...}}`.
283    let Some(object) = params.as_object() else {
284        return params.clone();
285    };
286    let Some(message) = object.get("message") else {
287        return params.clone();
288    };
289    let skill = object
290        .get("metadata")
291        .and_then(Value::as_object)
292        .and_then(|meta| meta.get(BRIDGE_SKILL_KEY))
293        .and_then(Value::as_str)
294        .unwrap_or("");
295    let arguments = first_message_data(message);
296    let mut out = serde_json::Map::new();
297    if !skill.is_empty() {
298        out.insert("name".to_owned(), Value::String(skill.to_owned()));
299    }
300    out.insert("arguments".to_owned(), arguments);
301    report.record(RequestCodecStep::ParamsTranslated {
302        from: "A2A SendMessage{message, metadata}",
303        to: "MCP tools/call{name, arguments}",
304    });
305    Value::Object(out)
306}
307
308fn first_message_data(message: &Value) -> Value {
309    let Some(parts) = message
310        .as_object()
311        .and_then(|object| object.get("parts"))
312        .and_then(Value::as_array)
313    else {
314        return Value::Null;
315    };
316    for part in parts {
317        if let Some(data) = part.as_object().and_then(|object| object.get("data")) {
318            return data.clone();
319        }
320    }
321    Value::Null
322}
323
324fn a2a_to_mcp_result(result: &Value) -> Value {
325    // A2A reply is either `{"id": "...", "status": ...}` (a Task) or
326    // `{"role": "...", "parts": [...]}` (a Message). The MCP side
327    // expects a `CallToolResult` with `content`.
328    let Some(object) = result.as_object() else {
329        return result.clone();
330    };
331    let mut mapping_report = crate::domain::bridge::a2a_mcp::CrossProtocolReport::new();
332    let mut content = if object.contains_key("id") {
333        a2a_task_content(object, &mut mapping_report)
334    } else {
335        let message = clone_message(object);
336        a2a_message_to_mcp_content(&message, &mut mapping_report)
337    };
338    if content.is_empty() {
339        // An empty A2A Task with no history becomes a text content
340        // block carrying the task state, so the MCP client sees *some*
341        // answer rather than a 0-block result it has to special-case.
342        if let Some(state) = object
343            .get("status")
344            .and_then(Value::as_object)
345            .and_then(|status| status.get("state"))
346            .and_then(Value::as_str)
347        {
348            content = vec![crate::domain::ir::Content::Text(
349                crate::domain::ir::TextContent::new(state),
350            )];
351        }
352    }
353    let mut out = serde_json::Map::new();
354    out.insert(
355        "content".to_owned(),
356        Value::Array(content_to_values(&content)),
357    );
358    let is_error = object
359        .get("status")
360        .and_then(Value::as_object)
361        .and_then(|status| status.get("state"))
362        .and_then(Value::as_str)
363        .is_some_and(|state| state == "failed" || state == "rejected");
364    if is_error {
365        out.insert("isError".to_owned(), Value::Bool(true));
366    }
367    Value::Object(out)
368}
369
370fn a2a_task_content(
371    object: &Map<String, Value>,
372    report: &mut crate::domain::bridge::a2a_mcp::CrossProtocolReport,
373) -> Vec<crate::domain::ir::Content> {
374    // It's a Task; pick the most recent non-user message from history.
375    let history = object
376        .get("history")
377        .and_then(Value::as_array)
378        .cloned()
379        .unwrap_or_default();
380    let last_message: Option<crate::domain::a2a::message::Message> =
381        history.iter().rev().find_map(|m| {
382            let is_agent = m
383                .as_object()
384                .and_then(|obj| obj.get("role"))
385                .and_then(Value::as_str)
386                .is_some_and(|role| role != "user");
387            if is_agent {
388                serde_json::from_value(m.clone()).ok()
389            } else {
390                None
391            }
392        });
393    last_message.map_or_else(Vec::new, |message| {
394        a2a_message_to_mcp_content(&message, report)
395    })
396}
397
398fn mcp_to_a2a_result(result: &Value) -> Value {
399    // MCP `CallToolResult`: `{"content": [...], "isError": bool?}`.
400    let Some(object) = result.as_object() else {
401        return result.clone();
402    };
403    let content = object.get("content").and_then(Value::as_array);
404    let parts = content.map_or_else(Vec::new, |items| {
405        items.iter().map(value_to_a2a_part_value).collect()
406    });
407    let mut message = serde_json::Map::new();
408    message.insert("role".to_owned(), Value::String("assistant".to_owned()));
409    message.insert("parts".to_owned(), Value::Array(parts));
410    let mut out = serde_json::Map::new();
411    out.insert("message".to_owned(), Value::Object(message));
412    if object.get("isError").and_then(Value::as_bool) == Some(true) {
413        let mut status = serde_json::Map::new();
414        status.insert("state".to_owned(), Value::String("failed".to_owned()));
415        out.insert("status".to_owned(), Value::Object(status));
416    }
417    Value::Object(out)
418}
419
420fn value_to_a2a_part_value(value: &Value) -> Value {
421    // A wire `Content` block carries `"type": "text" | "image" | ...`
422    // and the rest of the block is the type-specific payload. A
423    // bridge that does not recognise the type falls through to a
424    // `DataPart` with the whole block, which is lossless and lets the
425    // A2A agent read whatever it can.
426    let Some(object) = value.as_object() else {
427        return value.clone();
428    };
429    let kind = object.get("type").and_then(Value::as_str).unwrap_or("data");
430    match kind {
431        "text" => {
432            let text = object
433                .get("text")
434                .and_then(Value::as_str)
435                .unwrap_or("")
436                .to_owned();
437            let mut part = serde_json::Map::new();
438            part.insert("kind".to_owned(), Value::String("text".to_owned()));
439            part.insert("text".to_owned(), Value::String(text));
440            Value::Object(part)
441        }
442        "image" | "audio" | "resource_link" | "resource" => {
443            let mut part = serde_json::Map::new();
444            part.insert("kind".to_owned(), Value::String("file".to_owned()));
445            part.insert("file".to_owned(), value.clone());
446            Value::Object(part)
447        }
448        _ => {
449            let mut part = serde_json::Map::new();
450            part.insert("kind".to_owned(), Value::String("data".to_owned()));
451            part.insert("data".to_owned(), value.clone());
452            Value::Object(part)
453        }
454    }
455}
456
457fn clone_message(object: &Map<String, Value>) -> crate::domain::a2a::message::Message {
458    // The mapping rule expects a typed `Message`; the A2A schema's
459    // wire form is exactly that, so a typed parse is the simplest
460    // path. A bridge that does not trust the wire form would lift
461    // first; the codec is the right place to do it. A parse that
462    // fails is a malformed reply from the upstream, which the bridge
463    // already treats as an error: an empty default keeps the bridge
464    // responsive rather than panic-prone.
465    let value = Value::Object(object.clone());
466    serde_json::from_value(value).unwrap_or_default()
467}
468
469fn content_to_values(content: &[crate::domain::ir::Content]) -> Vec<Value> {
470    content.iter().map(content_to_value).collect()
471}
472
473fn content_to_value(content: &crate::domain::ir::Content) -> Value {
474    use crate::domain::ir::Content;
475    match content {
476        Content::Text(text) => {
477            let mut map = serde_json::Map::new();
478            map.insert("type".to_owned(), Value::String("text".to_owned()));
479            map.insert("text".to_owned(), Value::String(text.text.clone()));
480            Value::Object(map)
481        }
482        Content::Image(image) => {
483            let mut map = serde_json::Map::new();
484            map.insert("type".to_owned(), Value::String("image".to_owned()));
485            map.insert("data".to_owned(), Value::String(image.data.clone()));
486            map.insert(
487                "mimeType".to_owned(),
488                Value::String(image.mime_type.clone()),
489            );
490            Value::Object(map)
491        }
492        Content::Audio(audio) => {
493            let mut map = serde_json::Map::new();
494            map.insert("type".to_owned(), Value::String("audio".to_owned()));
495            map.insert("data".to_owned(), Value::String(audio.data.clone()));
496            map.insert(
497                "mimeType".to_owned(),
498                Value::String(audio.mime_type.clone()),
499            );
500            Value::Object(map)
501        }
502        Content::ResourceLink(link) => {
503            let mut map = serde_json::Map::new();
504            map.insert("type".to_owned(), Value::String("resource_link".to_owned()));
505            map.insert("uri".to_owned(), Value::String(link.uri.clone()));
506            map.insert("name".to_owned(), Value::String(link.name.clone()));
507            if let Some(description) = link.description.as_ref() {
508                map.insert("description".to_owned(), Value::String(description.clone()));
509            }
510            if let Some(mime) = link.mime_type.as_ref() {
511                map.insert("mimeType".to_owned(), Value::String(mime.clone()));
512            }
513            Value::Object(map)
514        }
515        Content::EmbeddedResource(resource) => {
516            let mut map = serde_json::Map::new();
517            map.insert("type".to_owned(), Value::String("resource".to_owned()));
518            let mut resource_map = serde_json::Map::new();
519            resource_map.insert("uri".to_owned(), Value::String(resource.uri.clone()));
520            if let Some(text) = resource.text.as_ref() {
521                resource_map.insert("text".to_owned(), Value::String(text.clone()));
522            }
523            if let Some(blob) = resource.blob.as_ref() {
524                resource_map.insert("blob".to_owned(), Value::String(blob.clone()));
525            }
526            if let Some(mime) = resource.mime_type.as_ref() {
527                resource_map.insert("mimeType".to_owned(), Value::String(mime.clone()));
528            }
529            map.insert("resource".to_owned(), Value::Object(resource_map));
530            Value::Object(map)
531        }
532        Content::Unknown(unknown) => match serde_json::from_str(&unknown.raw.to_string()) {
533            Ok(Value::Object(map)) => Value::Object(map),
534            Ok(other) => other,
535            Err(_) => Value::Null,
536        },
537    }
538}
539
540impl CrossProtocolCodec for Arc<A2aMcpCodec> {
541    fn downstream_protocol(&self) -> ProtocolSide {
542        (**self).downstream_protocol()
543    }
544
545    fn encode_request(
546        &self,
547        downstream_side: ProtocolSide,
548        ir_method: &str,
549        raw_envelope: &Map<String, Value>,
550        report: &mut RequestCodecReport,
551    ) -> Result<Map<String, Value>, CrossProtocolError> {
552        (**self).encode_request(downstream_side, ir_method, raw_envelope, report)
553    }
554
555    fn decode_reply(
556        &self,
557        downstream_side: ProtocolSide,
558        ir_method: &str,
559        reply: &Map<String, Value>,
560        report: &mut RequestCodecReport,
561    ) -> Result<Map<String, Value>, CrossProtocolError> {
562        (**self).decode_reply(downstream_side, ir_method, reply, report)
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use std::sync::Arc;
569
570    use serde_json::json;
571
572    use super::*;
573
574    fn report() -> RequestCodecReport {
575        RequestCodecReport::empty()
576    }
577
578    fn object_of(input: &Value) -> Map<String, Value> {
579        input.as_object().cloned().unwrap_or_default()
580    }
581
582    #[test]
583    fn a2a_send_message_becomes_mcp_tools_call() -> Result<(), Box<dyn std::error::Error>> {
584        let codec = A2aMcpCodec::new();
585        let mut rep = report();
586        let raw = object_of(&json!({
587            "jsonrpc": "2.0",
588            "id": 1,
589            "method": "SendMessage",
590            "params": {
591                "message": {
592                    "role": "user",
593                    "parts": [
594                        { "kind": "data", "data": { "query": "weather" } }
595                    ]
596                },
597                "metadata": { "bridge_skill": "lookup" }
598            }
599        }));
600        let out = codec.encode_request(ProtocolSide::A2a, "SendMessage", &raw, &mut rep)?;
601        assert_eq!(
602            out.get("method").and_then(Value::as_str),
603            Some("tools/call")
604        );
605        let params = out
606            .get("params")
607            .and_then(Value::as_object)
608            .ok_or("missing params")?;
609        assert_eq!(params.get("name").and_then(Value::as_str), Some("lookup"));
610        assert_eq!(
611            params.get("arguments"),
612            Some(&json!({ "query": "weather" }))
613        );
614        Ok(())
615    }
616
617    #[test]
618    fn mcp_tools_call_becomes_a2a_send_message() -> Result<(), Box<dyn std::error::Error>> {
619        let codec = A2aMcpCodec::new();
620        let mut rep = report();
621        let raw = object_of(&json!({
622            "jsonrpc": "2.0",
623            "id": 1,
624            "method": "tools/call",
625            "params": {
626                "name": "lookup",
627                "arguments": { "query": "weather" }
628            }
629        }));
630        let out = codec.encode_request(ProtocolSide::Mcp, "tools/call", &raw, &mut rep)?;
631        assert_eq!(
632            out.get("method").and_then(Value::as_str),
633            Some("SendMessage")
634        );
635        let params = out
636            .get("params")
637            .and_then(Value::as_object)
638            .ok_or("missing params")?;
639        let metadata = params
640            .get("metadata")
641            .and_then(Value::as_object)
642            .ok_or("missing metadata")?;
643        assert_eq!(
644            metadata.get("bridge_skill").and_then(Value::as_str),
645            Some("lookup")
646        );
647        Ok(())
648    }
649
650    #[test]
651    fn an_unbridged_method_is_refused() {
652        let codec = A2aMcpCodec::new();
653        let mut rep = report();
654        let raw = object_of(&json!({ "jsonrpc": "2.0", "id": 1, "method": "GetTask" }));
655        let outcome = codec.encode_request(ProtocolSide::A2a, "GetTask", &raw, &mut rep);
656        assert!(matches!(
657            outcome,
658            Err(CrossProtocolError::MethodNotBridged(_, ProtocolSide::A2a))
659        ));
660    }
661
662    #[test]
663    fn a_task_reply_becomes_a_mcp_tool_result() -> Result<(), Box<dyn std::error::Error>> {
664        let codec = A2aMcpCodec::new();
665        let mut rep = report();
666        let reply = object_of(&json!({
667            "jsonrpc": "2.0",
668            "id": 1,
669            "result": {
670                "id": "task-1",
671                "status": { "state": "completed" },
672                "history": [
673                    { "role": "user", "parts": [{ "kind": "text", "text": "hi" }] },
674                    { "role": "agent", "parts": [{ "kind": "text", "text": "hello" }] }
675                ]
676            }
677        }));
678        // An MCP client called `tools/call`; the bridge turned it
679        // into an A2A `SendMessage`; the upstream answered with a
680        // Task. The reply must come back as an MCP `content` block.
681        let out = codec.decode_reply(ProtocolSide::Mcp, "tools/call", &reply, &mut rep)?;
682        let result = out
683            .get("result")
684            .and_then(Value::as_object)
685            .ok_or("missing result")?;
686        let content = result
687            .get("content")
688            .and_then(Value::as_array)
689            .ok_or("missing content")?;
690        assert!(!content.is_empty());
691        Ok(())
692    }
693
694    #[test]
695    fn an_mcp_tool_result_becomes_an_a2a_message() -> Result<(), Box<dyn std::error::Error>> {
696        let codec = A2aMcpCodec::new();
697        let mut rep = report();
698        let reply = object_of(&json!({
699            "jsonrpc": "2.0",
700            "id": 1,
701            "result": {
702                "content": [{ "type": "text", "text": "hello" }],
703                "isError": false
704            }
705        }));
706        // An A2A client called `SendMessage`; the bridge turned it
707        // into MCP `tools/call`; the upstream answered with a
708        // `CallToolResult`. The reply must come back as an A2A
709        // `Message`.
710        let out = codec.decode_reply(ProtocolSide::A2a, "SendMessage", &reply, &mut rep)?;
711        let result = out
712            .get("result")
713            .and_then(Value::as_object)
714            .ok_or("missing result")?;
715        let message = result.get("message").ok_or("missing message")?;
716        assert!(message.is_object());
717        Ok(())
718    }
719
720    #[test]
721    fn the_codec_is_send_and_sync_via_arc() {
722        fn assert_send_sync<T: Send + Sync>(_: &T) {}
723        let codec: Arc<A2aMcpCodec> = Arc::new(A2aMcpCodec::new());
724        assert_send_sync(&codec);
725    }
726}