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
275pub 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
310pub 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 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 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 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 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 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}