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