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
57#[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 #[serde(default = "default_true")]
92 pub executable: bool,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
100 #[schemars(extend("omitempty" = true))]
101 pub mcp_servers: Option<Vec<ClientObjectiveaiMcpPluginMcpServer>>,
102}
103
104#[derive(
112 Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, arbitrary::Arbitrary,
113)]
114#[schemars(rename = "agent.ClientObjectiveaiMcpPluginMcpServer")]
115pub struct ClientObjectiveaiMcpPluginMcpServer {
116 pub name: String,
120 #[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 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 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 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#[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 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#[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 pub root: bool,
309 pub tools: Vec<ClientObjectiveaiMcpEntry>,
313 pub plugins: Vec<ClientObjectiveaiMcpEntry>,
319}
320
321impl ClientObjectiveaiMcpHeaders {
322 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
347pub 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
382pub 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 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 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 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 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 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 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}