Skip to main content

protocol_droid/adapters/secondary/codec/result/
draft.rs

1//! The `draft` `CallToolResult` codec.
2//!
3//! # `draft` is not a version
4//!
5//! Upstream's floating `draft` schema declares
6//! `LATEST_PROTOCOL_VERSION = "2026-07-28"`, the same string the final revision
7//! declares. The two differ in schema _shape_, not in what a peer negotiates,
8//! so this codec's [`version`](Codec::version) is
9//! [`ProtocolVersion::V2026_07_28`] — the same value the `2026-07-28` codec returns. A
10//! session that has negotiated a version cannot know which shape it is talking
11//! to, and inventing a distinction the wire does not carry would be worse than
12//! having none.
13//!
14//! # What this revision adds to `2025-11-25`
15//!
16//! One field: **`resultType` on the result, and it is required.** The
17//! specification makes every server state which kind of result this is —
18//! `complete`, or `input_required` when the client has to supply more before
19//! the call can finish. A peer that omits it is not sending a result at this
20//! revision, so the generated wire type has no `Option` to give.
21//!
22//! An absent field defaults to `"complete"`, and that is the specification's
23//! instruction rather than a convenience. The `resultType` description in the
24//! vendored schema says: "For backward compatibility, when a client receives a
25//! result from a server implementing an earlier protocol version (which does
26//! not include `resultType`), the client MUST treat the absent field as
27//! `complete`." The IR spells the field `Option<String>` precisely so a result
28//! lifted from an earlier peer arrives with `None`, and the default is what the
29//! protocol says to do with it.
30//!
31//! An earlier draft of this codec *refused* instead, on the grounds that
32//! writing `"complete"` would assert the call finished when nobody said so.
33//! That is the right instinct applied to the wrong question: the field is
34//! required for a peer at this revision, so a result without one did come from
35//! an earlier one, and the protocol has already decided what to assume.
36//!
37//! The other direction needs no code here: `ToolResult::required_features`
38//! raises `Feature::ToolResultType` when the field is set, so lowering a result
39//! that _has_ one back to `2025-11-25` is refused by the gate before this codec
40//! is reached.
41//!
42//! Everything else — the content union's five arms in the same order, icons,
43//! `structuredContent`, per-block `meta` — is unchanged, and the union is still
44//! untagged, so the arm order remains the only thing keeping an audio block from
45//! arriving as an image.
46//!
47//! # `size` is signed on the wire and unsigned here
48//!
49//! `ResourceLink::size` is `i64` and [`ResourceLinkContent::size`] is `u64`,
50//! so a negative value is not representable and is refused rather than clamped.
51
52/// The value the specification says to read an absent `resultType` as.
53const COMPLETE: &str = "complete";
54
55use protocol_droid_schema::wire::draft as wire;
56
57use super::{AUDIO_TYPE, IMAGE_TYPE, META_KEY, RESOURCE_LINK_TYPE, RESOURCE_TYPE, TEXT_TYPE};
58use crate::domain::ir::Codec;
59use crate::domain::ir::lower::{LowerError, TranslationReport, lower_tool_result};
60use crate::domain::ir::{
61    Annotations, AudioContent, Content, EmbeddedResource, ExtensionValue, Extensions, ImageContent,
62    JsonObject, ResourceIcon, ResourceLinkContent, Role, TextContent, ToolResult,
63};
64use crate::domain::{
65    CapabilitySet, DegradePolicy, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
66    resolve,
67};
68
69fn lift_annotations(annotations: &wire::Annotations) -> Annotations {
70    Annotations {
71        audience: annotations
72            .audience
73            .iter()
74            .flatten()
75            .map(|role| match role {
76                wire::Role::ASSISTANT => Role::Assistant,
77                wire::Role::USER => Role::User,
78            })
79            .collect(),
80        priority: annotations.priority,
81        last_modified: annotations.last_modified.clone(),
82        extensions: Extensions::from_json_object(annotations.extra.clone()),
83    }
84}
85
86fn lower_annotations(annotations: Option<&Annotations>) -> Option<wire::Annotations> {
87    let annotations = annotations?;
88    let extra = annotations.extensions.to_json_object();
89    if annotations.audience.is_empty()
90        && annotations.priority.is_none()
91        && annotations.last_modified.is_none()
92        && extra.is_empty()
93    {
94        return None;
95    }
96    Some(wire::Annotations {
97        audience: (!annotations.audience.is_empty()).then(|| {
98            annotations
99                .audience
100                .iter()
101                .map(|role| match role {
102                    Role::Assistant => wire::Role::ASSISTANT,
103                    Role::User => wire::Role::USER,
104                })
105                .collect()
106        }),
107        last_modified: annotations.last_modified.clone(),
108        priority: annotations.priority,
109        extra,
110    })
111}
112
113/// The key a `structuredContent` that is not a JSON object travels under.
114///
115/// The wire type is a bare `serde_json::Value`, so a peer may legally send an
116/// array or a string. [`JsonObject`] holds only objects, so such a value has
117/// nowhere to go in the typed field — and a lift that dropped it would be the
118/// one outcome a lift may not have. It rides in the bag instead, exactly as
119/// unmodelled `annotations` does at `2024-11-05`, and is written back out
120/// ahead of the typed field.
121const RAW_STRUCTURED_KEY: &str = "structuredContent";
122
123/// A block's own `meta`, taken from the extensions on the way down and put
124/// there on the way up.
125///
126/// A block's `meta` is the same concept as the result's, so it shares
127/// [`META_KEY`] rather than each inventing one. There is no collision: a block
128/// and the result it sits in are separate objects with separate bags.
129fn take_meta(extensions: &Extensions) -> Option<serde_json::Value> {
130    let mut extensions = extensions.clone();
131    extensions
132        .take(META_KEY)
133        .map(|value| serde_json::Value::from(&value))
134}
135
136/// The `draft` codec.
137#[derive(Debug, Clone, Copy)]
138pub struct Draft;
139
140impl Codec for Draft {
141    type Wire = wire::CallToolResult;
142
143    fn version(&self) -> ProtocolVersion {
144        ProtocolVersion::V2026_07_28
145    }
146
147    fn capabilities(&self) -> FeatureProfile {
148        let caps = CapabilitySet::all_for(self.version());
149        resolve(Negotiation {
150            version: self.version(),
151            client_caps: caps,
152            server_caps: caps,
153            transport: TransportKind::Stdio,
154        })
155    }
156
157    fn lift(&self, wire: &Self::Wire) -> ToolResult {
158        let mut extensions = Extensions::from_json_object(wire.extra.clone());
159        if let Some(meta) = &wire.meta {
160            extensions.insert(META_KEY, ExtensionValue::from(meta_to_value(meta)));
161        }
162        // A `structuredContent` that is not an object has nowhere in the typed
163        // field to go, so it rides in the bag. `.ok()` would be the bug here:
164        // a lift that quietly discarded an array a peer sent is the outcome
165        // this whole trait exists to prevent.
166        let structured_content = match &wire.structured_content {
167            Some(value) if value.is_object() => JsonObject::new(value.to_string()).ok(),
168            Some(value) => {
169                extensions.insert(RAW_STRUCTURED_KEY, ExtensionValue::from(value.clone()));
170                None
171            }
172            None => None,
173        };
174        ToolResult {
175            content: wire.content.iter().map(lift_block).collect(),
176            structured_content,
177            is_error: wire.is_error.unwrap_or(false),
178            result_type: Some(wire.result_type.clone()),
179            extensions,
180        }
181    }
182
183    fn lower(
184        &self,
185        value: ToolResult,
186        policy: DegradePolicy,
187        report: &mut TranslationReport,
188    ) -> Result<Self::Wire, LowerError> {
189        let (value, lowered) =
190            lower_tool_result(value, &self.capabilities(), self.version(), policy)?;
191        report.absorb(&lowered);
192        // A raw value kept on the way in takes precedence: it is what the peer
193        // actually sent, and re-deriving it from the typed field would be
194        // inventing a shape for a value that never had one.
195        // An absent `resultType` is a result from an earlier peer, and the
196        // specification says to read it as `complete`.
197        let result_type = value
198            .result_type
199            .clone()
200            .unwrap_or_else(|| COMPLETE.to_owned());
201        let structured_content = match value.extensions.get(RAW_STRUCTURED_KEY) {
202            Some(raw) => Some(serde_json::Value::from(raw)),
203            None => value
204                .structured_content
205                .as_ref()
206                .and_then(|object| serde_json::from_str(object.as_str()).ok()),
207        };
208        Ok(wire::CallToolResult {
209            meta: lower_meta(value.extensions.get(META_KEY), ProtocolVersion::V2026_07_28)?,
210            content: value
211                .content
212                .iter()
213                .map(lower_block)
214                .collect::<Result<Vec<_>, _>>()?,
215            is_error: value.is_error.then_some(true),
216            result_type,
217            structured_content,
218            extra: value.extensions.to_json_object(),
219        })
220    }
221}
222
223/// Lifts one wire [`Icon`][w].
224///
225/// [w]: wire::Icon
226fn lift_icon(icon: &wire::Icon) -> ResourceIcon {
227    ResourceIcon {
228        src: icon.src.clone(),
229        mime_type: icon.mime_type.clone(),
230        // The wire carries an absent `sizes` and an empty one as the same
231        // thing, and the IR spells the empty case as an empty `Vec`. `flatten`
232        // is what does that without an `unwrap_or_default`, which would hide
233        // a decision rather than record it.
234        sizes: icon.sizes.clone().unwrap_or_default(),
235        theme: icon.theme.clone(),
236        extensions: Extensions::from_json_object(icon.extra.clone()),
237    }
238}
239
240/// Lowers one [`ResourceIcon`] to the wire type.
241///
242/// `theme` is untyped in the specification — every revision that has it writes
243/// it as free JSON — so it is carried as a raw value and put back exactly as
244/// it arrived, with no parsing in either direction. Anything narrower here
245/// would reject a value the schema accepts.
246fn lower_icon(icon: &ResourceIcon) -> wire::Icon {
247    wire::Icon {
248        src: icon.src.clone(),
249        mime_type: icon.mime_type.clone(),
250        sizes: (!icon.sizes.is_empty()).then(|| icon.sizes.clone()),
251        theme: icon.theme.clone(),
252        extra: icon.extensions.to_json_object(),
253    }
254}
255
256/// The `_meta` a result carries, as a JSON value.
257///
258/// `_meta` is free JSON in every earlier revision and a typed
259/// `ResultMetaObject` here, so the bag has to be filled from fields rather than
260/// from something that is already a value.
261///
262/// It is built by hand rather than with `serde_json::to_value` on purpose. The
263/// struct is strings and a map that is already a map, so a derived
264/// `Serialize` for it has no failure to produce — and a hand-built function
265/// therefore has no error path, which removes the temptation to answer a
266/// `Result` with a lossy default. The cost is that an upstream field added to
267/// `Implementation` has to be added here as well.
268fn meta_to_value(meta: &wire::ResultMetaObject) -> serde_json::Value {
269    let mut map = meta.extra.clone();
270    if let Some(info) = &meta.io_modelcontextprotocol_server_info {
271        let mut object = info.extra.clone();
272        object.insert(
273            "name".to_owned(),
274            serde_json::Value::String(info.name.clone()),
275        );
276        object.insert(
277            "version".to_owned(),
278            serde_json::Value::String(info.version.clone()),
279        );
280        for (key, value) in [
281            ("title", &info.title),
282            ("description", &info.description),
283            ("websiteUrl", &info.website_url),
284        ] {
285            if let Some(value) = value {
286                object.insert(key.to_owned(), serde_json::Value::String(value.clone()));
287            }
288        }
289        if let Some(icons) = &info.icons {
290            object.insert(
291                "icons".to_owned(),
292                serde_json::Value::Array(icons.iter().map(icon_to_value).collect()),
293            );
294        }
295        map.insert(
296            SERVER_INFO_KEY.to_owned(),
297            serde_json::Value::Object(object),
298        );
299    }
300    serde_json::Value::Object(map)
301}
302
303/// One wire `Icon` as a JSON value — the counterpart of `lower_icon`, written
304/// out rather than derived for the same reason as `meta_to_value`.
305fn icon_to_value(icon: &wire::Icon) -> serde_json::Value {
306    let mut map = icon.extra.clone();
307    map.insert(
308        "src".to_owned(),
309        serde_json::Value::String(icon.src.clone()),
310    );
311    if let Some(mime_type) = &icon.mime_type {
312        map.insert(
313            "mimeType".to_owned(),
314            serde_json::Value::String(mime_type.clone()),
315        );
316    }
317    if let Some(sizes) = &icon.sizes {
318        map.insert(
319            "sizes".to_owned(),
320            serde_json::Value::Array(
321                sizes
322                    .iter()
323                    .map(|s| serde_json::Value::String(s.clone()))
324                    .collect(),
325            ),
326        );
327    }
328    if let Some(theme) = &icon.theme {
329        map.insert("theme".to_owned(), theme.clone());
330    }
331    serde_json::Value::Object(map)
332}
333
334/// Reads `_meta` back out of the bag into the typed struct this revision wants.
335///
336/// A bag value that is not an object, or whose `serverInfo` is not shaped the
337/// way the specification declares, is refused. Both would be data this revision
338/// cannot express, and dropping either is the outcome the bag exists to
339/// prevent.
340fn lower_meta(
341    value: Option<&ExtensionValue>,
342    target: ProtocolVersion,
343) -> Result<Option<wire::ResultMetaObject>, LowerError> {
344    let Some(raw) = value else {
345        return Ok(None);
346    };
347    let as_value = serde_json::Value::from(raw);
348    let object = as_value
349        .as_object()
350        .ok_or_else(|| LowerError::FieldNotRepresentable {
351            field: "_meta".to_owned(),
352            expected: "an object",
353            detail: "the result carried `_meta` that is not an object".to_owned(),
354            target,
355        })?;
356    let result: wire::ResultMetaObject =
357        serde_json::from_value(serde_json::Value::Object(object.clone())).map_err(|error| {
358            LowerError::FieldNotRepresentable {
359                field: "_meta".to_owned(),
360                expected: "a result meta object",
361                detail: error.to_string(),
362                target,
363            }
364        })?;
365    let _ = object;
366    Ok(Some(result))
367}
368
369/// The `serverInfo` member inside `_meta`, spelled the way the specification
370/// spells it: a namespaced key, not a field name.
371const SERVER_INFO_KEY: &str = "io.modelcontextprotocol/serverInfo";
372
373fn lift_block(block: &wire::ContentBlock) -> Content {
374    match block {
375        wire::ContentBlock::Member0(text) => {
376            let mut extensions = Extensions::from_json_object(text.extra.clone());
377            if let Some(meta) = &text.meta {
378                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
379            }
380            Content::Text(TextContent {
381                text: text.text.clone(),
382                annotations: text.annotations.as_ref().map(lift_annotations),
383                extensions,
384            })
385        }
386        wire::ContentBlock::Member1(image) => {
387            let mut extensions = Extensions::from_json_object(image.extra.clone());
388            if let Some(meta) = &image.meta {
389                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
390            }
391            Content::Image(ImageContent {
392                data: image.data.clone(),
393                mime_type: image.mime_type.clone(),
394                annotations: image.annotations.as_ref().map(lift_annotations),
395                extensions,
396            })
397        }
398        wire::ContentBlock::Member2(audio) => {
399            let mut extensions = Extensions::from_json_object(audio.extra.clone());
400            if let Some(meta) = &audio.meta {
401                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
402            }
403            Content::Audio(AudioContent {
404                data: audio.data.clone(),
405                mime_type: audio.mime_type.clone(),
406                annotations: audio.annotations.as_ref().map(lift_annotations),
407                extensions,
408            })
409        }
410        wire::ContentBlock::Member3(link) => {
411            let mut extensions = Extensions::from_json_object(link.extra.clone());
412            if let Some(meta) = &link.meta {
413                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
414            }
415            Content::ResourceLink(ResourceLinkContent {
416                uri: link.uri.clone(),
417                name: link.name.clone(),
418                description: link.description.clone(),
419                mime_type: link.mime_type.clone(),
420                // Negative sizes are refused on the way down; a value that got
421                // here from a peer is not negative, and `as` is the narrowing
422                // that makes the two sides agree.
423                size: link.size.and_then(|size| u64::try_from(size).ok()),
424                icons: link.icons.iter().flatten().map(lift_icon).collect(),
425                title: link.title.clone(),
426                annotations: link.annotations.as_ref().map(lift_annotations),
427                extensions,
428            })
429        }
430        wire::ContentBlock::Member4(resource) => {
431            let wire::EmbeddedResource {
432                meta,
433                annotations,
434                resource,
435                extra,
436                ..
437            } = resource;
438            let (uri, mime_type, text, blob) = match resource {
439                wire::EmbeddedResourceResourceChoice::Member0(text) => (
440                    text.uri.clone(),
441                    text.mime_type.clone(),
442                    Some(text.text.clone()),
443                    None,
444                ),
445                wire::EmbeddedResourceResourceChoice::Member1(blob) => (
446                    blob.uri.clone(),
447                    blob.mime_type.clone(),
448                    None,
449                    Some(blob.blob.clone()),
450                ),
451            };
452            let mut extensions = Extensions::from_json_object(extra.clone());
453            if let Some(meta) = meta {
454                extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
455            }
456            Content::EmbeddedResource(EmbeddedResource {
457                uri,
458                mime_type,
459                text,
460                blob,
461                annotations: annotations.as_ref().map(lift_annotations),
462                extensions,
463            })
464        }
465    }
466}
467
468fn lower_block(block: &Content) -> Result<wire::ContentBlock, LowerError> {
469    let lowered = match block {
470        Content::Text(text) => wire::ContentBlock::Member0(wire::TextContent {
471            meta: take_meta(&text.extensions),
472            annotations: lower_annotations(text.annotations.as_ref()),
473            text: text.text.clone(),
474            r#type: TEXT_TYPE.to_owned(),
475            extra: text.extensions.to_json_object(),
476        }),
477        Content::Image(image) => wire::ContentBlock::Member1(wire::ImageContent {
478            meta: take_meta(&image.extensions),
479            annotations: lower_annotations(image.annotations.as_ref()),
480            data: image.data.clone(),
481            mime_type: image.mime_type.clone(),
482            r#type: IMAGE_TYPE.to_owned(),
483            extra: image.extensions.to_json_object(),
484        }),
485        Content::Audio(audio) => wire::ContentBlock::Member2(wire::AudioContent {
486            meta: take_meta(&audio.extensions),
487            annotations: lower_annotations(audio.annotations.as_ref()),
488            data: audio.data.clone(),
489            mime_type: audio.mime_type.clone(),
490            r#type: AUDIO_TYPE.to_owned(),
491            extra: audio.extensions.to_json_object(),
492        }),
493        Content::ResourceLink(link) => {
494            let size = link.size.map_or(Ok(None), |size| {
495                i64::try_from(size)
496                    .map(Some)
497                    .map_err(|_| LowerError::FieldNotRepresentable {
498                        field: "size".to_owned(),
499                        expected: "a non-negative size",
500                        detail: format!("{size} does not fit the i64 this revision uses"),
501                        target: ProtocolVersion::V2026_07_28,
502                    })
503            })?;
504            wire::ContentBlock::Member3(wire::ResourceLink {
505                meta: take_meta(&link.extensions),
506                annotations: lower_annotations(link.annotations.as_ref()),
507                description: link.description.clone(),
508                mime_type: link.mime_type.clone(),
509                name: link.name.clone(),
510                size,
511                title: link.title.clone(),
512                r#type: RESOURCE_LINK_TYPE.to_owned(),
513                uri: link.uri.clone(),
514                icons: (!link.icons.is_empty())
515                    .then(|| link.icons.iter().map(lower_icon).collect()),
516                extra: link.extensions.to_json_object(),
517            })
518        }
519        Content::EmbeddedResource(resource) => {
520            let contents = if let Some(text) = &resource.text {
521                wire::EmbeddedResourceResourceChoice::Member0(wire::TextResourceContents {
522                    meta: None,
523                    mime_type: resource.mime_type.clone(),
524                    text: text.clone(),
525                    uri: resource.uri.clone(),
526                    extra: serde_json::Map::new(),
527                })
528            } else if let Some(blob) = &resource.blob {
529                wire::EmbeddedResourceResourceChoice::Member1(wire::BlobResourceContents {
530                    meta: None,
531                    blob: blob.clone(),
532                    mime_type: resource.mime_type.clone(),
533                    uri: resource.uri.clone(),
534                    extra: serde_json::Map::new(),
535                })
536            } else {
537                return Err(LowerError::FieldNotRepresentable {
538                    field: "resource".to_owned(),
539                    expected: "a text or blob body",
540                    detail: "the resource has neither `text` nor `blob`".to_owned(),
541                    target: ProtocolVersion::V2026_07_28,
542                });
543            };
544            wire::ContentBlock::Member4(wire::EmbeddedResource {
545                meta: take_meta(&resource.extensions),
546                annotations: lower_annotations(resource.annotations.as_ref()),
547                resource: contents,
548                r#type: RESOURCE_TYPE.to_owned(),
549                extra: resource.extensions.to_json_object(),
550            })
551        }
552        Content::Unknown(unknown) => {
553            return Err(LowerError::FieldNotRepresentable {
554                field: unknown.type_name.clone(),
555                expected: "a draft content block",
556                detail: "this build does not model the block, and this revision has no \
557                         member that could carry it"
558                    .to_owned(),
559                target: ProtocolVersion::V2026_07_28,
560            });
561        }
562    };
563    Ok(lowered)
564}
565
566#[cfg(test)]
567mod tests {
568    /// What a test fails with.
569    ///
570    /// One type for every error a test body can hit, so `?` carries a codec
571    /// error, a JSON error or a bare message alike and the harness prints it
572    /// rather than the test having to choose between three error types.
573    type TestError = Box<dyn std::error::Error>;
574
575    use super::Draft;
576    use crate::domain::DegradePolicy;
577    use crate::domain::ir::Codec;
578    use crate::domain::ir::lower::TranslationReport;
579    use crate::domain::ir::{Content, ResourceIcon, ResourceLinkContent, ToolResult};
580
581    fn round_trip(json: &str) -> Result<serde_json::Value, TestError> {
582        let codec = Draft;
583        let wire: super::wire::CallToolResult = serde_json::from_str(json)?;
584        let mut report = TranslationReport::new(codec.version());
585        let lowered = codec.lower(codec.lift(&wire), DegradePolicy::strict(), &mut report)?;
586        Ok(serde_json::to_value(&lowered)?)
587    }
588
589    /// The revision is otherwise identical to `2025-06-18`, so the arm order
590    /// is the same claim: `Member3` is the resource link, `Member4` the
591    /// embedded resource. Getting that wrong makes a link arrive as a
592    /// resource or the reverse, with no error anywhere.
593    #[test]
594    fn a_resource_link_is_the_fourth_arm() -> Result<(), TestError> {
595        let value = round_trip(
596            r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a"}],"resultType":"complete"}"#,
597        )?;
598        let content = value
599            .get("content")
600            .and_then(|c| c.as_array())
601            .and_then(|a| a.first())
602            .ok_or("the result has no blocks")?;
603        assert_eq!(
604            content.get("type").and_then(|t| t.as_str()),
605            Some("resource_link")
606        );
607        Ok(())
608    }
609
610    /// An audio block is `{data, mimeType}` and an image block is the same
611    /// shape. Tried in order, audio lands in the image arm and comes back
612    /// claiming to be one.
613    #[test]
614    fn an_audio_block_is_not_returned_as_an_image() -> Result<(), TestError> {
615        let value = round_trip(
616            r#"{"content":[{"type":"audio","data":"AAA","mimeType":"audio/wav"}],"resultType":"complete"}"#,
617        )?;
618        let content = value
619            .get("content")
620            .and_then(|c| c.as_array())
621            .and_then(|a| a.first())
622            .ok_or("the result has no blocks")?;
623        assert_eq!(content.get("type").and_then(|t| t.as_str()), Some("audio"));
624        Ok(())
625    }
626
627    /// The whole point of this revision. Every modelled `Icon` member has to
628    /// survive, and `theme` has to come back as the value that was sent rather
629    /// than as a stringified one.
630    #[test]
631    fn every_icon_member_survives_the_crossing() -> Result<(), TestError> {
632        let value = round_trip(
633            r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a",
634                 "icons":[{"src":"file:///i.png","mimeType":"image/png",
635                           "sizes":["48x48","any"],"theme":"dark"},
636                          {"src":"file:///j.svg"}]}],"resultType":"complete"}"#,
637        )?;
638        let icons = value
639            .get("content")
640            .and_then(|c| c.as_array())
641            .and_then(|a| a.first())
642            .and_then(|b| b.get("icons"))
643            .and_then(|i| i.as_array())
644            .ok_or("the fixture has no blocks where the test expects one")?;
645        assert_eq!(icons.len(), 2);
646        assert_eq!(
647            icons
648                .first()
649                .ok_or("the fixture has no icon there")?
650                .get("src")
651                .and_then(|s| s.as_str()),
652            Some("file:///i.png")
653        );
654        assert_eq!(
655            icons
656                .first()
657                .ok_or("the fixture has no icon there")?
658                .get("mimeType")
659                .and_then(|s| s.as_str()),
660            Some("image/png")
661        );
662        assert_eq!(
663            icons
664                .first()
665                .ok_or("the fixture has no icon there")?
666                .get("sizes")
667                .and_then(|s| s.as_array())
668                .map(Vec::len),
669            Some(2)
670        );
671        assert_eq!(
672            icons
673                .first()
674                .ok_or("the fixture has no icon there")?
675                .get("theme")
676                .and_then(|t| t.as_str()),
677            Some("dark")
678        );
679        // An icon with only `src` must not gain the members it never sent.
680        assert_eq!(
681            icons
682                .get(1)
683                .ok_or("the fixture has no icon there")?
684                .get("src")
685                .and_then(|s| s.as_str()),
686            Some("file:///j.svg")
687        );
688        assert!(
689            icons
690                .get(1)
691                .ok_or("the fixture has no icon there")?
692                .get("sizes")
693                .is_none(),
694            "an absent list is not an empty one"
695        );
696        Ok(())
697    }
698
699    /// An empty icon list and an absent one are the same thing on the wire, and
700    /// a codec that re-emits `"icons": []` makes a bridge's output differ from
701    /// its input for no protocol reason.
702    #[test]
703    fn an_empty_icon_list_is_not_written_out() -> Result<(), TestError> {
704        let value = round_trip(
705            r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a",
706                 "icons":[]}],"resultType":"complete"}"#,
707        )?;
708        let block = value
709            .get("content")
710            .and_then(|c| c.as_array())
711            .and_then(|a| a.first())
712            .ok_or("the result has no blocks")?;
713        assert!(
714            block.get("icons").is_none(),
715            "got {}",
716            block.get("icons").unwrap_or(&serde_json::Value::Null)
717        );
718        Ok(())
719    }
720
721    /// An `Icon` the schema does not name still has to come back. The bag is
722    /// the whole reason a bridge can forward a field the peer sent that this
723    /// build has never heard of.
724    #[test]
725    fn an_unmodelled_icon_member_is_returned_whole() -> Result<(), TestError> {
726        let value = round_trip(
727            r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a",
728                 "icons":[{"src":"file:///i.png","unheardOf":true}]}],"resultType":"complete"}"#,
729        )?;
730        let icon = value
731            .get("content")
732            .and_then(|c| c.as_array())
733            .and_then(|a| a.first())
734            .and_then(|b| b.get("icons"))
735            .and_then(|i| i.as_array())
736            .and_then(|a| a.first())
737            .ok_or("the result has no blocks")?;
738        assert_eq!(
739            icon.get("unheardOf").and_then(serde_json::Value::as_bool),
740            Some(true)
741        );
742        Ok(())
743    }
744
745    /// The IR type, built by hand rather than lifted, has to reach the wire
746    /// with every member intact. A lift-only test cannot catch a lowering that
747    /// drops a field the lift happens to always fill.
748    #[test]
749    fn a_hand_built_icon_reaches_the_wire() -> Result<(), TestError> {
750        let codec = Draft;
751        let mut ir = ToolResult::new(vec![Content::ResourceLink(ResourceLinkContent {
752            icons: vec![ResourceIcon {
753                src: "file:///i.png".to_owned(),
754                mime_type: Some("image/svg+xml".to_owned()),
755                sizes: vec!["96x96".to_owned()],
756                ..ResourceIcon::new("")
757            }],
758            ..ResourceLinkContent::new("file:///a", "a")
759        })]);
760        ir.result_type = Some("complete".to_owned());
761        let mut report = TranslationReport::new(codec.version());
762        let wire = codec.lower(ir, DegradePolicy::strict(), &mut report)?;
763        let value = serde_json::to_value(&wire)?;
764        let icon = value
765            .get("content")
766            .and_then(|c| c.as_array())
767            .and_then(|a| a.first())
768            .and_then(|b| b.get("icons"))
769            .and_then(|i| i.as_array())
770            .and_then(|a| a.first())
771            .ok_or("the result has no blocks")?;
772        assert_eq!(
773            icon.get("mimeType").and_then(|s| s.as_str()),
774            Some("image/svg+xml")
775        );
776        assert_eq!(
777            icon.get("sizes").and_then(|s| s.as_array()).map(Vec::len),
778            Some(1)
779        );
780        Ok(())
781    }
782
783    /// `resultType` is required here, so it has to come back.
784    #[test]
785    fn the_result_type_survives_the_crossing() -> Result<(), TestError> {
786        let value = round_trip(
787            r#"{"content":[{"type":"text","text":"hi"}],"resultType":"input_required"}"#,
788        )?;
789        assert_eq!(
790            value.get("resultType").and_then(|t| t.as_str()),
791            Some("input_required")
792        );
793        Ok(())
794    }
795
796    /// A result lifted from an older peer has no `resultType`, and the
797    /// specification says to read that as `complete`:
798    ///
799    /// > "when a client receives a result from a server implementing an
800    /// > earlier protocol version (which does not include `resultType`), the
801    /// > client MUST treat the absent field as `complete`."
802    ///
803    /// Asserting the default rather than a refusal, because the protocol has
804    /// already decided this and a codec that second-guesses it is wrong.
805    #[test]
806    fn an_absent_result_type_reads_as_complete() -> Result<(), TestError> {
807        let codec = Draft;
808        let ir = ToolResult::new(vec![Content::Text(crate::domain::ir::TextContent::new(
809            "hi",
810        ))]);
811        let mut report = TranslationReport::new(codec.version());
812        let wire = codec.lower(ir, DegradePolicy::strict(), &mut report)?;
813        assert_eq!(wire.result_type, "complete");
814        Ok(())
815    }
816
817    /// A result that *did* say keeps what it said. The default is for the
818    /// absent case, not a normalisation that overwrites the sender.
819    #[test]
820    fn a_stated_result_type_is_not_overwritten() -> Result<(), TestError> {
821        let value = round_trip(
822            r#"{"content":[{"type":"text","text":"hi"}],
823                 "resultType":"input_required"}"#,
824        )?;
825        assert_eq!(
826            value.get("resultType").and_then(|t| t.as_str()),
827            Some("input_required")
828        );
829        Ok(())
830    }
831
832    /// `_meta` is a typed struct here and free JSON everywhere earlier, so the
833    /// namespaced `serverInfo` key has to survive the trip into a modelled
834    /// field and back out under its original spelling.
835    #[test]
836    fn a_typed_result_meta_survives_the_crossing() -> Result<(), TestError> {
837        let value = round_trip(
838            r#"{"content":[{"type":"text","text":"hi"}],"resultType":"complete",
839                 "_meta":{"io.modelcontextprotocol/serverInfo":{"name":"srv","version":"1"},
840                          "unheardOf":true}}"#,
841        )?;
842        let meta = value.get("_meta").ok_or("the fixture carries no `_meta`")?;
843        let info = meta
844            .get("io.modelcontextprotocol/serverInfo")
845            .ok_or("the fixture carries no `serverInfo`")?;
846        assert_eq!(info.get("name").and_then(|n| n.as_str()), Some("srv"));
847        assert_eq!(info.get("version").and_then(|v| v.as_str()), Some("1"));
848        assert_eq!(
849            meta.get("unheardOf").and_then(serde_json::Value::as_bool),
850            Some(true)
851        );
852        Ok(())
853    }
854}