1use indexmap::IndexMap;
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17
18#[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 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 pub fn tool_name(&self) -> String {
58 materialize_tool_name(&self.owner, &self.name, &self.version)
59 }
60}
61
62#[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 #[serde(default = "default_true")]
97 pub executable: bool,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
105 #[schemars(extend("omitempty" = true))]
106 pub mcp_servers: Option<Vec<ClientObjectiveaiMcpPluginMcpServer>>,
107}
108
109#[derive(
117 Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, arbitrary::Arbitrary,
118)]
119#[schemars(rename = "agent.ClientObjectiveaiMcpPluginMcpServer")]
120pub struct ClientObjectiveaiMcpPluginMcpServer {
121 pub name: String,
125 #[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 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 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 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 pub fn tool_name(&self) -> String {
224 materialize_tool_name(&self.owner, &self.name, &self.version)
225 }
226}
227
228pub fn materialize_tool_name(owner: &str, name: &str, version: &str) -> String {
238 format!("{owner}-{name}-{version}").replace('.', "-")
239}
240
241#[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 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#[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 pub root: bool,
332 pub tools: Vec<ClientObjectiveaiMcpEntry>,
336 pub plugins: Vec<ClientObjectiveaiMcpEntry>,
342}
343
344impl ClientObjectiveaiMcpHeaders {
345 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
370pub 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
405pub 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 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 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 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 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 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 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}