use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub const AGENT_PLUGINS_V1_MANIFEST_SCHEMA: &str =
"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
pub const AGENT_PLUGINS_V1_MCP_SCHEMA: &str =
"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
pub const AGENT_PLUGINS_V1_MANIFEST_SCHEMA_JSON: &str =
include_str!("schemas/1.0.0/plugin.schema.json");
pub const AGENT_PLUGINS_V1_MCP_SCHEMA_JSON: &str = include_str!("schemas/1.0.0/mcp.schema.json");
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PluginAuthor {
#[serde(default)]
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum McpServersField {
Path(String),
Paths(Vec<String>),
Inline(HashMap<String, serde_json::Value>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StringOrArray {
Single(String),
Multiple(Vec<String>),
}
impl StringOrArray {
pub fn to_vec(&self) -> Vec<String> {
match self {
StringOrArray::Single(s) => vec![s.clone()],
StringOrArray::Multiple(v) => v.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManifest {
#[serde(rename = "$schema", default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<PluginAuthor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keywords: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub extensions: HashMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skills: Option<StringOrArray>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commands: Option<StringOrArray>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agents: Option<StringOrArray>,
#[serde(
rename = "mcpServers",
default,
skip_serializing_if = "Option::is_none"
)]
pub mcp_servers: Option<McpServersField>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl PluginManifest {
pub fn is_agent_plugins_v1(&self) -> bool {
self.schema.as_deref() == Some(AGENT_PLUGINS_V1_MANIFEST_SCHEMA)
}
}
pub fn parse_agent_plugins_v1_manifest(
text: &str,
) -> Result<(PluginManifest, Vec<String>), String> {
let mut value: serde_json::Value = serde_json::from_str(text)
.map_err(|error| format!("failed to parse plugin.json: {error}"))?;
let object = value
.as_object_mut()
.ok_or_else(|| "plugin.json must contain a JSON object".to_string())?;
let schema = object.get("$schema").and_then(serde_json::Value::as_str);
if schema != Some(AGENT_PLUGINS_V1_MANIFEST_SCHEMA) {
return Err(format!(
"unsupported Agent Plugins schema '{}'; supported schema is {AGENT_PLUGINS_V1_MANIFEST_SCHEMA}",
schema.unwrap_or("<missing>")
));
}
let permitted = [
"$schema",
"name",
"version",
"description",
"author",
"homepage",
"repository",
"license",
"keywords",
"extensions",
];
let unknown: Vec<String> = object
.keys()
.filter(|key| !permitted.contains(&key.as_str()))
.cloned()
.collect();
let mut warnings = Vec::new();
for key in unknown {
object.remove(&key);
warnings.push(format!(
"plugin.json: unrecognized field '{key}' was ignored"
));
}
if object
.get("extensions")
.is_some_and(|extensions| !extensions.is_object())
{
object.remove("extensions");
warnings.push("plugin.json: non-object 'extensions' field was ignored".to_string());
}
validate_json_schema(AGENT_PLUGINS_V1_MANIFEST_SCHEMA_JSON, &value, "plugin.json")?;
let manifest = serde_json::from_value(value)
.map_err(|error| format!("failed to parse plugin.json: {error}"))?;
Ok((manifest, warnings))
}
pub(crate) fn validate_json_schema(
schema_json: &str,
value: &serde_json::Value,
label: &str,
) -> Result<(), String> {
let schema: serde_json::Value = serde_json::from_str(schema_json)
.map_err(|error| format!("embedded {label} schema is invalid: {error}"))?;
let validator = jsonschema::draft202012::options()
.build(&schema)
.map_err(|error| format!("embedded {label} schema is invalid: {error}"))?;
let errors: Vec<String> = validator
.iter_errors(value)
.map(|error| {
let path = error.instance_path().to_string();
if path.is_empty() {
error.to_string()
} else {
format!("{path}: {error}")
}
})
.collect();
if errors.is_empty() {
Ok(())
} else {
Err(format!("invalid {label}: {}", errors.join("; ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic_manifest() {
let json = r#"{
"name": "microsoft-docs",
"displayName": "Microsoft Docs",
"version": "0.1.0",
"description": "Search official Microsoft docs.",
"license": "MIT"
}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.name, "microsoft-docs");
assert_eq!(manifest.display_name.as_deref(), Some("Microsoft Docs"));
assert_eq!(manifest.version.as_deref(), Some("0.1.0"));
assert_eq!(manifest.license.as_deref(), Some("MIT"));
assert!(manifest.extra.is_empty());
}
#[test]
fn unrecognized_fields_land_in_extra() {
let json = r#"{
"name": "test-plugin",
"description": "A test plugin.",
"interface": {"displayName": "Test", "category": "Dev"}
}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert!(manifest.extra.contains_key("interface"));
}
#[test]
fn mcp_servers_path_string() {
let json = r#"{"name": "x", "description": "y", "mcpServers": "./.mcp.json"}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert!(matches!(
manifest.mcp_servers,
Some(McpServersField::Path(_))
));
}
#[test]
fn skills_string_or_array() {
let json = r#"{"name": "x", "description": "y", "skills": "./skills/"}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert!(matches!(manifest.skills, Some(StringOrArray::Single(_))));
}
}