Skip to main content

objectiveai_sdk/agent/
client_objectiveai_mcp.rs

1//! Client-side ObjectiveAI MCP surface declared by an agent.
2//!
3//! Sits alongside [`super::McpServers`] on every upstream
4//! `AgentBase`. Where `mcp_servers` lists full-URL MCP servers the
5//! agent will dial out to, this struct declares the
6//! ObjectiveAI-managed bits (the built-in `objectiveai-mcp` plus
7//! specific plugins / tools by `owner`+`name`+`version`) the agent
8//! expects the *calling client* to expose locally back to the API.
9//!
10//! Content-addressed: the field flows into each upstream's `id()`
11//! hash, so swapping in a different plugin reference produces a
12//! different agent id.
13
14use indexmap::IndexMap;
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17
18/// A single `owner` / `name` / `version` reference identifying one
19/// tool inside [`ClientObjectiveaiMcp::tools`]. Plugin references
20/// use the larger [`ClientObjectiveaiMcpPluginEntry`] — they carry
21/// extra `executable` / `mcp_servers` fields tools don't have.
22#[derive(
23    Debug,
24    Clone,
25    Serialize,
26    Deserialize,
27    PartialEq,
28    Eq,
29    PartialOrd,
30    Ord,
31    JsonSchema,
32    arbitrary::Arbitrary,
33)]
34#[schemars(rename = "agent.ClientObjectiveaiMcpEntry")]
35pub struct ClientObjectiveaiMcpEntry {
36    pub owner: String,
37    pub name: String,
38    pub version: String,
39}
40
41impl ClientObjectiveaiMcpEntry {
42    /// `owner`, `name`, and `version` must all be non-empty.
43    pub fn validate(&self) -> Result<(), String> {
44        if self.owner.is_empty() {
45            return Err("`owner` cannot be empty".into());
46        }
47        if self.name.is_empty() {
48            return Err("`name` cannot be empty".into());
49        }
50        if self.version.is_empty() {
51            return Err("`version` cannot be empty".into());
52        }
53        Ok(())
54    }
55
56    /// LLM-visible tool name. See [`materialize_tool_name`].
57    pub fn tool_name(&self) -> String {
58        materialize_tool_name(&self.owner, &self.name, &self.version)
59    }
60}
61
62/// Plugin reference inside [`ClientObjectiveaiMcp::plugins`].
63///
64/// - `owner` / `name` / `version` identify the plugin (same shape as
65///   [`ClientObjectiveaiMcpEntry`]).
66/// - `executable` controls whether this plugin contributes a tool
67///   to the agent's surface (`true`, default) or is loaded purely
68///   for its declared MCP servers (`false`). The API's
69///   `X-OBJECTIVEAI-TOOLS-ALLOWED` construction honors this: only
70///   `executable = true` plugin entries contribute their `name` to
71///   the allow-list.
72/// - `mcp_servers` selects which of the plugin's manifest-declared
73///   `filesystem::plugins::Manifest::mcp_servers` entries should be
74///   exposed to the agent, by `name`. `None` ⇒ none of them.
75#[derive(
76    Debug,
77    Clone,
78    Serialize,
79    Deserialize,
80    PartialEq,
81    Eq,
82    PartialOrd,
83    Ord,
84    JsonSchema,
85    arbitrary::Arbitrary,
86)]
87#[schemars(rename = "agent.ClientObjectiveaiMcpPluginEntry")]
88pub struct ClientObjectiveaiMcpPluginEntry {
89    pub owner: String,
90    pub name: String,
91    pub version: String,
92    /// `true`: spawn the plugin binary and surface its tools the
93    /// usual way. `false`: don't run the plugin; only consume its
94    /// declared MCP servers (`mcp_servers` below). Defaults to
95    /// `true` so existing declarations keep their current behavior.
96    #[serde(default = "default_true")]
97    pub executable: bool,
98    /// Subset of the plugin's manifest `mcp_servers` to expose, each
99    /// referenced by `name` plus an optional `arguments` map. `None`
100    /// ⇒ none. Names that aren't present in the plugin's manifest are
101    /// rejected when the API asks the CLI to begin them; declarations
102    /// themselves don't validate the referent (the plugin may not be
103    /// installed at declaration time).
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    #[schemars(extend("omitempty" = true))]
106    pub mcp_servers: Option<Vec<ClientObjectiveaiMcpPluginMcpServer>>,
107}
108
109/// One `mcp_servers` entry on a
110/// [`ClientObjectiveaiMcpPluginEntry`]: the manifest-declared `name`
111/// the agent wants exposed, plus optional `arguments` the CLI feeds
112/// to the plugin alongside the name when bringing the server up. The
113/// arguments map is sorted by key in [`prepare`] so two equivalent
114/// declarations (same key/value pairs in any order) hash to the same
115/// canonical form.
116#[derive(
117    Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, arbitrary::Arbitrary,
118)]
119#[schemars(rename = "agent.ClientObjectiveaiMcpPluginMcpServer")]
120pub struct ClientObjectiveaiMcpPluginMcpServer {
121    /// Author-chosen identifier — must match a `name` in the plugin
122    /// manifest's `mcp_servers` list. The CLI feeds this as the
123    /// first positional arg when starting the plugin.
124    pub name: String,
125    /// Optional key→value arguments forwarded to the plugin alongside
126    /// `name`. `Some(value)` ⇒ `--key value` on the spawned plugin's
127    /// argv; `None` ⇒ a bare `--key` flag with no following token. The
128    /// plugin author decides how to interpret them. [`prepare`]
129    /// normalizes (`Some("") → None`), sorts the map by key, and
130    /// collapses an empty map to `None` so two equivalent declarations
131    /// canonicalize to byte-identical JSON.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    #[schemars(extend("omitempty" = true))]
134    #[arbitrary(with = crate::arbitrary_util::arbitrary_option_indexmap_string_option_string)]
135    pub arguments: Option<IndexMap<String, Option<String>>>,
136}
137
138impl PartialOrd for ClientObjectiveaiMcpPluginMcpServer {
139    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
140        Some(self.cmp(other))
141    }
142}
143
144impl Ord for ClientObjectiveaiMcpPluginMcpServer {
145    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
146        // Compare by name first. `IndexMap` doesn't derive `Ord`, so
147        // we walk the entries in iteration order — after `prepare`'s
148        // `sort_keys` pass that order is deterministic. `None`
149        // arguments sorts before `Some(...)` via the standard
150        // `Option<T>::cmp` ordering (so a bare `--flag` sorts before
151        // `--flag value`).
152        let by_name = self.name.cmp(&other.name);
153        if by_name.is_ne() {
154            return by_name;
155        }
156        let a: Option<Vec<(&String, &Option<String>)>> =
157            self.arguments.as_ref().map(|m| m.iter().collect());
158        let b: Option<Vec<(&String, &Option<String>)>> =
159            other.arguments.as_ref().map(|m| m.iter().collect());
160        a.cmp(&b)
161    }
162}
163
164impl ClientObjectiveaiMcpPluginMcpServer {
165    /// `name` must be non-empty, and every `arguments` key (if
166    /// present) must be non-empty (values may be empty).
167    pub fn validate(&self) -> Result<(), String> {
168        if self.name.is_empty() {
169            return Err("`name` cannot be empty".into());
170        }
171        if let Some(args) = self.arguments.as_ref() {
172            for (k, _) in args {
173                if k.is_empty() {
174                    return Err("`arguments` key cannot be empty".into());
175                }
176            }
177        }
178        Ok(())
179    }
180}
181
182fn default_true() -> bool {
183    true
184}
185
186impl ClientObjectiveaiMcpPluginEntry {
187    /// `owner`, `name`, and `version` must all be non-empty; each
188    /// `mcp_servers[i]` must validate (see
189    /// [`ClientObjectiveaiMcpPluginMcpServer::validate`]); and within
190    /// one plugin entry, `mcp_servers` must contain no duplicate
191    /// `name` values (matches the uniqueness rule the plugin manifest
192    /// itself enforces at
193    /// `crate::filesystem::plugins::Manifest::validate`).
194    pub fn validate(&self) -> Result<(), String> {
195        if self.owner.is_empty() {
196            return Err("`owner` cannot be empty".into());
197        }
198        if self.name.is_empty() {
199            return Err("`name` cannot be empty".into());
200        }
201        if self.version.is_empty() {
202            return Err("`version` cannot be empty".into());
203        }
204        if let Some(servers) = self.mcp_servers.as_ref() {
205            for entry in servers {
206                entry.validate()?;
207            }
208            for (i, a) in servers.iter().enumerate() {
209                for b in &servers[i + 1..] {
210                    if a.name == b.name {
211                        return Err(format!(
212                            "`mcp_servers` contains duplicate name: \"{}\"",
213                            a.name
214                        ));
215                    }
216                }
217            }
218        }
219        Ok(())
220    }
221
222    /// LLM-visible tool name. See [`materialize_tool_name`].
223    pub fn tool_name(&self) -> String {
224        materialize_tool_name(&self.owner, &self.name, &self.version)
225    }
226}
227
228/// Materialize the LLM-visible tool name for an `owner` / `name` /
229/// `version` triple: `{owner}-{name}-{version}` with every `.`
230/// substituted to `-`. The substitution keeps the result
231/// Anthropic-tool-name-regex safe (`^[a-zA-Z0-9_-]{1,128}$`) even
232/// when the version field carries semver dots (`1.2.3` -> `1-2-3`).
233///
234/// Single source of truth shared by [`ClientObjectiveaiMcpEntry::tool_name`],
235/// [`crate::filesystem::plugins::Manifest::tool_name`], and
236/// [`crate::filesystem::tools::Manifest::tool_name`].
237pub fn materialize_tool_name(owner: &str, name: &str, version: &str) -> String {
238    format!("{owner}-{name}-{version}").replace('.', "-")
239}
240
241/// Client-side MCP surface the agent expects:
242///
243/// - `objectiveai`: whether the calling client exposes the built-in
244///   `objectiveai-mcp`. `None` means unspecified; `Some(true)` /
245///   `Some(false)` explicitly opt in / out.
246/// - `plugins`: specific plugins (by `owner` / `name` / `version`)
247///   plus per-plugin `executable` + `mcp_servers`.
248/// - `tools`: specific tools (by `owner` / `name` / `version`).
249#[derive(
250    Debug,
251    Clone,
252    Serialize,
253    Deserialize,
254    PartialEq,
255    Eq,
256    JsonSchema,
257    arbitrary::Arbitrary,
258    Default,
259)]
260#[schemars(rename = "agent.ClientObjectiveaiMcp")]
261pub struct ClientObjectiveaiMcp {
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    #[schemars(extend("omitempty" = true))]
264    pub objectiveai: Option<bool>,
265
266    #[serde(default, skip_serializing_if = "Vec::is_empty")]
267    #[schemars(extend("omitempty" = true))]
268    pub plugins: Vec<ClientObjectiveaiMcpPluginEntry>,
269
270    #[serde(default, skip_serializing_if = "Vec::is_empty")]
271    #[schemars(extend("omitempty" = true))]
272    pub tools: Vec<ClientObjectiveaiMcpEntry>,
273}
274
275impl ClientObjectiveaiMcp {
276    /// Snapshot of the three `X-OBJECTIVEAI-MCP-*` request headers a
277    /// caller stamps on the initial dial of the objectiveai-mcp
278    /// proxy upstream. Per-field rules:
279    ///
280    /// - `root`: `self.objectiveai.unwrap_or(false)`. `None`
281    ///   ("unspecified") is conservatively treated as "do not expose".
282    /// - `tools`: copy of `self.tools`, sorted by
283    ///   `(owner, name, version)` (the derived
284    ///   [`ClientObjectiveaiMcpEntry`] `Ord`).
285    /// - `plugins`: `self.plugins` filtered to `executable: true`,
286    ///   projected onto `(owner, name, version)` (dropping
287    ///   `executable` and `mcp_servers`), then sorted the same way.
288    pub fn mcp_headers(&self) -> ClientObjectiveaiMcpHeaders {
289        let mut tools = self.tools.clone();
290        tools.sort();
291
292        let mut plugins: Vec<ClientObjectiveaiMcpEntry> = self
293            .plugins
294            .iter()
295            .filter(|p| p.executable)
296            .map(|p| ClientObjectiveaiMcpEntry {
297                owner: p.owner.clone(),
298                name: p.name.clone(),
299                version: p.version.clone(),
300            })
301            .collect();
302        plugins.sort();
303
304        ClientObjectiveaiMcpHeaders {
305            root: self.objectiveai.unwrap_or(false),
306            tools,
307            plugins,
308        }
309    }
310}
311
312/// Snapshot of the three transient `X-OBJECTIVEAI-MCP-*` request
313/// headers an HTTP caller stamps on its initial dial of the
314/// objectiveai-mcp proxy upstream.
315///
316/// Produced by [`ClientObjectiveaiMcp::mcp_headers`] — see that
317/// method's doc for the per-field source rules and sort order.
318#[derive(
319    Debug,
320    Clone,
321    Serialize,
322    Deserialize,
323    PartialEq,
324    Eq,
325    JsonSchema,
326)]
327#[schemars(rename = "agent.ClientObjectiveaiMcpHeaders")]
328pub struct ClientObjectiveaiMcpHeaders {
329    /// Becomes the `X-OBJECTIVEAI-MCP-ROOT` header value verbatim
330    /// (`"true"` / `"false"`).
331    pub root: bool,
332    /// Tools the agent expects the client to expose, sorted by
333    /// `(owner, name, version)`. Becomes the
334    /// `X-OBJECTIVEAI-MCP-TOOLS` header's JSON payload.
335    pub tools: Vec<ClientObjectiveaiMcpEntry>,
336    /// Plugins the agent expects the client to expose, filtered to
337    /// `executable: true` entries only, projected onto the
338    /// `(owner, name, version)` shape, and sorted by
339    /// `(owner, name, version)`. Becomes the
340    /// `X-OBJECTIVEAI-MCP-PLUGINS` header's JSON payload.
341    pub plugins: Vec<ClientObjectiveaiMcpEntry>,
342}
343
344impl ClientObjectiveaiMcpHeaders {
345    /// Project the three fields onto the canonical
346    /// `(header-name, header-value)` pairs an HTTP caller can stamp
347    /// directly. Headers are emitted in the order
348    /// `ROOT → TOOLS → PLUGINS`; callers that need a map can collect
349    /// the returned vec themselves.
350    pub fn to_headers(&self) -> Vec<(String, String)> {
351        vec![
352            (
353                "X-OBJECTIVEAI-MCP-ROOT".to_string(),
354                if self.root { "true" } else { "false" }.to_string(),
355            ),
356            (
357                "X-OBJECTIVEAI-MCP-TOOLS".to_string(),
358                serde_json::to_string(&self.tools)
359                    .expect("ClientObjectiveaiMcpEntry always serializes"),
360            ),
361            (
362                "X-OBJECTIVEAI-MCP-PLUGINS".to_string(),
363                serde_json::to_string(&self.plugins)
364                    .expect("ClientObjectiveaiMcpEntry always serializes"),
365            ),
366        ]
367    }
368}
369
370/// Validates the configuration. Each entry's fields must be
371/// non-empty, and the `plugins` / `tools` lists each contain no
372/// `(owner, name, version)` duplicates. Free-function counterpart to
373/// [`super::mcp_servers::validate`].
374pub fn validate(this: &ClientObjectiveaiMcp) -> Result<(), String> {
375    for entry in &this.plugins {
376        entry.validate()?;
377    }
378    for entry in &this.tools {
379        entry.validate()?;
380    }
381    for (i, a) in this.plugins.iter().enumerate() {
382        for b in &this.plugins[i + 1..] {
383            if a.owner == b.owner && a.name == b.name && a.version == b.version
384            {
385                return Err(format!(
386                    "`client_objectiveai_mcp.plugins` contains duplicate entry: \"{}/{}@{}\"",
387                    a.owner, a.name, a.version,
388                ));
389            }
390        }
391    }
392    for (i, a) in this.tools.iter().enumerate() {
393        for b in &this.tools[i + 1..] {
394            if a == b {
395                return Err(format!(
396                    "`client_objectiveai_mcp.tools` contains duplicate entry: \"{}/{}@{}\"",
397                    a.owner, a.name, a.version,
398                ));
399            }
400        }
401    }
402    Ok(())
403}
404
405/// Sorts plugins + tools for deterministic ordering. Per-plugin
406/// `mcp_servers` get their inner `arguments` IndexMap key-sorted
407/// in place, then the `mcp_servers` Vec is sorted via the
408/// [`ClientObjectiveaiMcpPluginMcpServer`] `Ord` impl. Collapses an
409/// all-empty struct to `None` so the enclosing `Option` can drop the
410/// empty container entirely (same convention as
411/// [`super::mcp_servers::prepare`]).
412pub fn prepare(mut this: ClientObjectiveaiMcp) -> Option<ClientObjectiveaiMcp> {
413    for plugin in &mut this.plugins {
414        if let Some(servers) = plugin.mcp_servers.as_mut() {
415            for entry in servers.iter_mut() {
416                // Normalize each value (`Some("") → None` so an
417                // explicit empty string canonicalizes the same way
418                // as a missing value — i.e., a bare `--flag`), sort
419                // by key, then collapse an empty map to `None` so
420                // the canonical form omits the field entirely.
421                let drop_empty = match entry.arguments.as_mut() {
422                    Some(args) => {
423                        for (_, v) in args.iter_mut() {
424                            if let Some(s) = v.as_deref() {
425                                if s.is_empty() {
426                                    *v = None;
427                                }
428                            }
429                        }
430                        args.sort_keys();
431                        args.is_empty()
432                    }
433                    None => false,
434                };
435                if drop_empty {
436                    entry.arguments = None;
437                }
438            }
439            servers.sort();
440        }
441    }
442    this.plugins.sort();
443    this.tools.sort();
444    if this.objectiveai.is_none() && this.plugins.is_empty() && this.tools.is_empty() {
445        None
446    } else {
447        Some(this)
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    fn entry(name: &str, args: &[(&str, Option<&str>)]) -> ClientObjectiveaiMcpPluginMcpServer {
456        let arguments = if args.is_empty() {
457            None
458        } else {
459            let mut m = IndexMap::new();
460            for (k, v) in args {
461                m.insert(k.to_string(), v.map(|s| s.to_string()));
462            }
463            Some(m)
464        };
465        ClientObjectiveaiMcpPluginMcpServer {
466            name: name.to_string(),
467            arguments,
468        }
469    }
470
471    fn plugin(name: &str, servers: Vec<ClientObjectiveaiMcpPluginMcpServer>) -> ClientObjectiveaiMcpPluginEntry {
472        ClientObjectiveaiMcpPluginEntry {
473            owner: "o".into(),
474            name: name.into(),
475            version: "v".into(),
476            executable: true,
477            mcp_servers: Some(servers),
478        }
479    }
480
481    fn shell(plugins: Vec<ClientObjectiveaiMcpPluginEntry>) -> ClientObjectiveaiMcp {
482        ClientObjectiveaiMcp {
483            objectiveai: None,
484            plugins,
485            tools: vec![],
486        }
487    }
488
489    #[test]
490    fn prepare_sorts_arguments_by_key_so_order_does_not_matter() {
491        let a = shell(vec![plugin(
492            "p",
493            vec![entry("s", &[("b", Some("1")), ("a", Some("2"))])],
494        )]);
495        let b = shell(vec![plugin(
496            "p",
497            vec![entry("s", &[("a", Some("2")), ("b", Some("1"))])],
498        )]);
499        let ap = prepare(a).expect("non-empty after prepare");
500        let bp = prepare(b).expect("non-empty after prepare");
501        assert_eq!(
502            serde_json::to_string(&ap).unwrap(),
503            serde_json::to_string(&bp).unwrap(),
504            "two declarations with identical key/value pairs in different insertion order must canonicalize to byte-identical JSON",
505        );
506    }
507
508    #[test]
509    fn prepare_sorts_mcp_servers_vec_by_name_then_arguments() {
510        let a = shell(vec![plugin(
511            "p",
512            vec![
513                entry("z", &[("a", Some("1"))]),
514                entry("a", &[("k", Some("v"))]),
515            ],
516        )]);
517        let ap = prepare(a).expect("non-empty after prepare");
518        let servers = ap.plugins[0].mcp_servers.as_ref().unwrap();
519        assert_eq!(servers[0].name, "a");
520        assert_eq!(servers[1].name, "z");
521    }
522
523    #[test]
524    fn validate_rejects_duplicate_mcp_server_names_within_plugin() {
525        let bad = shell(vec![plugin(
526            "p",
527            vec![entry("dup", &[]), entry("dup", &[("k", Some("v"))])],
528        )]);
529        let err = validate(&bad).expect_err("duplicate names must be rejected");
530        assert!(err.contains("duplicate name"), "unexpected error: {err}");
531    }
532
533    #[test]
534    fn validate_rejects_empty_argument_key() {
535        let bad = shell(vec![plugin("p", vec![entry("s", &[("", Some("v"))])])]);
536        let err = validate(&bad).expect_err("empty argument keys must be rejected");
537        assert!(err.contains("`arguments` key"), "unexpected error: {err}");
538    }
539
540    #[test]
541    fn empty_arguments_round_trip_omits_field() {
542        let s = entry("name", &[]);
543        let json = serde_json::to_string(&s).unwrap();
544        assert!(
545            !json.contains("arguments"),
546            "absent arguments must be skipped on serialize: {json}"
547        );
548        let back: ClientObjectiveaiMcpPluginMcpServer = serde_json::from_str(&json).unwrap();
549        assert_eq!(back, s);
550    }
551
552    #[test]
553    fn populated_arguments_round_trip() {
554        // Mix of valued and flag-only args.
555        let s = entry("name", &[("a", Some("1")), ("debug", None), ("b", Some("2"))]);
556        let json = serde_json::to_string(&s).unwrap();
557        let back: ClientObjectiveaiMcpPluginMcpServer = serde_json::from_str(&json).unwrap();
558        assert_eq!(back, s);
559    }
560
561    #[test]
562    fn prepare_normalizes_empty_string_value_to_none() {
563        // Caller wrote `--debug ""` — prepare must canonicalize that
564        // to a bare `--debug` flag (None) so identical declarations
565        // hash byte-identically regardless of whether the author
566        // wrote `None` or `Some("")`.
567        let with_empty = shell(vec![plugin("p", vec![entry("s", &[("debug", Some(""))])])]);
568        let prepared = prepare(with_empty).expect("non-empty after prepare");
569        let args = prepared.plugins[0].mcp_servers.as_ref().unwrap()[0]
570            .arguments
571            .as_ref()
572            .unwrap();
573        assert_eq!(
574            args.get("debug").unwrap(),
575            &None,
576            "Some(\"\") must canonicalize to None"
577        );
578
579        // And the canonical JSON for `Some("")` must equal the one
580        // for `None`.
581        let with_none = shell(vec![plugin("p", vec![entry("s", &[("debug", None)])])]);
582        let prepared_none = prepare(with_none).expect("non-empty after prepare");
583        assert_eq!(
584            serde_json::to_string(&prepared).unwrap(),
585            serde_json::to_string(&prepared_none).unwrap(),
586        );
587    }
588
589    #[test]
590    fn prepare_collapses_empty_arguments_to_none() {
591        // Caller explicitly supplied `Some(empty map)` — prepare
592        // must canonicalize that to `None` so it serializes
593        // identically to the absent case.
594        let with_empty = ClientObjectiveaiMcp {
595            objectiveai: None,
596            plugins: vec![ClientObjectiveaiMcpPluginEntry {
597                owner: "o".into(),
598                name: "p".into(),
599                version: "v".into(),
600                executable: true,
601                mcp_servers: Some(vec![ClientObjectiveaiMcpPluginMcpServer {
602                    name: "s".into(),
603                    arguments: Some(IndexMap::new()),
604                }]),
605            }],
606            tools: vec![],
607        };
608        let prepared = prepare(with_empty).expect("non-empty after prepare");
609        let arg = &prepared.plugins[0].mcp_servers.as_ref().unwrap()[0].arguments;
610        assert!(arg.is_none(), "empty arguments map must canonicalize to None");
611    }
612
613    // -------------------------------------------------------------
614    // mcp_headers / to_headers
615    // -------------------------------------------------------------
616
617    fn entry_triple(owner: &str, name: &str, version: &str) -> ClientObjectiveaiMcpEntry {
618        ClientObjectiveaiMcpEntry {
619            owner: owner.into(),
620            name: name.into(),
621            version: version.into(),
622        }
623    }
624
625    fn plugin_triple(
626        owner: &str,
627        name: &str,
628        version: &str,
629        executable: bool,
630    ) -> ClientObjectiveaiMcpPluginEntry {
631        ClientObjectiveaiMcpPluginEntry {
632            owner: owner.into(),
633            name: name.into(),
634            version: version.into(),
635            executable,
636            mcp_servers: None,
637        }
638    }
639
640    #[test]
641    fn mcp_headers_root_unwraps_unspecified_to_false() {
642        let m = ClientObjectiveaiMcp {
643            objectiveai: None,
644            plugins: vec![],
645            tools: vec![],
646        };
647        assert!(!m.mcp_headers().root);
648    }
649
650    #[test]
651    fn mcp_headers_root_unwraps_explicit_true() {
652        let m = ClientObjectiveaiMcp {
653            objectiveai: Some(true),
654            plugins: vec![],
655            tools: vec![],
656        };
657        assert!(m.mcp_headers().root);
658    }
659
660    #[test]
661    fn mcp_headers_plugins_drop_non_executable() {
662        let m = ClientObjectiveaiMcp {
663            objectiveai: None,
664            plugins: vec![
665                plugin_triple("o", "yes", "v", true),
666                plugin_triple("o", "no", "v", false),
667            ],
668            tools: vec![],
669        };
670        let h = m.mcp_headers();
671        assert_eq!(h.plugins, vec![entry_triple("o", "yes", "v")]);
672    }
673
674    #[test]
675    fn mcp_headers_sorts_owner_then_name_then_version() {
676        let m = ClientObjectiveaiMcp {
677            objectiveai: None,
678            plugins: vec![
679                plugin_triple("b", "x", "1", true),
680                plugin_triple("a", "y", "2", true),
681                plugin_triple("a", "x", "2", true),
682                plugin_triple("a", "x", "1", true),
683            ],
684            tools: vec![
685                entry_triple("b", "x", "1"),
686                entry_triple("a", "y", "2"),
687                entry_triple("a", "x", "2"),
688                entry_triple("a", "x", "1"),
689            ],
690        };
691        let h = m.mcp_headers();
692        assert_eq!(
693            h.tools,
694            vec![
695                entry_triple("a", "x", "1"),
696                entry_triple("a", "x", "2"),
697                entry_triple("a", "y", "2"),
698                entry_triple("b", "x", "1"),
699            ],
700        );
701        assert_eq!(
702            h.plugins,
703            vec![
704                entry_triple("a", "x", "1"),
705                entry_triple("a", "x", "2"),
706                entry_triple("a", "y", "2"),
707                entry_triple("b", "x", "1"),
708            ],
709        );
710    }
711
712    #[test]
713    fn to_headers_emits_canonical_triple() {
714        let h = ClientObjectiveaiMcpHeaders {
715            root: true,
716            tools: vec![entry_triple("a", "b", "c")],
717            plugins: vec![],
718        };
719        assert_eq!(
720            h.to_headers(),
721            vec![
722                ("X-OBJECTIVEAI-MCP-ROOT".to_string(), "true".to_string()),
723                (
724                    "X-OBJECTIVEAI-MCP-TOOLS".to_string(),
725                    r#"[{"owner":"a","name":"b","version":"c"}]"#.to_string(),
726                ),
727                (
728                    "X-OBJECTIVEAI-MCP-PLUGINS".to_string(),
729                    "[]".to_string(),
730                ),
731            ],
732        );
733    }
734}