Skip to main content

objectiveai_sdk/mcp/
rmcp_bridge.rs

1//! Conversions between our own MCP types and the external [`rmcp`]
2//! crate's model types (rmcp 1.7).
3//!
4//! HARD RULE: our own mcp type is always the middle-man. There is no
5//! direct objectiveai <-> rmcp conversion; every path is
6//! `objectiveai -> mcp -> rmcp` or `rmcp -> mcp -> objectiveai`.
7//!
8//! - **Layer 1** (`From`, both ways): each own mcp type that has a base
9//!   conversion gets a foundation `From` to/from its rmcp counterpart —
10//!   `ContentBlock <-> rmcp Content`, `ImageContent <-> rmcp ImageContent`,
11//!   `AudioContent <-> rmcp AudioContent`, `ResourceContentsUnion <-> rmcp
12//!   ResourceContents`. The forward direction mirrors the (now-superseded)
13//!   `objectiveai-mcp` bridge field-for-field.
14//! - **Layer 2** (delegating): for every existing base<->mcp conversion,
15//!   a base<->rmcp conversion that composes Layer 1 with the base<->mcp
16//!   impl. Each is a one-liner.
17//!
18//! Round-trip losses (inherent to rmcp's model, pre-existing in the old
19//! bridge): rmcp's `RawAudioContent` has no `_meta`, and resource-link
20//! `icons` are dropped (rmcp's `Icon` differs from ours and is outside
21//! the base<->mcp conversion set).
22
23use indexmap::IndexMap;
24use serde_json::Value;
25
26use rmcp::model::{
27    Annotated, AudioContent as RmcpAudioContent, Content as RmcpContent,
28    ImageContent as RmcpImageContent, Meta, RawAudioContent, RawContent, RawEmbeddedResource,
29    RawImageContent, RawResource, RawTextContent, ResourceContents as RmcpResourceContents,
30};
31
32use crate::agent::completions::message::{
33    File, ImageUrl, InputAudio, RichContent, RichContentPart, VideoUrl,
34};
35use crate::mcp::shared::{
36    BlobResourceContents, ResourceContents, ResourceContentsUnion, TextResourceContents,
37};
38use crate::mcp::tool::{
39    AudioContent, ContentBlock, EmbeddedResource, ImageContent, ImageUrlNotDataUrl, ResourceLink,
40    TextContent,
41};
42
43// ── _meta helpers (ordered both ways) ────────────────────────────────────────
44
45/// Our `Option<IndexMap>` → rmcp `Option<Meta>` (a `serde_json::Map`),
46/// preserving insertion order.
47fn sdk_meta_to_rmcp(meta: Option<IndexMap<String, Value>>) -> Option<Meta> {
48    meta.map(|m| {
49        let mut map = serde_json::Map::with_capacity(m.len());
50        for (k, v) in m {
51            map.insert(k, v);
52        }
53        Meta(map)
54    })
55}
56
57/// rmcp `Option<Meta>` → our `Option<IndexMap>` (inverse of
58/// [`sdk_meta_to_rmcp`]).
59fn rmcp_meta_to_sdk(meta: Option<Meta>) -> Option<IndexMap<String, Value>> {
60    meta.map(|Meta(map)| map.into_iter().collect::<IndexMap<String, Value>>())
61}
62
63// ── Layer 1: own mcp <-> rmcp ─────────────────────────────────────────────────
64
65impl From<ContentBlock> for RmcpContent {
66    fn from(block: ContentBlock) -> Self {
67        let raw = match block {
68            ContentBlock::Text(t) => RawContent::Text(RawTextContent {
69                text: t.text,
70                meta: sdk_meta_to_rmcp(t._meta),
71            }),
72            ContentBlock::Image(i) => RawContent::Image(RawImageContent {
73                data: i.data,
74                mime_type: i.mime_type,
75                meta: sdk_meta_to_rmcp(i._meta),
76            }),
77            // rmcp `RawAudioContent` has no `meta`, so `a._meta` is dropped.
78            ContentBlock::Audio(a) => RawContent::Audio(RawAudioContent {
79                data: a.data,
80                mime_type: a.mime_type,
81            }),
82            ContentBlock::EmbeddedResource(er) => RawContent::Resource(RawEmbeddedResource {
83                resource: er.resource.into(),
84                meta: sdk_meta_to_rmcp(er._meta),
85            }),
86            // our `icons` are dropped (rmcp `Icon` differs; out of scope).
87            ContentBlock::ResourceLink(rl) => RawContent::ResourceLink(RawResource {
88                uri: rl.uri,
89                name: rl.name,
90                title: rl.title,
91                description: rl.description,
92                mime_type: rl.mime_type,
93                size: None,
94                icons: None,
95                meta: sdk_meta_to_rmcp(rl._meta),
96            }),
97        };
98        Annotated {
99            raw,
100            annotations: None,
101        }
102    }
103}
104
105impl From<RmcpContent> for ContentBlock {
106    fn from(content: RmcpContent) -> Self {
107        match content.raw {
108            RawContent::Text(t) => ContentBlock::Text(TextContent {
109                text: t.text,
110                annotations: None,
111                _meta: rmcp_meta_to_sdk(t.meta),
112            }),
113            RawContent::Image(i) => ContentBlock::Image(ImageContent {
114                data: i.data,
115                mime_type: i.mime_type,
116                annotations: None,
117                _meta: rmcp_meta_to_sdk(i.meta),
118            }),
119            RawContent::Audio(a) => ContentBlock::Audio(AudioContent {
120                data: a.data,
121                mime_type: a.mime_type,
122                annotations: None,
123                _meta: None,
124            }),
125            RawContent::Resource(er) => ContentBlock::EmbeddedResource(EmbeddedResource {
126                resource: er.resource.into(),
127                annotations: None,
128                _meta: rmcp_meta_to_sdk(er.meta),
129            }),
130            RawContent::ResourceLink(rl) => ContentBlock::ResourceLink(ResourceLink {
131                name: rl.name,
132                uri: rl.uri,
133                title: rl.title,
134                description: rl.description,
135                mime_type: rl.mime_type,
136                icons: None,
137                annotations: None,
138                _meta: rmcp_meta_to_sdk(rl.meta),
139            }),
140        }
141    }
142}
143
144impl From<ImageContent> for RmcpImageContent {
145    fn from(i: ImageContent) -> Self {
146        Annotated {
147            raw: RawImageContent {
148                data: i.data,
149                mime_type: i.mime_type,
150                meta: sdk_meta_to_rmcp(i._meta),
151            },
152            annotations: None,
153        }
154    }
155}
156
157impl From<RmcpImageContent> for ImageContent {
158    fn from(i: RmcpImageContent) -> Self {
159        ImageContent {
160            data: i.raw.data,
161            mime_type: i.raw.mime_type,
162            annotations: None,
163            _meta: rmcp_meta_to_sdk(i.raw.meta),
164        }
165    }
166}
167
168impl From<AudioContent> for RmcpAudioContent {
169    fn from(a: AudioContent) -> Self {
170        // rmcp `RawAudioContent` has no `meta`; `a._meta` is dropped.
171        Annotated {
172            raw: RawAudioContent {
173                data: a.data,
174                mime_type: a.mime_type,
175            },
176            annotations: None,
177        }
178    }
179}
180
181impl From<RmcpAudioContent> for AudioContent {
182    fn from(a: RmcpAudioContent) -> Self {
183        AudioContent {
184            data: a.raw.data,
185            mime_type: a.raw.mime_type,
186            annotations: None,
187            _meta: None,
188        }
189    }
190}
191
192impl From<ResourceContentsUnion> for RmcpResourceContents {
193    fn from(rcu: ResourceContentsUnion) -> Self {
194        match rcu {
195            ResourceContentsUnion::Text(t) => RmcpResourceContents::TextResourceContents {
196                uri: t.base.uri,
197                mime_type: t.base.mime_type,
198                text: t.text,
199                meta: sdk_meta_to_rmcp(t.base._meta),
200            },
201            ResourceContentsUnion::Blob(b) => RmcpResourceContents::BlobResourceContents {
202                uri: b.base.uri,
203                mime_type: b.base.mime_type,
204                blob: b.blob,
205                meta: sdk_meta_to_rmcp(b.base._meta),
206            },
207        }
208    }
209}
210
211impl From<RmcpResourceContents> for ResourceContentsUnion {
212    fn from(rc: RmcpResourceContents) -> Self {
213        match rc {
214            RmcpResourceContents::TextResourceContents {
215                uri,
216                mime_type,
217                text,
218                meta,
219            } => ResourceContentsUnion::Text(TextResourceContents {
220                base: ResourceContents {
221                    uri,
222                    mime_type,
223                    _meta: rmcp_meta_to_sdk(meta),
224                },
225                text,
226            }),
227            RmcpResourceContents::BlobResourceContents {
228                uri,
229                mime_type,
230                blob,
231                meta,
232            } => ResourceContentsUnion::Blob(BlobResourceContents {
233                base: ResourceContents {
234                    uri,
235                    mime_type,
236                    _meta: rmcp_meta_to_sdk(meta),
237                },
238                blob,
239            }),
240        }
241    }
242}
243
244// ── Layer 2: base <-> rmcp (delegating; one per master-list row) ──────────────
245
246// A1: ImageUrl -> ImageContent (fallible) -> rmcp ImageContent
247impl TryFrom<ImageUrl> for RmcpImageContent {
248    type Error = ImageUrlNotDataUrl;
249    fn try_from(v: ImageUrl) -> Result<Self, Self::Error> {
250        Ok(ImageContent::try_from(v)?.into())
251    }
252}
253
254// A2: InputAudio -> AudioContent -> rmcp AudioContent
255impl From<InputAudio> for RmcpAudioContent {
256    fn from(v: InputAudio) -> Self {
257        AudioContent::from(v).into()
258    }
259}
260
261// A3: RichContentPart -> ContentBlock -> rmcp Content
262impl From<RichContentPart> for RmcpContent {
263    fn from(v: RichContentPart) -> Self {
264        ContentBlock::from(v).into()
265    }
266}
267
268// A4: ImageUrl -> ContentBlock -> rmcp Content
269impl From<ImageUrl> for RmcpContent {
270    fn from(v: ImageUrl) -> Self {
271        ContentBlock::from(v).into()
272    }
273}
274
275// A5: InputAudio -> ContentBlock -> rmcp Content
276impl From<InputAudio> for RmcpContent {
277    fn from(v: InputAudio) -> Self {
278        ContentBlock::from(v).into()
279    }
280}
281
282// A6: VideoUrl -> ContentBlock -> rmcp Content
283impl From<VideoUrl> for RmcpContent {
284    fn from(v: VideoUrl) -> Self {
285        ContentBlock::from(v).into()
286    }
287}
288
289// A7: File -> ContentBlock -> rmcp Content
290impl From<File> for RmcpContent {
291    fn from(v: File) -> Self {
292        ContentBlock::from(v).into()
293    }
294}
295
296// A8: RichContent -> Vec<ContentBlock> -> Vec<rmcp Content>
297impl From<RichContent> for Vec<RmcpContent> {
298    fn from(v: RichContent) -> Self {
299        Vec::<ContentBlock>::from(v)
300            .into_iter()
301            .map(RmcpContent::from)
302            .collect()
303    }
304}
305
306// B1: rmcp ResourceContents -> ResourceContentsUnion -> RichContentPart
307impl From<RmcpResourceContents> for RichContentPart {
308    fn from(v: RmcpResourceContents) -> Self {
309        ResourceContentsUnion::from(v).into()
310    }
311}
312
313// B2: rmcp Content -> ContentBlock -> RichContentPart
314impl From<RmcpContent> for RichContentPart {
315    fn from(v: RmcpContent) -> Self {
316        ContentBlock::from(v).into()
317    }
318}
319
320// B3: Vec<rmcp Content> -> Vec<ContentBlock> -> RichContent
321impl From<Vec<RmcpContent>> for RichContent {
322    fn from(v: Vec<RmcpContent>) -> Self {
323        v.into_iter()
324            .map(ContentBlock::from)
325            .collect::<Vec<ContentBlock>>()
326            .into()
327    }
328}
329
330// B4: rmcp ImageContent -> ImageContent -> ImageUrl
331impl From<RmcpImageContent> for ImageUrl {
332    fn from(v: RmcpImageContent) -> Self {
333        ImageContent::from(v).into()
334    }
335}
336
337// B5: rmcp AudioContent -> AudioContent -> InputAudio
338impl From<RmcpAudioContent> for InputAudio {
339    fn from(v: RmcpAudioContent) -> Self {
340        AudioContent::from(v).into()
341    }
342}
343
344// ── tests (authored; not run per the no-build directive) ─────────────────────
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    fn meta() -> Option<IndexMap<String, Value>> {
351        let mut m = IndexMap::new();
352        m.insert("k".to_string(), Value::String("v".to_string()));
353        Some(m)
354    }
355
356    #[test]
357    fn content_block_text_roundtrips_through_rmcp() {
358        let cb = ContentBlock::Text(TextContent {
359            text: "hi".to_string(),
360            annotations: None,
361            _meta: meta(),
362        });
363        let back: ContentBlock = RmcpContent::from(cb).into();
364        match back {
365            ContentBlock::Text(t) => {
366                assert_eq!(t.text, "hi");
367                assert_eq!(t._meta, meta());
368            }
369            other => panic!("expected Text, got {other:?}"),
370        }
371    }
372
373    #[test]
374    fn image_content_roundtrips_through_rmcp() {
375        let ic = ImageContent {
376            data: "AAAA".to_string(),
377            mime_type: "image/png".to_string(),
378            annotations: None,
379            _meta: meta(),
380        };
381        let back: ImageContent = RmcpImageContent::from(ic).into();
382        assert_eq!(back.data, "AAAA");
383        assert_eq!(back.mime_type, "image/png");
384        assert_eq!(back._meta, meta());
385    }
386
387    #[test]
388    fn audio_content_roundtrips_through_rmcp_meta_lost() {
389        let ac = AudioContent {
390            data: "BBBB".to_string(),
391            mime_type: "audio/mpeg".to_string(),
392            annotations: None,
393            _meta: meta(),
394        };
395        let back: AudioContent = RmcpAudioContent::from(ac).into();
396        assert_eq!(back.data, "BBBB");
397        assert_eq!(back.mime_type, "audio/mpeg");
398        // rmcp RawAudioContent carries no _meta — documented loss.
399        assert_eq!(back._meta, None);
400    }
401
402    #[test]
403    fn resource_contents_text_roundtrips_through_rmcp() {
404        let rcu = ResourceContentsUnion::Text(TextResourceContents {
405            base: ResourceContents {
406                uri: "str:///x".to_string(),
407                mime_type: Some("text/plain".to_string()),
408                _meta: meta(),
409            },
410            text: "body".to_string(),
411        });
412        let back: ResourceContentsUnion = RmcpResourceContents::from(rcu).into();
413        match back {
414            ResourceContentsUnion::Text(t) => {
415                assert_eq!(t.base.uri, "str:///x");
416                assert_eq!(t.base.mime_type.as_deref(), Some("text/plain"));
417                assert_eq!(t.text, "body");
418                assert_eq!(t.base._meta, meta());
419            }
420            other => panic!("expected Text, got {other:?}"),
421        }
422    }
423}