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
275/// Validates the configuration. Each entry's fields must be
276/// non-empty, and the `plugins` / `tools` lists each contain no
277/// `(owner, name, version)` duplicates. Free-function counterpart to
278/// [`super::mcp_servers::validate`].
279pub fn validate(this: &ClientObjectiveaiMcp) -> Result<(), String> {
280    for entry in &this.plugins {
281        entry.validate()?;
282    }
283    for entry in &this.tools {
284        entry.validate()?;
285    }
286    for (i, a) in this.plugins.iter().enumerate() {
287        for b in &this.plugins[i + 1..] {
288            if a.owner == b.owner && a.name == b.name && a.version == b.version
289            {
290                return Err(format!(
291                    "`client_objectiveai_mcp.plugins` contains duplicate entry: \"{}/{}@{}\"",
292                    a.owner, a.name, a.version,
293                ));
294            }
295        }
296    }
297    for (i, a) in this.tools.iter().enumerate() {
298        for b in &this.tools[i + 1..] {
299            if a == b {
300                return Err(format!(
301                    "`client_objectiveai_mcp.tools` contains duplicate entry: \"{}/{}@{}\"",
302                    a.owner, a.name, a.version,
303                ));
304            }
305        }
306    }
307    Ok(())
308}
309
310/// Sorts plugins + tools for deterministic ordering. Per-plugin
311/// `mcp_servers` get their inner `arguments` IndexMap key-sorted
312/// in place, then the `mcp_servers` Vec is sorted via the
313/// [`ClientObjectiveaiMcpPluginMcpServer`] `Ord` impl. Collapses an
314/// all-empty struct to `None` so the enclosing `Option` can drop the
315/// empty container entirely (same convention as
316/// [`super::mcp_servers::prepare`]).
317pub fn prepare(mut this: ClientObjectiveaiMcp) -> Option<ClientObjectiveaiMcp> {
318    for plugin in &mut this.plugins {
319        if let Some(servers) = plugin.mcp_servers.as_mut() {
320            for entry in servers.iter_mut() {
321                // Normalize each value (`Some("") → None` so an
322                // explicit empty string canonicalizes the same way
323                // as a missing value — i.e., a bare `--flag`), sort
324                // by key, then collapse an empty map to `None` so
325                // the canonical form omits the field entirely.
326                let drop_empty = match entry.arguments.as_mut() {
327                    Some(args) => {
328                        for (_, v) in args.iter_mut() {
329                            if let Some(s) = v.as_deref() {
330                                if s.is_empty() {
331                                    *v = None;
332                                }
333                            }
334                        }
335                        args.sort_keys();
336                        args.is_empty()
337                    }
338                    None => false,
339                };
340                if drop_empty {
341                    entry.arguments = None;
342                }
343            }
344            servers.sort();
345        }
346    }
347    this.plugins.sort();
348    this.tools.sort();
349    if this.objectiveai.is_none() && this.plugins.is_empty() && this.tools.is_empty() {
350        None
351    } else {
352        Some(this)
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    fn entry(name: &str, args: &[(&str, Option<&str>)]) -> ClientObjectiveaiMcpPluginMcpServer {
361        let arguments = if args.is_empty() {
362            None
363        } else {
364            let mut m = IndexMap::new();
365            for (k, v) in args {
366                m.insert(k.to_string(), v.map(|s| s.to_string()));
367            }
368            Some(m)
369        };
370        ClientObjectiveaiMcpPluginMcpServer {
371            name: name.to_string(),
372            arguments,
373        }
374    }
375
376    fn plugin(name: &str, servers: Vec<ClientObjectiveaiMcpPluginMcpServer>) -> ClientObjectiveaiMcpPluginEntry {
377        ClientObjectiveaiMcpPluginEntry {
378            owner: "o".into(),
379            name: name.into(),
380            version: "v".into(),
381            executable: true,
382            mcp_servers: Some(servers),
383        }
384    }
385
386    fn shell(plugins: Vec<ClientObjectiveaiMcpPluginEntry>) -> ClientObjectiveaiMcp {
387        ClientObjectiveaiMcp {
388            objectiveai: None,
389            plugins,
390            tools: vec![],
391        }
392    }
393
394    #[test]
395    fn prepare_sorts_arguments_by_key_so_order_does_not_matter() {
396        let a = shell(vec![plugin(
397            "p",
398            vec![entry("s", &[("b", Some("1")), ("a", Some("2"))])],
399        )]);
400        let b = shell(vec![plugin(
401            "p",
402            vec![entry("s", &[("a", Some("2")), ("b", Some("1"))])],
403        )]);
404        let ap = prepare(a).expect("non-empty after prepare");
405        let bp = prepare(b).expect("non-empty after prepare");
406        assert_eq!(
407            serde_json::to_string(&ap).unwrap(),
408            serde_json::to_string(&bp).unwrap(),
409            "two declarations with identical key/value pairs in different insertion order must canonicalize to byte-identical JSON",
410        );
411    }
412
413    #[test]
414    fn prepare_sorts_mcp_servers_vec_by_name_then_arguments() {
415        let a = shell(vec![plugin(
416            "p",
417            vec![
418                entry("z", &[("a", Some("1"))]),
419                entry("a", &[("k", Some("v"))]),
420            ],
421        )]);
422        let ap = prepare(a).expect("non-empty after prepare");
423        let servers = ap.plugins[0].mcp_servers.as_ref().unwrap();
424        assert_eq!(servers[0].name, "a");
425        assert_eq!(servers[1].name, "z");
426    }
427
428    #[test]
429    fn validate_rejects_duplicate_mcp_server_names_within_plugin() {
430        let bad = shell(vec![plugin(
431            "p",
432            vec![entry("dup", &[]), entry("dup", &[("k", Some("v"))])],
433        )]);
434        let err = validate(&bad).expect_err("duplicate names must be rejected");
435        assert!(err.contains("duplicate name"), "unexpected error: {err}");
436    }
437
438    #[test]
439    fn validate_rejects_empty_argument_key() {
440        let bad = shell(vec![plugin("p", vec![entry("s", &[("", Some("v"))])])]);
441        let err = validate(&bad).expect_err("empty argument keys must be rejected");
442        assert!(err.contains("`arguments` key"), "unexpected error: {err}");
443    }
444
445    #[test]
446    fn empty_arguments_round_trip_omits_field() {
447        let s = entry("name", &[]);
448        let json = serde_json::to_string(&s).unwrap();
449        assert!(
450            !json.contains("arguments"),
451            "absent arguments must be skipped on serialize: {json}"
452        );
453        let back: ClientObjectiveaiMcpPluginMcpServer = serde_json::from_str(&json).unwrap();
454        assert_eq!(back, s);
455    }
456
457    #[test]
458    fn populated_arguments_round_trip() {
459        // Mix of valued and flag-only args.
460        let s = entry("name", &[("a", Some("1")), ("debug", None), ("b", Some("2"))]);
461        let json = serde_json::to_string(&s).unwrap();
462        let back: ClientObjectiveaiMcpPluginMcpServer = serde_json::from_str(&json).unwrap();
463        assert_eq!(back, s);
464    }
465
466    #[test]
467    fn prepare_normalizes_empty_string_value_to_none() {
468        // Caller wrote `--debug ""` — prepare must canonicalize that
469        // to a bare `--debug` flag (None) so identical declarations
470        // hash byte-identically regardless of whether the author
471        // wrote `None` or `Some("")`.
472        let with_empty = shell(vec![plugin("p", vec![entry("s", &[("debug", Some(""))])])]);
473        let prepared = prepare(with_empty).expect("non-empty after prepare");
474        let args = prepared.plugins[0].mcp_servers.as_ref().unwrap()[0]
475            .arguments
476            .as_ref()
477            .unwrap();
478        assert_eq!(
479            args.get("debug").unwrap(),
480            &None,
481            "Some(\"\") must canonicalize to None"
482        );
483
484        // And the canonical JSON for `Some("")` must equal the one
485        // for `None`.
486        let with_none = shell(vec![plugin("p", vec![entry("s", &[("debug", None)])])]);
487        let prepared_none = prepare(with_none).expect("non-empty after prepare");
488        assert_eq!(
489            serde_json::to_string(&prepared).unwrap(),
490            serde_json::to_string(&prepared_none).unwrap(),
491        );
492    }
493
494    #[test]
495    fn prepare_collapses_empty_arguments_to_none() {
496        // Caller explicitly supplied `Some(empty map)` — prepare
497        // must canonicalize that to `None` so it serializes
498        // identically to the absent case.
499        let with_empty = ClientObjectiveaiMcp {
500            objectiveai: None,
501            plugins: vec![ClientObjectiveaiMcpPluginEntry {
502                owner: "o".into(),
503                name: "p".into(),
504                version: "v".into(),
505                executable: true,
506                mcp_servers: Some(vec![ClientObjectiveaiMcpPluginMcpServer {
507                    name: "s".into(),
508                    arguments: Some(IndexMap::new()),
509                }]),
510            }],
511            tools: vec![],
512        };
513        let prepared = prepare(with_empty).expect("non-empty after prepare");
514        let arg = &prepared.plugins[0].mcp_servers.as_ref().unwrap()[0].arguments;
515        assert!(arg.is_none(), "empty arguments map must canonicalize to None");
516    }
517}