返回 CodeWhale
agent_plugin.rs
根目录 / crates / tui / src / plugins / agent_plugin.rs
1 //! Agent Plugins v1.0.0 (agent-plugins.org) format support.
2 //!
3 //! The standard is a vendor-neutral packaging format: a `plugin.json` manifest
4 //! with a closed root (`$schema`, `name`, and the optional well-known fields;
5 //! everything client-specific lives under `extensions`, keyed by reverse-domain
6 //! namespace), a sibling `mcp.json` for MCP servers (the manifest root is
7 //! closed, so servers cannot live in `plugin.json`), and a `skills/<name>/
8 //! SKILL.md` tree that already matches Codewhale's skill layout.
9 //!
10 //! This module owns the mapping between that format and Codewhale's internal
11 //! [`PluginManifest`]:
12 //!
13 //! * **Consume** — [`parse_plugin_json`] + [`standard_to_manifest`] and
14 //! [`parse_mcp_json`]. Unknown `extensions` namespaces are ignored, never
15 //! rejected: that is what lets a Cursor- or Copilot-authored plugin load
16 //! here. Codewhale-specific fields round-trip through
17 //! `extensions["net.codewhale"]`.
18 //! * **Publish** — [`manifest_to_standard`], emitting a spec-valid
19 //! `plugin.json` (+ `mcp.json` when servers exist). [`validate_plugin_json`]
20 //! and [`validate_mcp_json`] re-check every emission against the standard's
21 //! shape before it is written, so upstream drift fails here rather than
22 //! shipping.
23 //! * **Names** — the standard's name rule
24 //! (`^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$`, 1–64 chars) is
25 //! [`is_standard_plugin_name`]; [`slugify_plugin_name`] maps a legacy name
26 //! onto it. A slugified name that collides with an existing plugin is an
27 //! error, never a silent rename.
28 //!
29 //! Auto-migration of on-disk `plugin.toml` files is deliberately **not**
30 //! implemented here; this module only converts representations.
31
32 use std::collections::{BTreeMap, BTreeSet, HashMap};
33 use std::path::{Path, PathBuf};
34
35 use serde::{Deserialize, Serialize};
36
37 use super::manifest::{
38 CURRENT_SCHEMA_VERSION, MAX_PLUGIN_NAME_CHARS, PluginCapabilities, PluginManifest, PluginMeta,
39 PluginPathSpec, PluginWhen,
40 };
41 use crate::mcp::{McpServerConfig, McpServerOAuthConfig};
42
43 /// Manifest file names, in discovery preference order. `plugin.json` is the
44 /// native Agent Plugins format; `plugin.toml` is the legacy Codewhale format
45 /// and stays readable.
46 pub const PLUGIN_JSON_NAME: &str = "plugin.json";
47 pub const PLUGIN_TOML_NAME: &str = "plugin.toml";
48 /// Kimi Code's native plugin manifest. Codewhale consumes the compatible
49 /// Skills and MCP subset directly so official Kimi bundles do not need to be
50 /// rewritten or copied by hand before installation.
51 pub const KIMI_PLUGIN_JSON_NAME: &str = "kimi.plugin.json";
52 /// Sibling file carrying MCP server definitions for a `plugin.json` bundle.
53 pub const MCP_JSON_NAME: &str = "mcp.json";
54
55 /// `$schema` values emitted for the v1.0.0 documents.
56 pub const PLUGIN_SCHEMA_URL: &str = "https://agent-plugins.org/schemas/plugin.json";
57 pub const MCP_SCHEMA_URL: &str = "https://agent-plugins.org/schemas/mcp.json";
58
59 /// Codewhale's reverse-domain extension namespace.
60 pub const CODEWHALE_NAMESPACE: &str = "net.codewhale";
61
62 /// `env` keys a plugin may never define: the host runtime owns these.
63 const RESERVED_MCP_ENV_NAMES: [&str; 2] = ["PLUGIN_ROOT", "PLUGIN_DATA"];
64
65 /// The standard's name rule: lowercase ASCII letters/digits with internal
66 /// single hyphens or dots, starting and ending alphanumeric, never containing
67 /// `--` or `..`, 1–64 chars. Equivalent to
68 /// `^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$`.
69 #[must_use]
70 pub fn is_standard_plugin_name(name: &str) -> bool {
71 let count = name.chars().count();
72 if count == 0 || count > MAX_PLUGIN_NAME_CHARS {
73 return false;
74 }
75 if name.contains("--") || name.contains("..") {
76 return false;
77 }
78 let alphanumeric = |ch: char| ch.is_ascii_lowercase() || ch.is_ascii_digit();
79 name.chars().next().is_some_and(alphanumeric)
80 && name.chars().next_back().is_some_and(alphanumeric)
81 && name
82 .chars()
83 .all(|ch| alphanumeric(ch) || ch == '-' || ch == '.')
84 }
85
86 /// Map an arbitrary legacy plugin name onto the standard's name rule.
87 ///
88 /// Letters are ASCII-lowercased; every maximal run of non-conforming
89 /// characters collapses to one separator (`.` when the run is dots only, `-`
90 /// otherwise), leading/trailing separators are dropped, and the result is
91 /// truncated to 64 chars. Errors when nothing usable remains — slugification
92 /// never invents a name.
93 pub fn slugify_plugin_name(name: &str) -> Result<String, String> {
94 let mut slug = String::new();
95 let mut pending_separator: Option<char> = None;
96 for ch in name.chars() {
97 if ch.is_ascii_alphanumeric() {
98 if let Some(separator) = pending_separator.take()
99 && !slug.is_empty()
100 {
101 slug.push(separator);
102 }
103 slug.push(ch.to_ascii_lowercase());
104 } else {
105 let separator = if ch == '.' { '.' } else { '-' };
106 pending_separator = Some(match (pending_separator, separator) {
107 (Some('.'), '.') => '.',
108 (None, sep) => sep,
109 // Any run touching a hyphen (or mixing separators) is a hyphen.
110 _ => '-',
111 });
112 }
113 }
114 while slug.chars().count() > MAX_PLUGIN_NAME_CHARS {
115 slug.pop();
116 }
117 while slug.ends_with(['-', '.']) {
118 slug.pop();
119 }
120 if slug.is_empty() {
121 return Err(format!(
122 "plugin name `{name}` cannot be slugified: it contains no ASCII letters or digits"
123 ));
124 }
125 debug_assert!(is_standard_plugin_name(&slug));
126 Ok(slug)
127 }
128
129 /// Locate the manifest of a bundle directory: `plugin.json` when present,
130 /// otherwise a legacy `plugin.toml`. A bundle shipping both (dual-publish for
131 /// older clients) is read through its `plugin.json`.
132 #[must_use]
133 pub fn resolve_manifest_path(root: &Path) -> Option<PathBuf> {
134 let json = root.join(PLUGIN_JSON_NAME);
135 if json.is_file() {
136 return Some(json);
137 }
138 let kimi_json = root.join(KIMI_PLUGIN_JSON_NAME);
139 if kimi_json.is_file() {
140 return Some(kimi_json);
141 }
142 let toml = root.join(PLUGIN_TOML_NAME);
143 if toml.is_file() {
144 return Some(toml);
145 }
146 let claude = root.join(".claude-plugin").join(PLUGIN_JSON_NAME);
147 claude.is_file().then_some(claude)
148 }
149
150 /// The bundle root is outside Claude's manifest-only metadata directory.
151 pub(crate) fn plugin_root_for_manifest(path: &Path) -> Option<&Path> {
152 let parent = path.parent()?;
153 if path.file_name()?.to_str()? == PLUGIN_JSON_NAME
154 && parent
155 .file_name()
156 .is_some_and(|name| name == ".claude-plugin")
157 {
158 parent.parent()
159 } else {
160 Some(parent)
161 }
162 }
163
164 /// Claude's skills/commands/agents/MCP subset uses the existing declarative
165 /// adapters. Unsupported components fail validation; no hooks are silently lost.
166 pub(crate) fn parse_claude_plugin_json(
167 text: &str,
168 root: &Path,
169 mcp_bytes: Option<&[u8]>,
170 ) -> Result<PluginManifest, String> {
171 let mut value: serde_json::Value = serde_json::from_str(text)
172 .map_err(|error| format!("invalid Claude plugin.json: {error}"))?;
173 let object = value
174 .as_object_mut()
175 .ok_or("Claude plugin.json must be an object")?;
176 for component in ["hooks/hooks.json", ".lsp.json"] {
177 if root.join(component).exists() {
178 return Err(format!(
179 "Claude component {component} is not supported by this importer; no partial plugin will be installed"
180 ));
181 }
182 }
183
184 // Reuse the compatible, closed metadata/component schema. Claude's
185 // default component directories are additive to explicit paths.
186 for component in ["skills", "commands", "agents"] {
187 if root.join(component).is_dir() {
188 let mut paths = match object.remove(component) {
189 Some(value) => {
190 let spec = serde_json::from_value::<KimiPathSpec>(value)
191 .map_err(|error| format!("invalid Claude {component} paths: {error}"))?
192 .into_plugin_path_spec()?;
193 spec.path.into_iter().chain(spec.paths).collect::<Vec<_>>()
194 }
195 None => Vec::new(),
196 };
197 if !paths.iter().any(|path| {
198 path.strip_prefix("./")
199 .unwrap_or(path)
200 .trim_end_matches('/')
201 == component
202 }) {
203 paths.push(component.to_string());
204 }
205 object.insert(component.to_string(), serde_json::json!(paths));
206 }
207 }
208 let mut servers = match object.remove("mcpServers") {
209 Some(serde_json::Value::Object(map)) => map,
210 Some(_) => return Err("Claude mcpServers must be an inline server object; custom MCP file paths are not supported".to_string()),
211 None => serde_json::Map::new(),
212 };
213 if let Some(bytes) = mcp_bytes {
214 let mut file: serde_json::Value =
215 serde_json::from_slice(bytes).map_err(|error| format!("invalid .mcp.json: {error}"))?;
216 let map = file.as_object_mut().ok_or(".mcp.json must be an object")?;
217 let entries = if let Some(wrapped) = map.remove("mcpServers") {
218 map.remove("$schema");
219 if !map.is_empty() {
220 return Err("unsupported .mcp.json wrapper fields".to_string());
221 }
222 wrapped
223 .as_object()
224 .cloned()
225 .ok_or(".mcp.json mcpServers must be an object")?
226 } else {
227 std::mem::take(map)
228 };
229 for (name, server) in entries {
230 if servers.insert(name.clone(), server).is_some() {
231 return Err(format!(
232 "MCP server {name} is declared in both plugin.json and .mcp.json"
233 ));
234 }
235 }
236 }
237 for (name, server) in &mut servers {
238 if server.get("type").and_then(serde_json::Value::as_str) == Some("http") {
239 server["type"] = serde_json::json!("streamable-http");
240 }
241 if server.to_string().contains("${CLAUDE_PLUGIN_ROOT}") {
242 return Err(format!(
243 "MCP server {name} uses unsupported CLAUDE_PLUGIN_ROOT expansion; use bundle-relative command paths"
244 ));
245 }
246 }
247 object.insert("mcpServers".to_string(), serde_json::Value::Object(servers));
248 let mut manifest = parse_kimi_plugin_json(&value.to_string(), root)
249 .map_err(|error| error.replace("kimi.plugin.json", "Claude plugin.json"))?;
250 // Official Claude bundles express credential sources as header templates.
251 // Translate only exact environment references into the existing reviewed
252 // credential contract. Never expand credentials while importing a bundle.
253 if let Some(servers) = &mut manifest.mcp_servers {
254 for (name, server) in servers {
255 for (header, value) in std::mem::take(&mut server.headers) {
256 let authorization = header.eq_ignore_ascii_case("authorization");
257 if server
258 .env_headers
259 .keys()
260 .any(|key| key.eq_ignore_ascii_case(&header))
261 || (authorization && server.bearer_token_env_var.is_some())
262 {
263 return Err(format!(
264 "MCP server {name} has duplicate credential sources for header {header}"
265 ));
266 }
267 if authorization
268 && let Some(source) = value
269 .strip_prefix("Bearer ")
270 .and_then(super::manifest::exact_environment_placeholder)
271 {
272 server.bearer_token_env_var = Some(source.to_string());
273 } else if let Some(source) = super::manifest::exact_environment_placeholder(&value)
274 {
275 server.env_headers.insert(header, source.to_string());
276 } else {
277 return Err(format!(
278 "MCP server {name} header {header} must use an exact environment reference; literal or compound header values are not supported"
279 ));
280 }
281 }
282 }
283 }
284 Ok(manifest)
285 }
286
287 // ─────────────────────────────────────────────────────────────────────────────
288 // Kimi Code compatibility
289 // ─────────────────────────────────────────────────────────────────────────────
290
291 #[derive(Debug, Clone, Deserialize)]
292 #[serde(deny_unknown_fields)]
293 struct KimiPluginManifest {
294 #[serde(rename = "$schema", default)]
295 schema: Option<String>,
296 name: String,
297 #[serde(default)]
298 version: Option<String>,
299 #[serde(default)]
300 description: Option<String>,
301 #[serde(default)]
302 keywords: Vec<String>,
303 #[serde(default)]
304 author: Option<KimiAuthor>,
305 #[serde(default)]
306 homepage: Option<String>,
307 #[serde(default)]
308 repository: Option<String>,
309 #[serde(default)]
310 license: Option<String>,
311 #[serde(default)]
312 skills: Option<KimiPathSpec>,
313 #[serde(default)]
314 commands: Option<KimiPathSpec>,
315 #[serde(default)]
316 agents: Option<KimiPathSpec>,
317 #[serde(rename = "mcpServers", default)]
318 mcp_servers: BTreeMap<String, KimiMcpServer>,
319 #[serde(rename = "interface", default)]
320 interface_metadata: Option<KimiInterface>,
321 }
322
323 #[derive(Debug, Clone, Deserialize)]
324 #[serde(untagged)]
325 enum KimiAuthor {
326 Text(String),
327 Detailed(StandardAuthor),
328 }
329
330 #[derive(Debug, Clone, Deserialize)]
331 #[serde(untagged)]
332 enum KimiPathSpec {
333 One(String),
334 Many(Vec<String>),
335 }
336
337 impl KimiPathSpec {
338 fn into_plugin_path_spec(self) -> Result<PluginPathSpec, String> {
339 match self {
340 Self::One(path) => Ok(PluginPathSpec {
341 path: Some(path),
342 paths: Vec::new(),
343 }),
344 Self::Many(paths) if paths.is_empty() => {
345 Err("Kimi plugin component path list must not be empty".to_string())
346 }
347 Self::Many(paths) => Ok(PluginPathSpec { path: None, paths }),
348 }
349 }
350 }
351
352 #[derive(Debug, Clone, Default, Deserialize)]
353 #[serde(deny_unknown_fields)]
354 struct KimiInterface {
355 #[serde(rename = "displayName", default)]
356 display_name: Option<String>,
357 #[serde(rename = "shortDescription", default)]
358 _short_description: Option<String>,
359 #[serde(rename = "longDescription", default)]
360 _long_description: Option<String>,
361 #[serde(rename = "developerName", default)]
362 _developer_name: Option<String>,
363 #[serde(rename = "websiteURL", default)]
364 _website_url: Option<String>,
365 #[serde(rename = "iconUrl", default)]
366 _icon_url: Option<String>,
367 #[serde(rename = "category", default)]
368 _category: Option<String>,
369 #[serde(rename = "hostKind", default)]
370 host_kind: Option<String>,
371 #[serde(default)]
372 platforms: Vec<String>,
373 #[serde(rename = "mcpOverrides", default)]
374 mcp_overrides: BTreeMap<String, KimiMcpInterfaceOverride>,
375 }
376
377 #[derive(Debug, Clone, Default, Deserialize)]
378 #[serde(deny_unknown_fields)]
379 struct KimiMcpInterfaceOverride {
380 #[serde(rename = "displayName", default)]
381 _display_name: Option<String>,
382 #[serde(rename = "iconUrl", default)]
383 _icon_url: Option<String>,
384 }
385
386 /// Kimi's MCP shape is the standard MCP server entry plus a top-level
387 /// `enabledTools` allow-list. Keeping this as a separate closed type prevents
388 /// Kimi-only fields from weakening the Agent Plugins `mcp.json` parser.
389 #[derive(Debug, Clone, Default, Deserialize)]
390 #[serde(deny_unknown_fields)]
391 struct KimiMcpServer {
392 #[serde(rename = "type", default)]
393 transport: Option<String>,
394 #[serde(default)]
395 command: Option<String>,
396 #[serde(default)]
397 args: Vec<String>,
398 #[serde(default)]
399 env: BTreeMap<String, String>,
400 #[serde(default)]
401 cwd: Option<String>,
402 #[serde(default)]
403 url: Option<String>,
404 #[serde(default)]
405 headers: BTreeMap<String, String>,
406 #[serde(default)]
407 extensions: BTreeMap<String, serde_json::Value>,
408 #[serde(rename = "enabledTools", default)]
409 enabled_tools: Vec<String>,
410 }
411
412 impl KimiMcpServer {
413 fn into_config(self, id: &str) -> Result<McpServerConfig, String> {
414 let enabled_tools = self.enabled_tools;
415 let standard = StandardMcpServer {
416 transport: self.transport,
417 command: self.command,
418 args: self.args,
419 env: self.env,
420 cwd: self.cwd,
421 url: self.url,
422 headers: self.headers,
423 extensions: self.extensions,
424 };
425 let mut config = standard_server_to_config(id, standard)?;
426 if !enabled_tools.is_empty() {
427 if !config.enabled_tools.is_empty() && config.enabled_tools != enabled_tools {
428 return Err(format!(
429 "kimi.plugin.json MCP server `{id}` declares conflicting enabledTools filters"
430 ));
431 }
432 config.enabled_tools = enabled_tools;
433 }
434 Ok(config)
435 }
436 }
437
438 /// Parse the Skills/MCP-compatible subset of a Kimi Code plugin manifest.
439 ///
440 /// The input shape is deliberately closed. Kimi capabilities Codewhale does
441 /// not yet implement (for example lifecycle hooks or prompt injection) fail
442 /// validation instead of being silently dropped and then presented as fully
443 /// working. In addition to the Skills-only official bundles, this accepts the
444 /// known Kimi-managed CU shape: display/platform interface metadata and MCP
445 /// `enabledTools`. External applications, daemons, binaries, and permissions
446 /// are prerequisites outside this parser and are never implied to exist.
447 pub fn parse_kimi_plugin_json(text: &str, root: &Path) -> Result<PluginManifest, String> {
448 let kimi: KimiPluginManifest = serde_json::from_str(text)
449 .map_err(|error| format!("failed to parse kimi.plugin.json: {error}"))?;
450 if let Some(schema) = kimi.schema.as_deref()
451 && schema.trim().is_empty()
452 {
453 return Err("kimi.plugin.json `$schema` must be a non-empty string".to_string());
454 }
455 if !is_kimi_plugin_name(&kimi.name) {
456 return Err(format!(
457 "kimi.plugin.json name `{}` is invalid (1-{MAX_PLUGIN_NAME_CHARS} lowercase ASCII letters, digits, hyphens, or underscores; must start with a letter or digit)",
458 kimi.name
459 ));
460 }
461
462 let skills = match kimi.skills {
463 Some(paths) => Some(paths.into_plugin_path_spec()?),
464 None if root.join("SKILL.md").is_file() => Some(PluginPathSpec {
465 path: Some(".".to_string()),
466 paths: Vec::new(),
467 }),
468 None => None,
469 };
470 let commands = kimi
471 .commands
472 .map(KimiPathSpec::into_plugin_path_spec)
473 .transpose()?;
474 let agents = kimi
475 .agents
476 .map(KimiPathSpec::into_plugin_path_spec)
477 .transpose()?;
478
479 let mut mcp_servers = HashMap::with_capacity(kimi.mcp_servers.len());
480 for (id, server) in kimi.mcp_servers {
481 mcp_servers.insert(id.clone(), server.into_config(&id)?);
482 }
483 let mut network_hosts = mcp_servers
484 .values()
485 .filter_map(|server| server.url.as_deref())
486 .filter_map(|url| reqwest::Url::parse(url).ok())
487 .filter_map(|url| url.host_str().map(str::to_ascii_lowercase))
488 .collect::<Vec<_>>();
489 network_hosts.sort();
490 network_hosts.dedup();
491
492 let author = match kimi.author {
493 Some(KimiAuthor::Text(author)) => Some(author),
494 Some(KimiAuthor::Detailed(author)) => Some(compose_author(author)?),
495 None => None,
496 };
497 let (display_name, when) = match kimi.interface_metadata {
498 Some(interface) => {
499 if let Some(host_kind) = interface.host_kind.as_deref()
500 && !host_kind.eq_ignore_ascii_case("local")
501 {
502 return Err(format!(
503 "kimi.plugin.json interface hostKind `{host_kind}` is unsupported; only `local` is understood"
504 ));
505 }
506 if interface
507 .mcp_overrides
508 .keys()
509 .any(|key| key.trim().is_empty())
510 {
511 return Err(
512 "kimi.plugin.json interface mcpOverrides keys must not be empty".to_string(),
513 );
514 }
515 let mut platforms = Vec::with_capacity(interface.platforms.len());
516 let mut seen = BTreeSet::new();
517 const SUPPORTED_PLATFORMS: &[&str] = &[
518 "windows", "linux", "macos", "freebsd", "openbsd", "netbsd", "android", "ios",
519 ];
520 for platform in interface.platforms {
521 let platform = platform.trim().to_ascii_lowercase();
522 if !SUPPORTED_PLATFORMS.contains(&platform.as_str()) {
523 return Err(format!(
524 "kimi.plugin.json interface has unsupported platform `{platform}`"
525 ));
526 }
527 if !seen.insert(platform.clone()) {
528 return Err(format!(
529 "kimi.plugin.json interface repeats platform `{platform}`"
530 ));
531 }
532 platforms.push(platform);
533 }
534 let when = (!platforms.is_empty()).then_some(PluginWhen {
535 os: Some(platforms),
536 binaries: None,
537 });
538 (interface.display_name, when)
539 }
540 None => (None, None),
541 };
542
543 Ok(PluginManifest {
544 schema_version: CURRENT_SCHEMA_VERSION,
545 plugin: PluginMeta {
546 name: kimi.name,
547 description: kimi.description,
548 version: kimi.version.unwrap_or_else(|| "0.0.0".to_string()),
549 author,
550 homepage: kimi.homepage,
551 repository: kimi.repository,
552 license: kimi.license,
553 keywords: kimi.keywords,
554 display_name,
555 icon: None,
556 },
557 skills,
558 commands,
559 agents,
560 hooks: None,
561 lsp: None,
562 native: None,
563 mcp_servers: (!mcp_servers.is_empty()).then_some(mcp_servers),
564 capabilities: PluginCapabilities {
565 network_hosts,
566 ..PluginCapabilities::default()
567 },
568 when,
569 })
570 }
571
572 #[must_use]
573 pub fn is_kimi_plugin_name(name: &str) -> bool {
574 let count = name.chars().count();
575 count > 0
576 && count <= MAX_PLUGIN_NAME_CHARS
577 && name
578 .chars()
579 .next()
580 .is_some_and(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
581 && name
582 .chars()
583 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_'))
584 }
585
586 // ─────────────────────────────────────────────────────────────────────────────
587 // Standard document shapes
588 // ─────────────────────────────────────────────────────────────────────────────
589
590 /// `plugin.json` per Agent Plugins v1.0.0. The root is closed
591 /// (`additionalProperties: false`); client-specific data belongs under
592 /// `extensions`, keyed by reverse-domain namespace.
593 #[derive(Debug, Clone, Deserialize, Serialize)]
594 #[serde(deny_unknown_fields)]
595 pub struct StandardPluginManifest {
596 #[serde(rename = "$schema")]
597 pub schema: String,
598 pub name: String,
599 #[serde(default, skip_serializing_if = "Option::is_none")]
600 pub version: Option<String>,
601 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub description: Option<String>,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub author: Option<StandardAuthor>,
605 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub homepage: Option<String>,
607 #[serde(default, skip_serializing_if = "Option::is_none")]
608 pub repository: Option<String>,
609 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub license: Option<String>,
611 #[serde(default, skip_serializing_if = "Vec::is_empty")]
612 pub keywords: Vec<String>,
613 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
614 pub extensions: BTreeMap<String, serde_json::Value>,
615 }
616
617 #[derive(Debug, Clone, Deserialize, Serialize)]
618 #[serde(deny_unknown_fields)]
619 pub struct StandardAuthor {
620 pub name: String,
621 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub email: Option<String>,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub url: Option<String>,
625 }
626
627 /// Codewhale's own `extensions["net.codewhale"]` payload: every manifest
628 /// concept the standard does not define. Unknown keys inside our own namespace
629 /// are rejected (`deny_unknown_fields`) — the ignore-unknowns rule applies to
630 /// *other* vendors' namespaces, not to a silently dropped Codewhale field.
631 #[derive(Debug, Clone, Default, Deserialize, Serialize)]
632 #[serde(deny_unknown_fields)]
633 pub struct CodewhalePluginExtension {
634 #[serde(default, skip_serializing_if = "Option::is_none")]
635 pub display_name: Option<String>,
636 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub icon: Option<String>,
638 #[serde(default, skip_serializing_if = "Option::is_none")]
639 pub skills: Option<PluginPathSpec>,
640 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub commands: Option<PluginPathSpec>,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub agents: Option<PluginPathSpec>,
644 #[serde(default, skip_serializing_if = "Option::is_none")]
645 pub hooks: Option<PluginPathSpec>,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub lsp: Option<PluginPathSpec>,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub native: Option<PluginPathSpec>,
650 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub capabilities: Option<PluginCapabilities>,
652 #[serde(default, skip_serializing_if = "Option::is_none")]
653 pub when: Option<PluginWhen>,
654 }
655
656 impl CodewhalePluginExtension {
657 #[must_use]
658 fn is_empty(&self) -> bool {
659 self.display_name.is_none()
660 && self.icon.is_none()
661 && self.skills.is_none()
662 && self.commands.is_none()
663 && self.agents.is_none()
664 && self.hooks.is_none()
665 && self.lsp.is_none()
666 && self.native.is_none()
667 && self.capabilities.is_none()
668 && self.when.is_none()
669 }
670 }
671
672 /// `mcp.json` per Agent Plugins v1.0.0: `$schema` plus `mcpServers` keyed by
673 /// server id.
674 #[derive(Debug, Clone, Deserialize, Serialize)]
675 #[serde(deny_unknown_fields)]
676 pub struct StandardMcpFile {
677 #[serde(rename = "$schema", default, skip_serializing_if = "Option::is_none")]
678 pub schema: Option<String>,
679 #[serde(rename = "mcpServers")]
680 pub servers: BTreeMap<String, StandardMcpServer>,
681 }
682
683 /// One `mcp.json` server entry. Transports: `stdio` (command/args/env/cwd),
684 /// `streamable-http` (url/headers), and `sse` (url/headers). `type` may be
685 /// omitted and is then inferred from `command` vs `url`. Codewhale-only
686 /// server options ride in `extensions["net.codewhale"]`.
687 #[derive(Debug, Clone, Default, Deserialize, Serialize)]
688 #[serde(deny_unknown_fields)]
689 pub struct StandardMcpServer {
690 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
691 pub transport: Option<String>,
692 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub command: Option<String>,
694 #[serde(default, skip_serializing_if = "Vec::is_empty")]
695 pub args: Vec<String>,
696 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
697 pub env: BTreeMap<String, String>,
698 #[serde(default, skip_serializing_if = "Option::is_none")]
699 pub cwd: Option<String>,
700 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub url: Option<String>,
702 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
703 pub headers: BTreeMap<String, String>,
704 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
705 pub extensions: BTreeMap<String, serde_json::Value>,
706 }
707
708 /// Codewhale-only MCP server options, carried per-server under
709 /// `extensions["net.codewhale"]` in `mcp.json` so the standard fields round-
710 /// trip losslessly with Codewhale's richer review model (timeouts, tool
711 /// filters, env-backed credentials, enablement).
712 #[derive(Debug, Clone, Default, Deserialize, Serialize)]
713 #[serde(deny_unknown_fields)]
714 pub struct CodewhaleMcpExtension {
715 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
716 pub env_headers: BTreeMap<String, String>,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub bearer_token_env_var: Option<String>,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub connect_timeout: Option<u64>,
721 #[serde(default, skip_serializing_if = "Option::is_none")]
722 pub execute_timeout: Option<u64>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub read_timeout: Option<u64>,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub enabled: Option<bool>,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub disabled: Option<bool>,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub required: Option<bool>,
731 #[serde(default, skip_serializing_if = "Vec::is_empty")]
732 pub enabled_tools: Vec<String>,
733 #[serde(default, skip_serializing_if = "Vec::is_empty")]
734 pub disabled_tools: Vec<String>,
735 #[serde(default, skip_serializing_if = "Vec::is_empty")]
736 pub scopes: Vec<String>,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
738 pub oauth: Option<McpServerOAuthConfig>,
739 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub oauth_resource: Option<String>,
741 }
742
743 impl CodewhaleMcpExtension {
744 #[must_use]
745 fn is_empty(&self) -> bool {
746 self.env_headers.is_empty()
747 && self.bearer_token_env_var.is_none()
748 && self.connect_timeout.is_none()
749 && self.execute_timeout.is_none()
750 && self.read_timeout.is_none()
751 && self.enabled.is_none()
752 && self.disabled.is_none()
753 && self.required.is_none()
754 && self.enabled_tools.is_empty()
755 && self.disabled_tools.is_empty()
756 && self.scopes.is_empty()
757 && self.oauth.is_none()
758 && self.oauth_resource.is_none()
759 }
760 }
761
762 // ─────────────────────────────────────────────────────────────────────────────
763 // Consume: standard documents -> internal manifest
764 // ─────────────────────────────────────────────────────────────────────────────
765
766 /// Parse a `plugin.json` document. Unknown root keys are rejected (the
767 /// standard's root is closed); unknown `extensions` namespaces parse fine and
768 /// are dropped by [`standard_to_manifest`].
769 pub fn parse_plugin_json(text: &str) -> Result<StandardPluginManifest, String> {
770 let manifest: StandardPluginManifest = serde_json::from_str(text)
771 .map_err(|error| format!("failed to parse plugin.json: {error}"))?;
772 if manifest.schema.trim().is_empty() {
773 return Err("plugin.json `$schema` must be a non-empty string".to_string());
774 }
775 if !is_standard_plugin_name(&manifest.name) {
776 return Err(format!(
777 "plugin.json name `{}` is invalid under the Agent Plugins standard (1-{MAX_PLUGIN_NAME_CHARS} lowercase ASCII letters, digits, or internal single `-`/`.`; never `--` or `..`)",
778 manifest.name
779 ));
780 }
781 Ok(manifest)
782 }
783
784 /// Parse an `mcp.json` document into internal server configs, enforcing the
785 /// per-transport shapes and the reserved `env` names.
786 pub fn parse_mcp_json(text: &str) -> Result<HashMap<String, McpServerConfig>, String> {
787 let file: StandardMcpFile =
788 serde_json::from_str(text).map_err(|error| format!("failed to parse mcp.json: {error}"))?;
789 let mut servers = HashMap::with_capacity(file.servers.len());
790 for (id, server) in file.servers {
791 servers.insert(id.clone(), standard_server_to_config(&id, server)?);
792 }
793 Ok(servers)
794 }
795
796 fn standard_server_to_config(
797 id: &str,
798 server: StandardMcpServer,
799 ) -> Result<McpServerConfig, String> {
800 let extension = codewhale_mcp_extension(&server.extensions)?;
801 let transport = resolve_transport(id, &server)?;
802 let (command, url, sse) = match transport {
803 StandardTransport::Stdio => {
804 let command = server.command.clone().ok_or_else(|| {
805 format!("mcp.json server `{id}` declares stdio transport without a command")
806 })?;
807 if server.url.is_some() || !server.headers.is_empty() {
808 return Err(format!(
809 "mcp.json stdio server `{id}` may not declare url or headers"
810 ));
811 }
812 for key in server.env.keys() {
813 if RESERVED_MCP_ENV_NAMES.contains(&key.as_str()) {
814 return Err(format!(
815 "mcp.json server `{id}` env may not define reserved name `{key}`"
816 ));
817 }
818 }
819 (Some(command), None, false)
820 }
821 StandardTransport::StreamableHttp | StandardTransport::Sse => {
822 let url = server.url.clone().ok_or_else(|| {
823 format!("mcp.json server `{id}` declares an HTTP transport without a url")
824 })?;
825 if server.command.is_some()
826 || !server.args.is_empty()
827 || !server.env.is_empty()
828 || server.cwd.is_some()
829 {
830 return Err(format!(
831 "mcp.json HTTP server `{id}` may not declare command, args, env, or cwd"
832 ));
833 }
834 (None, Some(url), transport == StandardTransport::Sse)
835 }
836 };
837 Ok(McpServerConfig {
838 command,
839 args: server.args,
840 env: server.env.into_iter().collect(),
841 cwd: server.cwd.map(PathBuf::from),
842 url,
843 transport: sse.then(|| "sse".to_string()),
844 connect_timeout: extension.connect_timeout,
845 execute_timeout: extension.execute_timeout,
846 read_timeout: extension.read_timeout,
847 disabled: extension.disabled.unwrap_or(false),
848 enabled: extension.enabled.unwrap_or(true),
849 required: extension.required.unwrap_or(false),
850 enabled_tools: extension.enabled_tools,
851 disabled_tools: extension.disabled_tools,
852 headers: server.headers.into_iter().collect(),
853 env_headers: extension.env_headers.into_iter().collect(),
854 bearer_token_env_var: extension.bearer_token_env_var,
855 scopes: extension.scopes,
856 oauth: extension.oauth,
857 oauth_resource: extension.oauth_resource,
858 reviewed_plugin: None,
859 runtime_added: false,
860 allow_private_network: false,
861 })
862 }
863
864 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
865 enum StandardTransport {
866 Stdio,
867 StreamableHttp,
868 Sse,
869 }
870
871 fn resolve_transport(id: &str, server: &StandardMcpServer) -> Result<StandardTransport, String> {
872 match server.transport.as_deref() {
873 Some(declared) => match declared.to_ascii_lowercase().as_str() {
874 "stdio" => Ok(StandardTransport::Stdio),
875 "streamable-http" => Ok(StandardTransport::StreamableHttp),
876 "sse" => Ok(StandardTransport::Sse),
877 _ => Err(format!(
878 "mcp.json server `{id}` has unknown type `{declared}`; expected `stdio`, `streamable-http`, or `sse`"
879 )),
880 },
881 None => match (server.command.is_some(), server.url.is_some()) {
882 (true, false) => Ok(StandardTransport::Stdio),
883 (false, true) => Ok(StandardTransport::StreamableHttp),
884 _ => Err(format!(
885 "mcp.json server `{id}` must declare exactly one of command or url, or set an explicit type"
886 )),
887 },
888 }
889 }
890
891 fn codewhale_mcp_extension(
892 extensions: &BTreeMap<String, serde_json::Value>,
893 ) -> Result<CodewhaleMcpExtension, String> {
894 match extensions.get(CODEWHALE_NAMESPACE) {
895 Some(value) => serde_json::from_value(value.clone()).map_err(|error| {
896 format!("mcp.json server extensions[\"{CODEWHALE_NAMESPACE}\"] is invalid: {error}")
897 }),
898 None => Ok(CodewhaleMcpExtension::default()),
899 }
900 }
901
902 /// Convert a parsed `plugin.json` (+ its sibling `mcp.json` servers, when the
903 /// file exists) into the internal manifest. `root` is the bundle directory:
904 /// the standard fixes the skills layout at `skills/`, so a bundle with no
905 /// Codewhale extension gets the default skills spec exactly when that
906 /// directory exists.
907 pub fn standard_to_manifest(
908 standard: StandardPluginManifest,
909 mcp_servers: Option<HashMap<String, McpServerConfig>>,
910 root: &Path,
911 ) -> Result<PluginManifest, String> {
912 let extension = match standard.extensions.get(CODEWHALE_NAMESPACE) {
913 Some(value) => {
914 serde_json::from_value::<CodewhalePluginExtension>(value.clone()).map_err(|error| {
915 format!("plugin.json extensions[\"{CODEWHALE_NAMESPACE}\"] is invalid: {error}")
916 })?
917 }
918 None => CodewhalePluginExtension::default(),
919 };
920 let skills = extension.skills.or_else(|| {
921 root.join("skills").is_dir().then(|| PluginPathSpec {
922 path: Some("skills".to_string()),
923 paths: Vec::new(),
924 })
925 });
926 Ok(PluginManifest {
927 schema_version: CURRENT_SCHEMA_VERSION,
928 plugin: PluginMeta {
929 name: standard.name,
930 description: standard.description,
931 version: standard.version.unwrap_or_else(|| "0.0.0".to_string()),
932 author: standard.author.map(compose_author).transpose()?,
933 homepage: standard.homepage,
934 repository: standard.repository,
935 license: standard.license,
936 keywords: standard.keywords,
937 display_name: extension.display_name,
938 icon: extension.icon,
939 },
940 skills,
941 commands: extension.commands,
942 agents: extension.agents,
943 hooks: extension.hooks,
944 lsp: extension.lsp,
945 native: extension.native,
946 mcp_servers,
947 capabilities: extension.capabilities.unwrap_or_default(),
948 when: extension.when,
949 })
950 }
951
952 /// `author: {name, email, url}` flattens onto the internal free-form author
953 /// string (`name <email> (url)`). The inverse mapping keeps the whole string
954 /// in `author.name`, so a Codewhale-origin author round-trips verbatim.
955 fn compose_author(author: StandardAuthor) -> Result<String, String> {
956 let name = author.name.trim();
957 if name.is_empty() {
958 return Err("plugin.json author.name must be a non-empty string".to_string());
959 }
960 let mut composed = name.to_string();
961 if let Some(email) = author
962 .email
963 .as_deref()
964 .filter(|email| !email.trim().is_empty())
965 {
966 composed.push_str(" <");
967 composed.push_str(email.trim());
968 composed.push('>');
969 }
970 if let Some(url) = author.url.as_deref().filter(|url| !url.trim().is_empty()) {
971 composed.push_str(" (");
972 composed.push_str(url.trim());
973 composed.push(')');
974 }
975 Ok(composed)
976 }
977
978 // ─────────────────────────────────────────────────────────────────────────────
979 // Publish: internal manifest -> standard documents
980 // ─────────────────────────────────────────────────────────────────────────────
981
982 /// The spec-valid documents for one plugin, ready to serialize and write.
983 #[derive(Debug, Clone)]
984 pub struct StandardEmission {
985 pub plugin_json: StandardPluginManifest,
986 /// Present exactly when the plugin declares MCP servers.
987 pub mcp_json: Option<StandardMcpFile>,
988 /// The conforming name the bundle is published under.
989 pub exported_name: String,
990 /// The original name, preserved when publishing required slugification (or
991 /// when an imported bundle already carried one).
992 pub display_name: Option<String>,
993 }
994
995 /// Convert an internal manifest into spec-valid documents.
996 ///
997 /// A name that is invalid under the standard is slugified and the original is
998 /// preserved as the display name. `existing_names` must hold every other
999 /// plugin name in scope: a slugified name colliding with an existing plugin is
1000 /// an error, never a silent rename.
1001 pub fn manifest_to_standard(
1002 manifest: &PluginManifest,
1003 existing_names: &BTreeSet<String>,
1004 ) -> Result<StandardEmission, String> {
1005 let original = manifest.plugin.name.as_str();
1006 let (name, renamed) = if is_standard_plugin_name(original) {
1007 (original.to_string(), false)
1008 } else {
1009 (slugify_plugin_name(original)?, true)
1010 };
1011 if renamed && existing_names.contains(&name) {
1012 return Err(format!(
1013 "slugifying plugin `{original}` yields `{name}`, which collides with an existing plugin; rename one of them first"
1014 ));
1015 }
1016 let display_name = if renamed {
1017 Some(original.to_string())
1018 } else {
1019 manifest.plugin.display_name.clone()
1020 };
1021
1022 let extension = CodewhalePluginExtension {
1023 display_name: display_name.clone(),
1024 icon: manifest.plugin.icon.clone(),
1025 skills: manifest
1026 .skills
1027 .clone()
1028 .filter(|spec| !is_default_skills_spec(spec)),
1029 commands: manifest.commands.clone(),
1030 agents: manifest.agents.clone(),
1031 hooks: manifest.hooks.clone(),
1032 lsp: manifest.lsp.clone(),
1033 native: manifest.native.clone(),
1034 capabilities: (!capabilities_are_default(&manifest.capabilities))
1035 .then(|| manifest.capabilities.clone()),
1036 when: manifest.when.clone(),
1037 };
1038
1039 let plugin_json = StandardPluginManifest {
1040 schema: PLUGIN_SCHEMA_URL.to_string(),
1041 name: name.clone(),
1042 version: Some(manifest.plugin.version.clone()).filter(|version| !version.trim().is_empty()),
1043 description: manifest.plugin.description.clone(),
1044 author: manifest.plugin.author.clone().map(|author| StandardAuthor {
1045 name: author,
1046 email: None,
1047 url: None,
1048 }),
1049 homepage: manifest.plugin.homepage.clone(),
1050 repository: manifest.plugin.repository.clone(),
1051 license: manifest.plugin.license.clone(),
1052 keywords: manifest.plugin.keywords.clone(),
1053 extensions: if extension.is_empty() {
1054 BTreeMap::new()
1055 } else {
1056 let value = serde_json::to_value(&extension)
1057 .map_err(|error| format!("failed to encode Codewhale extension: {error}"))?;
1058 BTreeMap::from([(CODEWHALE_NAMESPACE.to_string(), value)])
1059 },
1060 };
1061
1062 let mcp_json = match &manifest.mcp_servers {
1063 Some(servers) if !servers.is_empty() => {
1064 let mut standard_servers = BTreeMap::new();
1065 for (id, config) in servers {
1066 standard_servers.insert(id.clone(), config_to_standard_server(id, config)?);
1067 }
1068 Some(StandardMcpFile {
1069 schema: Some(MCP_SCHEMA_URL.to_string()),
1070 servers: standard_servers,
1071 })
1072 }
1073 _ => None,
1074 };
1075
1076 Ok(StandardEmission {
1077 plugin_json,
1078 mcp_json,
1079 exported_name: name,
1080 display_name,
1081 })
1082 }
1083
1084 /// The standard fixes the skills tree at `skills/`; this spec (or its absence)
1085 /// needs no extension entry.
1086 #[must_use]
1087 pub fn is_default_skills_spec(spec: &PluginPathSpec) -> bool {
1088 let mut declared = spec.path.iter().chain(spec.paths.iter());
1089 declared.next().is_some_and(|path| path == "skills") && declared.next().is_none()
1090 }
1091
1092 fn capabilities_are_default(capabilities: &PluginCapabilities) -> bool {
1093 capabilities.filesystem_roots.is_empty()
1094 && capabilities.network_hosts.is_empty()
1095 && !capabilities.lifecycle_mutation
1096 }
1097
1098 fn config_to_standard_server(
1099 id: &str,
1100 config: &McpServerConfig,
1101 ) -> Result<StandardMcpServer, String> {
1102 let extension = CodewhaleMcpExtension {
1103 env_headers: config
1104 .env_headers
1105 .iter()
1106 .map(|(k, v)| (k.clone(), v.clone()))
1107 .collect(),
1108 bearer_token_env_var: config.bearer_token_env_var.clone(),
1109 connect_timeout: config.connect_timeout,
1110 execute_timeout: config.execute_timeout,
1111 read_timeout: config.read_timeout,
1112 enabled: (!config.enabled).then_some(false),
1113 disabled: config.disabled.then_some(true),
1114 required: config.required.then_some(true),
1115 enabled_tools: config.enabled_tools.clone(),
1116 disabled_tools: config.disabled_tools.clone(),
1117 scopes: config.scopes.clone(),
1118 oauth: config.oauth.clone(),
1119 oauth_resource: config.oauth_resource.clone(),
1120 };
1121 let extensions = if extension.is_empty() {
1122 BTreeMap::new()
1123 } else {
1124 let value = serde_json::to_value(&extension)
1125 .map_err(|error| format!("failed to encode Codewhale MCP extension: {error}"))?;
1126 BTreeMap::from([(CODEWHALE_NAMESPACE.to_string(), value)])
1127 };
1128 match (config.command.as_deref(), config.url.as_deref()) {
1129 (Some(command), None) => {
1130 for key in config.env.keys() {
1131 if RESERVED_MCP_ENV_NAMES.contains(&key.as_str()) {
1132 return Err(format!(
1133 "MCP server `{id}` env may not define reserved name `{key}`"
1134 ));
1135 }
1136 }
1137 Ok(StandardMcpServer {
1138 transport: Some("stdio".to_string()),
1139 command: Some(command.to_string()),
1140 args: config.args.clone(),
1141 env: config
1142 .env
1143 .iter()
1144 .map(|(k, v)| (k.clone(), v.clone()))
1145 .collect(),
1146 cwd: config
1147 .cwd
1148 .as_ref()
1149 .map(|cwd| cwd.to_string_lossy().into_owned()),
1150 url: None,
1151 headers: BTreeMap::new(),
1152 extensions,
1153 })
1154 }
1155 (None, Some(url)) => {
1156 let sse = config
1157 .transport
1158 .as_deref()
1159 .is_some_and(|transport| transport.eq_ignore_ascii_case("sse"));
1160 Ok(StandardMcpServer {
1161 transport: Some(if sse { "sse" } else { "streamable-http" }.to_string()),
1162 command: None,
1163 args: Vec::new(),
1164 env: BTreeMap::new(),
1165 cwd: None,
1166 url: Some(url.to_string()),
1167 headers: config
1168 .headers
1169 .iter()
1170 .map(|(k, v)| (k.clone(), v.clone()))
1171 .collect(),
1172 extensions,
1173 })
1174 }
1175 _ => Err(format!(
1176 "MCP server `{id}` must declare exactly one of command or url to export"
1177 )),
1178 }
1179 }
1180
1181 // ─────────────────────────────────────────────────────────────────────────────
1182 // Emission conformance checks
1183 // ─────────────────────────────────────────────────────────────────────────────
1184 //
1185 // Every document Codewhale writes is re-validated against the standard's shape
1186 // before it hits disk; the same functions back the schema-conformance tests.
1187
1188 /// Validate a serialized `plugin.json` value against the standard's shape:
1189 /// closed root, required `$schema`/`name`, typed optional fields, and the name
1190 /// rule.
1191 pub fn validate_plugin_json(value: &serde_json::Value) -> Result<(), String> {
1192 const ROOT_KEYS: [&str; 10] = [
1193 "$schema",
1194 "name",
1195 "version",
1196 "description",
1197 "author",
1198 "homepage",
1199 "repository",
1200 "license",
1201 "keywords",
1202 "extensions",
1203 ];
1204 let object = value
1205 .as_object()
1206 .ok_or_else(|| "plugin.json must be a JSON object".to_string())?;
1207 for key in object.keys() {
1208 if !ROOT_KEYS.contains(&key.as_str()) {
1209 return Err(format!(
1210 "plugin.json root key `{key}` is not in the closed schema"
1211 ));
1212 }
1213 }
1214 let schema = required_string(object, "$schema")?;
1215 if schema.trim().is_empty() {
1216 return Err("plugin.json `$schema` must be a non-empty string".to_string());
1217 }
1218 let name = required_string(object, "name")?;
1219 if !is_standard_plugin_name(name) {
1220 return Err(format!(
1221 "plugin.json name `{name}` violates the standard's name rule"
1222 ));
1223 }
1224 for key in [
1225 "version",
1226 "description",
1227 "homepage",
1228 "repository",
1229 "license",
1230 ] {
1231 optional_string(object, key)?;
1232 }
1233 if let Some(author) = object.get("author") {
1234 let author = author
1235 .as_object()
1236 .ok_or_else(|| "plugin.json author must be an object".to_string())?;
1237 for key in author.keys() {
1238 if !["name", "email", "url"].contains(&key.as_str()) {
1239 return Err(format!(
1240 "plugin.json author key `{key}` is not in the schema"
1241 ));
1242 }
1243 }
1244 required_string(author, "name")?;
1245 optional_string(author, "email")?;
1246 optional_string(author, "url")?;
1247 }
1248 if let Some(keywords) = object.get("keywords") {
1249 let keywords = keywords
1250 .as_array()
1251 .ok_or_else(|| "plugin.json keywords must be an array of strings".to_string())?;
1252 if keywords.iter().any(|keyword| !keyword.is_string()) {
1253 return Err("plugin.json keywords must be an array of strings".to_string());
1254 }
1255 }
1256 if let Some(extensions) = object.get("extensions")
1257 && !extensions.is_object()
1258 {
1259 return Err("plugin.json extensions must be an object keyed by namespace".to_string());
1260 }
1261 Ok(())
1262 }
1263
1264 /// Validate a serialized `mcp.json` value against the standard's shape:
1265 /// `$schema` + `mcpServers`, the per-transport field sets, and the reserved
1266 /// `env` names.
1267 pub fn validate_mcp_json(value: &serde_json::Value) -> Result<(), String> {
1268 let object = value
1269 .as_object()
1270 .ok_or_else(|| "mcp.json must be a JSON object".to_string())?;
1271 for key in object.keys() {
1272 if !["$schema", "mcpServers"].contains(&key.as_str()) {
1273 return Err(format!(
1274 "mcp.json root key `{key}` is not in the closed schema"
1275 ));
1276 }
1277 }
1278 if let Some(schema) = object.get("$schema")
1279 && !schema.is_string()
1280 {
1281 return Err("mcp.json `$schema` must be a string".to_string());
1282 }
1283 let servers = object
1284 .get("mcpServers")
1285 .and_then(serde_json::Value::as_object)
1286 .ok_or_else(|| "mcp.json must carry an `mcpServers` object".to_string())?;
1287 for (id, server) in servers {
1288 validate_mcp_server_json(id, server)?;
1289 }
1290 Ok(())
1291 }
1292
1293 fn validate_mcp_server_json(id: &str, server: &serde_json::Value) -> Result<(), String> {
1294 const SERVER_KEYS: [&str; 8] = [
1295 "type",
1296 "command",
1297 "args",
1298 "env",
1299 "cwd",
1300 "url",
1301 "headers",
1302 "extensions",
1303 ];
1304 let object = server
1305 .as_object()
1306 .ok_or_else(|| format!("mcp.json server `{id}` must be an object"))?;
1307 for key in object.keys() {
1308 if !SERVER_KEYS.contains(&key.as_str()) {
1309 return Err(format!(
1310 "mcp.json server `{id}` key `{key}` is not in the closed schema"
1311 ));
1312 }
1313 }
1314 let transport = match object.get("type") {
1315 Some(value) => {
1316 let declared = value
1317 .as_str()
1318 .ok_or_else(|| format!("mcp.json server `{id}` type must be a string"))?;
1319 if !["stdio", "streamable-http", "sse"].contains(&declared) {
1320 return Err(format!(
1321 "mcp.json server `{id}` has unknown type `{declared}`"
1322 ));
1323 }
1324 Some(declared)
1325 }
1326 None => None,
1327 };
1328 for key in ["command", "cwd", "url"] {
1329 optional_string(object, key).map_err(|error| format!("mcp.json server `{id}`: {error}"))?;
1330 }
1331 for key in ["args"] {
1332 if let Some(values) = object.get(key) {
1333 let values = values.as_array().ok_or_else(|| {
1334 format!("mcp.json server `{id}` {key} must be an array of strings")
1335 })?;
1336 if values.iter().any(|value| !value.is_string()) {
1337 return Err(format!(
1338 "mcp.json server `{id}` {key} must be an array of strings"
1339 ));
1340 }
1341 }
1342 }
1343 for key in ["env", "headers"] {
1344 if let Some(values) = object.get(key) {
1345 let values = values
1346 .as_object()
1347 .ok_or_else(|| format!("mcp.json server `{id}` {key} must be a string map"))?;
1348 if values.values().any(|value| !value.is_string()) {
1349 return Err(format!("mcp.json server `{id}` {key} must be a string map"));
1350 }
1351 }
1352 }
1353 if let Some(env) = object.get("env").and_then(serde_json::Value::as_object) {
1354 for key in env.keys() {
1355 if RESERVED_MCP_ENV_NAMES.contains(&key.as_str()) {
1356 return Err(format!(
1357 "mcp.json server `{id}` env may not define reserved name `{key}`"
1358 ));
1359 }
1360 }
1361 }
1362 if let Some(extensions) = object.get("extensions")
1363 && !extensions.is_object()
1364 {
1365 return Err(format!(
1366 "mcp.json server `{id}` extensions must be an object"
1367 ));
1368 }
1369 let has_command = object.contains_key("command");
1370 let has_url = object.contains_key("url");
1371 let stdio_fields_empty = !has_command
1372 && !object.contains_key("args")
1373 && !object.contains_key("env")
1374 && !object.contains_key("cwd");
1375 let inferred = match transport {
1376 Some(declared) => declared,
1377 None if has_command && !has_url => "stdio",
1378 None if has_url && !has_command => "streamable-http",
1379 None => {
1380 return Err(format!(
1381 "mcp.json server `{id}` must declare exactly one of command or url, or set an explicit type"
1382 ));
1383 }
1384 };
1385 match inferred {
1386 "stdio" => {
1387 if !has_command {
1388 return Err(format!(
1389 "mcp.json stdio server `{id}` must declare a command"
1390 ));
1391 }
1392 if has_url || object.contains_key("headers") {
1393 return Err(format!(
1394 "mcp.json stdio server `{id}` may not declare url or headers"
1395 ));
1396 }
1397 }
1398 _ => {
1399 if !has_url {
1400 return Err(format!("mcp.json HTTP server `{id}` must declare a url"));
1401 }
1402 if !stdio_fields_empty {
1403 return Err(format!(
1404 "mcp.json HTTP server `{id}` may not declare command, args, env, or cwd"
1405 ));
1406 }
1407 }
1408 }
1409 Ok(())
1410 }
1411
1412 fn required_string<'a>(
1413 object: &'a serde_json::Map<String, serde_json::Value>,
1414 key: &str,
1415 ) -> Result<&'a str, String> {
1416 object
1417 .get(key)
1418 .and_then(serde_json::Value::as_str)
1419 .ok_or_else(|| format!("plugin.json requires a `{key}` string"))
1420 }
1421
1422 fn optional_string(
1423 object: &serde_json::Map<String, serde_json::Value>,
1424 key: &str,
1425 ) -> Result<(), String> {
1426 match object.get(key) {
1427 Some(value) if !value.is_string() => Err(format!("`{key}` must be a string")),
1428 _ => Ok(()),
1429 }
1430 }
1431
1432 #[cfg(test)]
1433 mod tests {
1434 use super::*;
1435
1436 /// A full-featured legacy manifest: every Codewhale-specific field the
1437 /// standard does not define, plus both MCP transports.
1438 const ROUND_TRIP_TOML: &str = r#"schema_version = 1
1439
1440 [plugin]
1441 name = "round-trip"
1442 version = "1.2.3"
1443 description = "Round-trip fixture"
1444 author = "Jane Doe"
1445 homepage = "https://example.com/round-trip"
1446 repository = "https://github.com/example/round-trip"
1447 license = "MIT"
1448 keywords = ["one", "two"]
1449
1450 [skills]
1451 path = "skills"
1452
1453 [commands]
1454 path = "commands"
1455
1456 [capabilities]
1457 filesystem_roots = ["src"]
1458 network_hosts = ["example.com"]
1459
1460 [when]
1461 os = ["macos", "linux"]
1462
1463 [mcp_servers.local]
1464 command = "run.sh"
1465 args = ["--port", "8080"]
1466 env = { API_KEY = "${ROUND_TRIP_API_KEY}" }
1467 cwd = "bin"
1468
1469 [mcp_servers.remote]
1470 url = "https://example.com/mcp"
1471 transport = "sse"
1472 connect_timeout = 30
1473 required = true
1474 enabled_tools = ["search"]
1475 env_headers = { Authorization = "ROUND_TRIP_REMOTE_TOKEN" }
1476 "#;
1477
1478 #[test]
1479 fn standard_name_rule_matches_the_spec_regex() {
1480 let long = "a".repeat(MAX_PLUGIN_NAME_CHARS);
1481 for valid in [
1482 "a",
1483 "0",
1484 "ab",
1485 "a-b",
1486 "a.b",
1487 "a-b.c-d",
1488 "a.-b",
1489 "acme.tools",
1490 long.as_str(),
1491 ] {
1492 assert!(is_standard_plugin_name(valid), "{valid}");
1493 }
1494 let too_long = "a".repeat(MAX_PLUGIN_NAME_CHARS + 1);
1495 for invalid in [
1496 "",
1497 "A",
1498 "a_b",
1499 "a b",
1500 "-a",
1501 "a-",
1502 ".a",
1503 "a.",
1504 "a--b",
1505 "a..b",
1506 "café",
1507 too_long.as_str(),
1508 ] {
1509 assert!(!is_standard_plugin_name(invalid), "{invalid}");
1510 }
1511 }
1512
1513 #[test]
1514 fn slugify_maps_legacy_names_onto_the_standard_rule() {
1515 let cases = [
1516 ("round-trip", "round-trip"),
1517 ("acme.tools", "acme.tools"),
1518 ("My_Plugin Name", "my-plugin-name"),
1519 ("Foo--Bar", "foo-bar"),
1520 ("a..b", "a.b"),
1521 ("--weird--", "weird"),
1522 ("UPPER_SNAKE Case.v2", "upper-snake-case.v2"),
1523 (" padded ", "padded"),
1524 ("dots...here", "dots.here"),
1525 ("mixed._-seps", "mixed-seps"),
1526 ];
1527 for (input, expected) in cases {
1528 let slug = slugify_plugin_name(input).unwrap();
1529 assert_eq!(slug, expected, "input: {input}");
1530 assert!(is_standard_plugin_name(&slug), "slug for {input}");
1531 }
1532
1533 let long = format!("{}-suffix", "a".repeat(70));
1534 let slug = slugify_plugin_name(&long).unwrap();
1535 assert!(slug.chars().count() <= MAX_PLUGIN_NAME_CHARS);
1536 assert!(is_standard_plugin_name(&slug));
1537 let boundary = format!("{}-{}", "a".repeat(63), "b".repeat(10));
1538 assert_eq!(slugify_plugin_name(&boundary).unwrap(), "a".repeat(63));
1539
1540 for unusable in ["___", "...", "", "插件"] {
1541 assert!(slugify_plugin_name(unusable).is_err(), "{unusable}");
1542 }
1543 }
1544
1545 #[test]
1546 fn toml_to_standard_to_internal_round_trip_is_stable() {
1547 let internal: PluginManifest = toml::from_str(ROUND_TRIP_TOML).unwrap();
1548 let emission = manifest_to_standard(&internal, &BTreeSet::new()).unwrap();
1549 assert_eq!(emission.exported_name, "round-trip");
1550 assert_eq!(emission.display_name, None);
1551
1552 // Every emitted document conforms to the standard's shape.
1553 let plugin_value = serde_json::to_value(&emission.plugin_json).unwrap();
1554 validate_plugin_json(&plugin_value).unwrap();
1555 let mcp_value = serde_json::to_value(emission.mcp_json.as_ref().unwrap()).unwrap();
1556 validate_mcp_json(&mcp_value).unwrap();
1557
1558 // The consume side derives the default skills spec from the standard
1559 // skills/ directory on disk.
1560 let tmp = tempfile::tempdir().unwrap();
1561 std::fs::create_dir(tmp.path().join("skills")).unwrap();
1562
1563 let plugin_text = serde_json::to_string_pretty(&plugin_value).unwrap();
1564 let standard = parse_plugin_json(&plugin_text).unwrap();
1565 let mcp_text = serde_json::to_string_pretty(&mcp_value).unwrap();
1566 let servers = parse_mcp_json(&mcp_text).unwrap();
1567 let restored = standard_to_manifest(standard, Some(servers), tmp.path()).unwrap();
1568
1569 assert_eq!(
1570 serde_json::to_value(&internal).unwrap(),
1571 serde_json::to_value(&restored).unwrap(),
1572 "plugin.toml -> internal -> plugin.json + mcp.json -> internal drifted"
1573 );
1574 }
1575
1576 #[test]
1577 fn emitted_documents_carry_only_standard_root_keys() {
1578 let internal: PluginManifest = toml::from_str(ROUND_TRIP_TOML).unwrap();
1579 let emission = manifest_to_standard(&internal, &BTreeSet::new()).unwrap();
1580 let plugin_value = serde_json::to_value(&emission.plugin_json).unwrap();
1581 // mcp_servers never leak into the closed plugin.json root; Codewhale
1582 // data is namespaced.
1583 assert!(plugin_value.get("mcp_servers").is_none());
1584 assert!(plugin_value.get("mcpServers").is_none());
1585 assert!(
1586 plugin_value["extensions"]
1587 .get(CODEWHALE_NAMESPACE)
1588 .is_some()
1589 );
1590 let mcp_value = serde_json::to_value(emission.mcp_json.as_ref().unwrap()).unwrap();
1591 assert_eq!(mcp_value["$schema"], serde_json::json!(MCP_SCHEMA_URL));
1592 assert_eq!(mcp_value["mcpServers"]["local"]["type"], "stdio");
1593 assert_eq!(mcp_value["mcpServers"]["remote"]["type"], "sse");
1594 assert_eq!(
1595 mcp_value["mcpServers"]["remote"]["extensions"][CODEWHALE_NAMESPACE]["connect_timeout"],
1596 serde_json::json!(30)
1597 );
1598 }
1599
1600 #[test]
1601 fn slugified_export_preserves_display_name_and_collision_is_an_error() {
1602 let toml = "schema_version = 1\n[plugin]\nname = \"Foo--Bar\"\nversion = \"1.0.0\"\n";
1603 let internal: PluginManifest = toml::from_str(toml).unwrap();
1604
1605 let emission = manifest_to_standard(&internal, &BTreeSet::new()).unwrap();
1606 assert_eq!(emission.exported_name, "foo-bar");
1607 assert_eq!(emission.display_name.as_deref(), Some("Foo--Bar"));
1608 let extension = emission.plugin_json.extensions[CODEWHALE_NAMESPACE].clone();
1609 assert_eq!(extension["display_name"], serde_json::json!("Foo--Bar"));
1610 validate_plugin_json(&serde_json::to_value(&emission.plugin_json).unwrap()).unwrap();
1611
1612 let colliding = BTreeSet::from(["foo-bar".to_string()]);
1613 let error = manifest_to_standard(&internal, &colliding).unwrap_err();
1614 assert!(error.contains("collides"), "{error}");
1615 }
1616
1617 #[test]
1618 fn unknown_extensions_namespaces_are_ignored_not_rejected() {
1619 let text = r#"{
1620 "$schema": "https://agent-plugins.org/schemas/plugin.json",
1621 "name": "acme.tools",
1622 "version": "2.0.0",
1623 "author": {"name": "Acme", "email": "plugins@acme.example", "url": "https://acme.example"},
1624 "extensions": {
1625 "com.example.client": {"anything": [1, 2, 3], "nested": {"x": true}},
1626 "net.codewhale": {"when": {"os": ["macos"]}}
1627 }
1628 }"#;
1629 let standard = parse_plugin_json(text).unwrap();
1630 let tmp = tempfile::tempdir().unwrap();
1631 let manifest = standard_to_manifest(standard, None, tmp.path()).unwrap();
1632 assert_eq!(manifest.plugin.name, "acme.tools");
1633 assert_eq!(
1634 manifest.plugin.author.as_deref(),
1635 Some("Acme <plugins@acme.example> (https://acme.example)")
1636 );
1637 assert_eq!(
1638 manifest.when.and_then(|when| when.os),
1639 Some(vec!["macos".to_string()])
1640 );
1641 assert!(
1642 manifest.skills.is_none(),
1643 "no skills/ directory means no skills spec"
1644 );
1645
1646 // Unknown keys inside our own namespace are rejected.
1647 let bad_extension = r#"{
1648 "$schema": "https://agent-plugins.org/schemas/plugin.json",
1649 "name": "acme.tools",
1650 "extensions": {"net.codewhale": {"surprise": true}}
1651 }"#;
1652 let standard = parse_plugin_json(bad_extension).unwrap();
1653 assert!(standard_to_manifest(standard, None, tmp.path()).is_err());
1654
1655 // Unknown root keys are rejected: the standard's root is closed.
1656 let bad_root = r#"{"$schema": "x", "name": "acme.tools", "mcpServers": {}}"#;
1657 assert!(parse_plugin_json(bad_root).is_err());
1658 }
1659
1660 #[test]
1661 fn plugin_json_requires_schema_and_a_standard_name() {
1662 let missing_schema = r#"{"name": "ok"}"#;
1663 assert!(parse_plugin_json(missing_schema).is_err());
1664 let bad_name = r#"{"$schema": "x", "name": "Not--Valid"}"#;
1665 assert!(parse_plugin_json(bad_name).is_err());
1666 }
1667
1668 #[test]
1669 fn mcp_json_transports_and_reserved_env_names() {
1670 let reserved =
1671 r#"{"mcpServers": {"x": {"command": "run", "env": {"PLUGIN_ROOT": "/tmp"}}}}"#;
1672 let error = parse_mcp_json(reserved).unwrap_err();
1673 assert!(error.contains("PLUGIN_ROOT"), "{error}");
1674
1675 let inferred = r#"{"mcpServers": {
1676 "local": {"command": "run.sh", "args": ["-y"]},
1677 "remote": {"url": "https://example.com/mcp"}
1678 }}"#;
1679 let servers = parse_mcp_json(inferred).unwrap();
1680 assert_eq!(servers["local"].command.as_deref(), Some("run.sh"));
1681 assert!(servers["local"].transport.is_none());
1682 assert!(servers["remote"].transport.is_none());
1683
1684 let sse = r#"{"mcpServers": {"r": {"type": "sse", "url": "https://example.com/sse"}}}"#;
1685 assert_eq!(
1686 parse_mcp_json(sse).unwrap()["r"].transport.as_deref(),
1687 Some("sse")
1688 );
1689 for bad in [
1690 r#"{"mcpServers": {"r": {"type": "websocket", "url": "https://example.com/ws"}}}"#,
1691 r#"{"mcpServers": {"r": {"command": "x", "url": "https://example.com"}}}"#,
1692 r#"{"mcpServers": {"r": {"type": "stdio", "command": "x", "url": "https://example.com"}}}"#,
1693 r#"{"mcpServers": {"r": {"url": "https://example.com", "args": ["x"]}}}"#,
1694 r#"{"mcpServers": {"r": {"type": "sse"}}}"#,
1695 ] {
1696 assert!(parse_mcp_json(bad).is_err(), "{bad}");
1697 }
1698
1699 let extended = r#"{"mcpServers": {"r": {"url": "https://example.com/mcp",
1700 "extensions": {"net.codewhale": {"connect_timeout": 30, "required": true,
1701 "env_headers": {"Authorization": "TOKEN_ENV"}}}}}}"#;
1702 let servers = parse_mcp_json(extended).unwrap();
1703 assert_eq!(servers["r"].connect_timeout, Some(30));
1704 assert!(servers["r"].required);
1705 assert_eq!(servers["r"].env_headers["Authorization"], "TOKEN_ENV");
1706 }
1707
1708 #[test]
1709 fn emission_validators_reject_nonconforming_documents() {
1710 let extra_root_key = serde_json::json!({"$schema": "x", "name": "ok", "surprise": 1});
1711 assert!(validate_plugin_json(&extra_root_key).is_err());
1712 let bad_name = serde_json::json!({"$schema": "x", "name": "Not--Valid"});
1713 assert!(validate_plugin_json(&bad_name).is_err());
1714 let nameless_author =
1715 serde_json::json!({"$schema": "x", "name": "ok", "author": {"email": "a@b.c"}});
1716 assert!(validate_plugin_json(&nameless_author).is_err());
1717
1718 let reserved_env = serde_json::json!({"mcpServers": {"x": {"command": "run", "env": {"PLUGIN_DATA": "y"}}}});
1719 assert!(validate_mcp_json(&reserved_env).is_err());
1720 let extra_root = serde_json::json!({"mcpServers": {}, "extra": true});
1721 assert!(validate_mcp_json(&extra_root).is_err());
1722 let cross_transport = serde_json::json!({"mcpServers": {"x": {"type": "sse", "url": "https://e.c", "cwd": "bin"}}});
1723 assert!(validate_mcp_json(&cross_transport).is_err());
1724
1725 let conforming = serde_json::json!({
1726 "$schema": MCP_SCHEMA_URL,
1727 "mcpServers": {"x": {"type": "stdio", "command": "run", "env": {"A": "${B}"}}}
1728 });
1729 validate_mcp_json(&conforming).unwrap();
1730 }
1731 }
1732
1732 lines RUST