Files
Kigi-CLI/crates/codegen/kigi-agent/src/plugins/manifest.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

871 lines
29 KiB
Rust

//! Plugin manifest parsing and validation.
//!
//! The canonical manifest location is `plugin.json` at the plugin root.
//! Fallback locations (checked in order when the root manifest is absent):
//! 1. `.kigi-plugin/plugin.json`
//! 2. `.claude-plugin/plugin.json`
//!
//! If no manifest is found at all, the plugin can still function via
//! convention-based discovery (skills/, agents/, .mcp.json, hooks/hooks.json),
//! with the plugin name derived from the directory name.
//!
//! The parser is forward-compatible: unknown fields are silently ignored
//! so that manifests authored for newer upstream versions still load.
use std::path::{Path, PathBuf};
use serde::Deserialize;
/// Maximum length of a plugin name (kebab-case identifier).
const MAX_PLUGIN_NAME_LEN: usize = 64;
/// Regex pattern for valid plugin names: lowercase alphanumeric + hyphens.
fn is_valid_plugin_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= MAX_PLUGIN_NAME_LEN
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !name.starts_with('-')
&& !name.ends_with('-')
}
/// Author metadata from a plugin manifest.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Author {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub url: Option<String>,
}
/// A path reference that can be either a single path or multiple paths.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum PathOrPaths {
Single(String),
Multiple(Vec<String>),
}
impl PathOrPaths {
/// Resolve all contained paths relative to a plugin root.
///
/// Paths that escape the plugin root (via `..` components) are rejected
/// with a warning and excluded from the result.
pub fn resolve(&self, plugin_root: &Path) -> Vec<PathBuf> {
let paths = match self {
PathOrPaths::Single(p) => vec![plugin_root.join(p)],
PathOrPaths::Multiple(ps) => ps.iter().map(|p| plugin_root.join(p)).collect(),
};
paths
.into_iter()
.filter(|resolved| {
if is_path_contained(resolved, plugin_root) {
true
} else {
tracing::warn!(
path = %resolved.display(),
plugin_root = %plugin_root.display(),
"manifest path escapes plugin root; skipping"
);
false
}
})
.collect()
}
}
/// Check whether a resolved path stays within the plugin root.
///
/// Canonicalizes both sides (resolving symlinks and `..`) before the prefix check.
fn is_path_contained(resolved: &Path, plugin_root: &Path) -> bool {
let canonical_root =
dunce::canonicalize(plugin_root).unwrap_or_else(|_| plugin_root.to_path_buf());
let canonical_resolved =
dunce::canonicalize(resolved).unwrap_or_else(|_| resolved.to_path_buf());
// Fail-closed >MAX_PATH caveat: see workspace clippy.toml.
canonical_resolved.starts_with(&canonical_root)
}
/// Resolve a plugin component path (hooks, MCP, LSP) from a manifest field.
///
/// If the field is `Path(p)`, resolves relative to plugin root with containment check.
/// If `Inline(_)`, returns `None` (caller reads inline value directly).
/// If `None`, checks for `default_file` at the plugin root.
fn resolve_component_path(
field: &Option<PathOrInline>,
plugin_root: &Path,
default_file: &str,
label: &str,
) -> Option<PathBuf> {
match field {
Some(PathOrInline::Path(p)) => {
let resolved = plugin_root.join(p);
if !is_path_contained(&resolved, plugin_root) {
tracing::warn!(
path = %resolved.display(),
plugin_root = %plugin_root.display(),
"{label} path escapes plugin root; skipping"
);
return None;
}
resolved.is_file().then_some(resolved)
}
Some(PathOrInline::Inline(_)) => None,
None => {
let default = plugin_root.join(default_file);
default.is_file().then_some(default)
}
}
}
/// A value that can be either a file path (string) or an inline JSON object.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum PathOrInline {
Path(String),
Inline(serde_json::Value),
}
/// Parsed plugin manifest from `plugin.json`.
///
/// Forward-compatible: unknown fields are silently ignored via
/// `#[serde(deny_unknown_fields)]` NOT being set.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManifest {
/// User-facing plugin namespace (kebab-case). Required.
pub name: String,
/// Semver version string.
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub author: Option<Author>,
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
// Component path overrides (supplement convention dirs)
#[serde(default)]
pub skills: Option<PathOrPaths>,
#[serde(default)]
pub commands: Option<PathOrPaths>,
#[serde(default)]
pub agents: Option<PathOrPaths>,
#[serde(default)]
pub hooks: Option<PathOrInline>,
#[serde(default)]
pub mcp_servers: Option<PathOrInline>,
#[serde(default)]
pub lsp_servers: Option<PathOrInline>,
}
impl PluginManifest {
/// Validate the parsed manifest.
pub fn validate(&self) -> Result<(), ManifestError> {
if !is_valid_plugin_name(&self.name) {
return Err(ManifestError::InvalidName {
name: self.name.clone(),
reason: format!(
"must be 1-{MAX_PLUGIN_NAME_LEN} chars, lowercase alphanumeric + hyphens, \
no leading/trailing hyphens"
),
});
}
Ok(())
}
pub fn skill_dirs(&self, plugin_root: &Path) -> Vec<PathBuf> {
resolve_dirs(&self.skills, plugin_root, "skills")
}
pub fn command_dirs(&self, plugin_root: &Path) -> Vec<PathBuf> {
resolve_dirs(&self.commands, plugin_root, "commands")
}
pub fn agent_dirs(&self, plugin_root: &Path) -> Vec<PathBuf> {
resolve_dirs(&self.agents, plugin_root, "agents")
}
/// Resolve the hooks path from the manifest.
/// Returns the manifest-specified path or the default `hooks/hooks.json`.
pub fn hooks_path(&self, plugin_root: &Path) -> Option<PathBuf> {
resolve_component_path(&self.hooks, plugin_root, "hooks/hooks.json", "hooks")
}
pub fn mcp_config_path(&self, plugin_root: &Path) -> Option<PathBuf> {
if matches!(self.mcp_servers, Some(PathOrInline::Inline(_))) {
let default = plugin_root.join(".mcp.json");
return default.is_file().then_some(default);
}
resolve_component_path(&self.mcp_servers, plugin_root, ".mcp.json", "MCP config")
}
/// Get inline hooks JSON value, if the manifest uses inline hooks.
///
/// Inline hooks are fully supported — the runtime parses and executes them
/// via `parse_plugin_hooks_from_value()`. This accessor is used during
/// `LoadedPlugin` construction and by the hooks adapter.
pub fn inline_hooks(&self) -> Option<&serde_json::Value> {
match &self.hooks {
Some(PathOrInline::Inline(v)) => Some(v),
_ => None,
}
}
/// Get inline MCP servers JSON value, if the manifest uses inline MCP.
///
/// Inline MCP servers are fully supported — the runtime parses and starts
/// them via `load_plugin_mcp_servers_from_value()`. This accessor is used
/// during `LoadedPlugin` construction and by the MCP merger.
pub fn inline_mcp_servers(&self) -> Option<&serde_json::Value> {
match &self.mcp_servers {
Some(PathOrInline::Inline(v)) => Some(v),
_ => None,
}
}
pub fn lsp_config_path(&self, plugin_root: &Path) -> Option<PathBuf> {
resolve_component_path(&self.lsp_servers, plugin_root, ".lsp.json", "LSP config")
}
pub fn inline_lsp_servers(&self) -> Option<&serde_json::Value> {
match &self.lsp_servers {
Some(PathOrInline::Inline(v)) => Some(v),
_ => None,
}
}
/// Log informational messages about manifest features.
///
/// Called during discovery. Inline hooks and MCP servers are
/// fully supported; this method logs when they are detected.
pub fn warn_unsupported_features(&self, plugin_name: &str) {
if self.inline_hooks().is_some() {
tracing::info!(plugin = plugin_name, "plugin uses inline hooks in manifest");
}
if self.inline_mcp_servers().is_some() {
tracing::info!(
plugin = plugin_name,
"plugin uses inline mcpServers in manifest"
);
}
if self.inline_lsp_servers().is_some() {
tracing::info!(
plugin = plugin_name,
"plugin uses inline lspServers in manifest"
);
}
}
}
/// Resolve directories from a manifest field or fall back to a default subdirectory.
fn resolve_dirs(
field: &Option<PathOrPaths>,
plugin_root: &Path,
default_name: &str,
) -> Vec<PathBuf> {
match field {
Some(paths) => paths.resolve(plugin_root),
None => {
let default = plugin_root.join(default_name);
if default.is_dir() {
vec![default]
} else {
vec![]
}
}
}
}
// Manifest loading
/// Manifest search order within a plugin directory.
const MANIFEST_PATHS: &[&str] = &[
"plugin.json",
".kigi-plugin/plugin.json",
".claude-plugin/plugin.json",
];
/// Result of attempting to load a manifest from a plugin directory.
#[derive(Debug)]
pub enum ManifestLoadResult {
/// Manifest found and parsed successfully.
Found(Box<PluginManifest>),
/// No manifest file found — plugin uses convention-based discovery.
NotFound,
}
/// Load a plugin manifest from the given plugin root directory.
///
/// Tries manifest files in priority order (see [`MANIFEST_PATHS`]).
/// If no manifest is found, returns `ManifestLoadResult::NotFound`.
/// The caller can still create a convention-based plugin from the directory.
pub fn load_manifest(plugin_root: &Path) -> Result<ManifestLoadResult, ManifestError> {
for rel_path in MANIFEST_PATHS {
let manifest_path = plugin_root.join(rel_path);
if manifest_path.is_file() {
let content =
std::fs::read_to_string(&manifest_path).map_err(|e| ManifestError::IoError {
path: manifest_path.clone(),
source: e,
})?;
let manifest: PluginManifest =
serde_json::from_str(&content).map_err(|e| ManifestError::ParseError {
path: manifest_path.clone(),
message: e.to_string(),
})?;
manifest.validate()?;
manifest.warn_unsupported_features(&manifest.name);
return Ok(ManifestLoadResult::Found(Box::new(manifest)));
}
}
Ok(ManifestLoadResult::NotFound)
}
/// Derive a plugin name from a directory name.
///
/// Sanitizes the directory name to match the kebab-case constraint:
/// lowercase, alphanumeric + hyphens, no leading/trailing hyphens.
pub fn name_from_dirname(dir: &Path) -> Option<String> {
let dirname = dir.file_name()?.to_str()?;
let sanitized: String = dirname
.to_ascii_lowercase()
.chars()
.map(|c| {
if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' {
c
} else {
'-'
}
})
.collect();
let trimmed = sanitized.trim_matches('-').to_string();
if trimmed.is_empty() || trimmed.len() > MAX_PLUGIN_NAME_LEN {
return None;
}
Some(trimmed)
}
/// Perform plugin-token substitution in a string.
///
/// Replaces `${KIGI_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_ROOT}`,
/// `${KIGI_PLUGIN_DATA}`, and `${CLAUDE_PLUGIN_DATA}` with the provided values.
///
/// Delegates to [`kigi_tools::util::substitute_plugin_tokens`], the single
/// source of truth shared with plugin skill/command body substitution.
pub fn substitute_env_vars(s: &str, plugin_root: &str, plugin_data: &str) -> String {
kigi_tools::util::substitute_plugin_tokens(s, Some(plugin_root), Some(plugin_data))
}
pub fn normalize_inline_mcp_servers(value: &serde_json::Value) -> serde_json::Value {
let inner = match value.get("mcpServers") {
Some(servers) if servers.is_object() => servers.clone(),
_ => value.clone(),
};
serde_json::json!({ "mcpServers": inner })
}
// Errors
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
#[error("invalid plugin name {name:?}: {reason}")]
InvalidName { name: String, reason: String },
#[error("failed to read {path}: {source}")]
IoError {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse {path}: {message}")]
ParseError { path: PathBuf, message: String },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_plugin_names() {
assert!(is_valid_plugin_name("my-plugin"));
assert!(is_valid_plugin_name("a"));
assert!(is_valid_plugin_name("deployment-tools"));
assert!(is_valid_plugin_name("plugin123"));
assert!(is_valid_plugin_name("a-b-c"));
}
#[test]
fn invalid_plugin_names() {
assert!(!is_valid_plugin_name(""));
assert!(!is_valid_plugin_name("-start"));
assert!(!is_valid_plugin_name("end-"));
assert!(!is_valid_plugin_name("UPPER"));
assert!(!is_valid_plugin_name("has space"));
assert!(!is_valid_plugin_name("has_underscore"));
assert!(!is_valid_plugin_name("has.dot"));
assert!(!is_valid_plugin_name(&"a".repeat(65)));
}
#[test]
fn parse_minimal_manifest() {
let json = r#"{"name": "my-plugin"}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.name, "my-plugin");
assert!(manifest.version.is_none());
assert!(manifest.description.is_none());
assert!(manifest.skills.is_none());
manifest.validate().unwrap();
}
#[test]
fn parse_full_manifest() {
let json = r#"{
"name": "deployment-tools",
"version": "1.2.0",
"description": "Tools for deployment",
"author": {"name": "Test", "email": "test@example.com"},
"homepage": "https://example.com",
"repository": "https://github.com/example/plugin",
"license": "MIT",
"keywords": ["ci-cd", "deploy"],
"skills": "./custom/skills/",
"agents": "./custom-agents/",
"hooks": "./config/hooks.json",
"mcpServers": "./mcp-config.json"
}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.name, "deployment-tools");
assert_eq!(manifest.version.as_deref(), Some("1.2.0"));
assert_eq!(manifest.keywords, vec!["ci-cd", "deploy"]);
assert!(matches!(manifest.skills, Some(PathOrPaths::Single(_))));
manifest.validate().unwrap();
}
#[test]
fn parse_manifest_ignores_unknown_fields() {
let json = r#"{
"name": "my-plugin",
"marketplace": true,
"installState": "active",
"futureField": {"nested": "value"},
"outputStyles": "./styles/"
}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.name, "my-plugin");
manifest.validate().unwrap();
}
#[test]
fn parse_manifest_inline_hooks() {
let json = r#"{
"name": "my-plugin",
"hooks": {
"hooks": {
"PostToolUse": [{"hooks": [{"type": "command", "command": "lint"}]}]
}
}
}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert!(manifest.inline_hooks().is_some());
}
#[test]
fn parse_manifest_inline_mcp() {
let json = r#"{
"name": "my-plugin",
"mcpServers": {
"mcpServers": {
"database": {
"command": "./servers/db-server",
"args": ["--config", "./config.json"]
}
}
}
}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert!(manifest.inline_mcp_servers().is_some());
}
#[test]
fn parse_manifest_multiple_skill_paths() {
let json = r#"{
"name": "my-plugin",
"skills": ["./skills-a/", "./skills-b/"]
}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
match manifest.skills.unwrap() {
PathOrPaths::Multiple(paths) => {
assert_eq!(paths.len(), 2);
assert_eq!(paths[0], "./skills-a/");
assert_eq!(paths[1], "./skills-b/");
}
_ => panic!("expected Multiple"),
}
}
#[test]
fn name_from_dirname_basic() {
assert_eq!(
name_from_dirname(Path::new("/home/user/my-plugin")),
Some("my-plugin".to_string())
);
assert_eq!(
name_from_dirname(Path::new("/path/to/MyPlugin")),
Some("myplugin".to_string())
);
assert_eq!(
name_from_dirname(Path::new("/path/to/my_plugin")),
Some("my-plugin".to_string())
);
assert_eq!(
name_from_dirname(Path::new("/path/to/---")),
// all hyphens after trim
None
);
}
#[test]
fn load_manifest_from_tempdir() {
let tmp = tempfile::tempdir().unwrap();
let plugin_root = tmp.path().join("my-plugin");
std::fs::create_dir_all(&plugin_root).unwrap();
// No manifest file
match load_manifest(&plugin_root).unwrap() {
ManifestLoadResult::NotFound => {}
_ => panic!("expected NotFound"),
}
// Write root plugin.json
let manifest_path = plugin_root.join("plugin.json");
std::fs::write(
&manifest_path,
r#"{"name": "my-plugin", "version": "0.1.0"}"#,
)
.unwrap();
match load_manifest(&plugin_root).unwrap() {
ManifestLoadResult::Found(m) => {
assert_eq!(m.name, "my-plugin");
assert_eq!(m.version.as_deref(), Some("0.1.0"));
}
_ => panic!("expected Found"),
}
}
#[test]
fn load_manifest_fallback_paths() {
let tmp = tempfile::tempdir().unwrap();
let plugin_root = tmp.path().join("fallback-plugin");
std::fs::create_dir_all(plugin_root.join(".kigi-plugin")).unwrap();
// Write manifest in .kigi-plugin/ fallback location
std::fs::write(
plugin_root.join(".kigi-plugin/plugin.json"),
r#"{"name": "fallback-plugin"}"#,
)
.unwrap();
match load_manifest(&plugin_root).unwrap() {
ManifestLoadResult::Found(m) => assert_eq!(m.name, "fallback-plugin"),
_ => panic!("expected Found"),
}
}
#[test]
fn load_manifest_root_wins_over_fallback() {
let tmp = tempfile::tempdir().unwrap();
let plugin_root = tmp.path().join("priority-test");
std::fs::create_dir_all(plugin_root.join(".kigi-plugin")).unwrap();
// Write both root and fallback
std::fs::write(plugin_root.join("plugin.json"), r#"{"name": "root-wins"}"#).unwrap();
std::fs::write(
plugin_root.join(".kigi-plugin/plugin.json"),
r#"{"name": "fallback-loses"}"#,
)
.unwrap();
match load_manifest(&plugin_root).unwrap() {
ManifestLoadResult::Found(m) => assert_eq!(m.name, "root-wins"),
_ => panic!("expected Found"),
}
}
#[test]
fn manifest_rejects_invalid_name() {
let json = r#"{"name": "INVALID_NAME"}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert!(manifest.validate().is_err());
}
#[test]
fn substitute_env_vars_replaces_all() {
let input = "${KIGI_PLUGIN_ROOT}/bin:${CLAUDE_PLUGIN_ROOT}/lib:${KIGI_PLUGIN_DATA}/cache";
let result = substitute_env_vars(input, "/home/user/plugin", "/home/user/.data/plugin");
assert_eq!(
result,
"/home/user/plugin/bin:/home/user/plugin/lib:/home/user/.data/plugin/cache"
);
}
#[test]
fn skill_dirs_default_convention() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("test-plugin");
std::fs::create_dir_all(root.join("skills")).unwrap();
let manifest = PluginManifest {
name: "test-plugin".into(),
version: None,
description: None,
author: None,
homepage: None,
repository: None,
license: None,
keywords: vec![],
skills: None,
commands: None,
agents: None,
hooks: None,
mcp_servers: None,
lsp_servers: None,
};
let dirs = manifest.skill_dirs(&root);
assert_eq!(dirs.len(), 1);
assert!(dirs[0].ends_with("skills"));
}
#[test]
fn skill_dirs_no_default_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("no-skills");
std::fs::create_dir_all(&root).unwrap();
let manifest = PluginManifest {
name: "no-skills".into(),
version: None,
description: None,
author: None,
homepage: None,
repository: None,
license: None,
keywords: vec![],
skills: None,
commands: None,
agents: None,
hooks: None,
mcp_servers: None,
lsp_servers: None,
};
let dirs = manifest.skill_dirs(&root);
assert!(dirs.is_empty());
}
#[test]
fn path_escape_rejected() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("contained");
std::fs::create_dir_all(&root).unwrap();
// Create an outside directory
let outside = tmp.path().join("outside-skills");
std::fs::create_dir_all(&outside).unwrap();
let manifest = PluginManifest {
name: "escape-test".into(),
version: None,
description: None,
author: None,
homepage: None,
repository: None,
license: None,
keywords: vec![],
skills: Some(PathOrPaths::Single("../outside-skills".to_string())),
commands: None,
agents: None,
hooks: None,
mcp_servers: None,
lsp_servers: None,
};
let dirs = manifest.skill_dirs(&root);
assert!(
dirs.is_empty(),
"path escaping plugin root should be rejected"
);
}
#[test]
fn path_within_root_accepted() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("plugin");
std::fs::create_dir_all(root.join("custom-skills")).unwrap();
let manifest = PluginManifest {
name: "within-test".into(),
version: None,
description: None,
author: None,
homepage: None,
repository: None,
license: None,
keywords: vec![],
skills: Some(PathOrPaths::Single("custom-skills".to_string())),
commands: None,
agents: None,
hooks: None,
mcp_servers: None,
lsp_servers: None,
};
let dirs = manifest.skill_dirs(&root);
assert_eq!(dirs.len(), 1, "path within plugin root should be accepted");
}
#[test]
fn hooks_path_escape_rejected() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("plugin");
std::fs::create_dir_all(&root).unwrap();
// Create a hooks file outside the plugin root
let outside = tmp.path().join("outside-hooks.json");
std::fs::write(&outside, r#"{"hooks":{}}"#).unwrap();
let manifest = PluginManifest {
name: "escape-hooks".into(),
version: None,
description: None,
author: None,
homepage: None,
repository: None,
license: None,
keywords: vec![],
skills: None,
commands: None,
agents: None,
hooks: Some(PathOrInline::Path("../outside-hooks.json".to_string())),
mcp_servers: None,
lsp_servers: None,
};
assert!(
manifest.hooks_path(&root).is_none(),
"hooks path escaping plugin root should be rejected"
);
}
#[test]
fn mcp_path_escape_rejected() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("plugin");
std::fs::create_dir_all(&root).unwrap();
let outside = tmp.path().join("outside-mcp.json");
std::fs::write(&outside, r#"{"mcpServers":{}}"#).unwrap();
let manifest = PluginManifest {
name: "escape-mcp".into(),
version: None,
description: None,
author: None,
homepage: None,
repository: None,
license: None,
keywords: vec![],
skills: None,
commands: None,
agents: None,
hooks: None,
mcp_servers: Some(PathOrInline::Path("../outside-mcp.json".to_string())),
lsp_servers: None,
};
assert!(
manifest.mcp_config_path(&root).is_none(),
"MCP path escaping plugin root should be rejected"
);
}
fn manifest_with_inline_mcp(servers: serde_json::Value) -> PluginManifest {
PluginManifest {
name: "sentry".into(),
version: None,
description: None,
author: None,
homepage: None,
repository: None,
license: None,
keywords: vec![],
skills: None,
commands: None,
agents: None,
hooks: None,
mcp_servers: Some(PathOrInline::Inline(servers)),
lsp_servers: None,
}
}
#[test]
fn normalize_inline_mcp_servers_wraps_direct_map() {
let direct = serde_json::json!({
"sentry": { "type": "http", "url": "https://mcp.sentry.dev/mcp" }
});
let normalized = normalize_inline_mcp_servers(&direct);
let servers = normalized
.get("mcpServers")
.and_then(|v| v.as_object())
.unwrap();
assert_eq!(servers.len(), 1);
assert!(servers.contains_key("sentry"));
}
#[test]
fn normalize_inline_mcp_servers_idempotent_for_wrapped() {
let wrapped = serde_json::json!({
"mcpServers": { "sentry": { "type": "http", "url": "https://mcp.sentry.dev/mcp" } }
});
assert_eq!(normalize_inline_mcp_servers(&wrapped), wrapped);
}
#[test]
fn mcp_config_path_inline_does_not_suppress_sibling_file() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sentry");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join(".mcp.json"),
r#"{"mcpServers":{"sentry":{"type":"http","url":"https://mcp.sentry.dev/mcp"}}}"#,
)
.unwrap();
let manifest = manifest_with_inline_mcp(serde_json::json!({
"sentry": { "type": "http", "url": "https://mcp.sentry.dev/mcp" }
}));
let resolved = manifest.mcp_config_path(&root);
assert!(
resolved.as_ref().is_some_and(|p| p.ends_with(".mcp.json")),
"inline mcpServers must not hide a sibling .mcp.json"
);
assert!(manifest.inline_mcp_servers().is_some());
}
#[test]
fn mcp_config_path_inline_without_file_is_none() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("inline-only");
std::fs::create_dir_all(&root).unwrap();
let manifest = manifest_with_inline_mcp(serde_json::json!({
"foo": { "command": "./server" }
}));
assert!(manifest.mcp_config_path(&root).is_none());
}
}