Skip to main content

everruns_core/plugins/
compiler.rs

1// Plugin compiler: PluginFileSet → CompiledPlugin
2//
3// Maps plugin directory components to a DeclarativeCapabilityDefinition per
4// the table in specs/plugins.md.
5
6use std::collections::BTreeMap;
7
8use crate::capabilities::{
9    CapabilityStatus, DeclarativeCapabilityDefinition, DeclarativeCapabilityFile,
10    DeclarativeCapabilitySkill, DeclarativeCapabilitySkillFile,
11    validate_declarative_capability_definition,
12};
13use crate::mcp_server::{
14    McpServerAuthMode, McpServerTransportType, ScopedMcpServer, ScopedMcpServers,
15};
16
17use super::file_set::PluginFileSet;
18use super::manifest::{McpServersField, PluginManifest};
19
20// `plugin:` prefix is 7 bytes, leaving 43 bytes for the name within the
21// VARCHAR(50) capability reference columns.
22const PLUGIN_CAPABILITY_PREFIX: &str = "plugin:";
23const MAX_PLUGIN_NAME_BYTES: usize = 50 - PLUGIN_CAPABILITY_PREFIX.len(); // 43
24
25/// Result of compiling a plugin directory.
26#[derive(Debug, Clone)]
27pub struct CompiledPlugin {
28    /// Parsed manifest.
29    pub manifest: PluginManifest,
30    /// Compiled declarative capability definition.
31    pub definition: DeclarativeCapabilityDefinition,
32    /// Non-fatal install warnings collected during compilation.
33    pub warnings: Vec<String>,
34}
35
36/// Compile a `PluginFileSet` into a `CompiledPlugin`.
37///
38/// Maps each plugin component to the corresponding capability contribution per
39/// the component-mapping table in `specs/plugins.md`. Errors are returned when
40/// compilation cannot produce a valid `DeclarativeCapabilityDefinition`.
41pub fn compile_plugin(file_set: &PluginFileSet) -> Result<CompiledPlugin, String> {
42    let (manifest, mut warnings) = file_set.manifest()?;
43
44    // --- name ---
45    let name = sanitize_plugin_name(&manifest.name)?;
46    if name.len() > MAX_PLUGIN_NAME_BYTES {
47        return Err(format!(
48            "plugin name '{}' is {} bytes but must fit in {} bytes (plugin: prefix occupies {} bytes)",
49            name,
50            name.len(),
51            MAX_PLUGIN_NAME_BYTES,
52            PLUGIN_CAPABILITY_PREFIX.len()
53        ));
54    }
55
56    // --- description (required) ---
57    let description = manifest
58        .description
59        .clone()
60        .filter(|d| !d.trim().is_empty())
61        .ok_or_else(|| "plugin manifest is missing a 'description' field".to_string())?;
62
63    // --- display_name ---
64    let display_name = manifest
65        .display_name
66        .clone()
67        .filter(|d| !d.trim().is_empty());
68
69    // --- agents → system_prompt ---
70    let system_prompt = compile_agents(file_set, &manifest, &mut warnings);
71
72    // --- skills ---
73    let skills = compile_skills(file_set, &manifest, &mut warnings);
74
75    // --- commands → user-invocable skills ---
76    let command_skills = compile_commands(file_set, &manifest, &mut warnings);
77
78    let mut all_skills = skills;
79    all_skills.extend(command_skills);
80
81    // --- MCP servers ---
82    let mcp_servers = compile_mcp_servers(file_set, &manifest, &mut warnings)?;
83
84    // Warn about unsupported component fields in the manifest.
85    for ignored_field in &["hooks", "lspServers", "monitors", "themes", "outputStyles"] {
86        if manifest.extra.contains_key(*ignored_field) {
87            warnings.push(format!(
88                "plugin manifest: '{ignored_field}' is not supported in v1 and will be ignored"
89            ));
90        }
91    }
92
93    let definition = DeclarativeCapabilityDefinition {
94        name: name.clone(),
95        display_name,
96        description,
97        status: CapabilityStatus::Available,
98        icon: Some("puzzle".to_string()),
99        category: Some("Plugin".to_string()),
100        system_prompt,
101        mcp_servers,
102        skills: all_skills,
103        files: Vec::<DeclarativeCapabilityFile>::new(),
104        dependencies: Vec::new(),
105        features: Vec::new(),
106        risk_level: crate::capabilities::RiskLevel::Low,
107    };
108
109    // Run through declarative validation to catch size/count violations.
110    validate_declarative_capability_definition(&definition)
111        .map_err(|e| format!("compiled plugin failed declarative validation: {e}"))?;
112
113    Ok(CompiledPlugin {
114        manifest,
115        definition,
116        warnings,
117    })
118}
119
120/// Validate and normalize a plugin name into the format accepted by the
121/// declarative name validator (lowercase, hyphens allowed, starts with letter).
122fn sanitize_plugin_name(name: &str) -> Result<String, String> {
123    let trimmed = name.trim();
124    if trimmed.is_empty() {
125        return Err("plugin name is empty".to_string());
126    }
127    // Validate: must start with lowercase letter, only [a-z0-9_-], no trailing -/_
128    let mut chars = trimmed.chars();
129    let first = chars.next().unwrap();
130    if !first.is_ascii_lowercase() {
131        return Err(format!(
132            "plugin name '{}' must start with a lowercase letter",
133            trimmed
134        ));
135    }
136    if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {
137        return Err(format!(
138            "plugin name '{trimmed}' may only contain lowercase letters, digits, '-', and '_'"
139        ));
140    }
141    if trimmed.ends_with('-') || trimmed.ends_with('_') {
142        return Err(format!(
143            "plugin name '{trimmed}' must not end with '-' or '_'"
144        ));
145    }
146    Ok(trimmed.to_string())
147}
148
149// ============================================================================
150// Agents → system_prompt
151// ============================================================================
152
153/// Render agent files into a combined system prompt.
154///
155/// Each `.md` file under `agents/` (or the manifest-overridden path) is
156/// rendered as a named `<agent>` XML section per specs/xml-prompt-formatting.md.
157fn compile_agents(
158    file_set: &PluginFileSet,
159    manifest: &PluginManifest,
160    _warnings: &mut Vec<String>,
161) -> Option<String> {
162    let agent_dirs = match &manifest.agents {
163        Some(paths) => resolve_component_paths(paths),
164        None => vec!["agents".to_string()],
165    };
166
167    let mut sections: Vec<String> = Vec::new();
168
169    for agent_dir in &agent_dirs {
170        let dir = strip_dot_slash(agent_dir);
171        // List .md files directly under this directory.
172        let mut entries: Vec<(&str, &str)> = file_set.list_dir(dir);
173        entries.sort_by_key(|(name, _)| *name);
174
175        for (filename, full_path) in entries {
176            if !filename.ends_with(".md") {
177                continue;
178            }
179            let Some(content) = file_set.text_file(full_path) else {
180                continue;
181            };
182
183            // Parse frontmatter name/description if present.
184            let (fm_name, fm_desc, body) = parse_simple_frontmatter(&content);
185
186            // Use frontmatter `name` if available; fall back to filename stem.
187            let agent_name =
188                fm_name.unwrap_or_else(|| filename.trim_end_matches(".md").to_string());
189
190            let mut section = format!("<agent name=\"{}\"", escape_attr(&agent_name));
191            if let Some(desc) = fm_desc {
192                section.push_str(&format!(" description=\"{}\"", escape_attr(&desc)));
193            }
194            section.push_str(">\n");
195            section.push_str(body.trim());
196            section.push_str("\n</agent>");
197            sections.push(section);
198        }
199    }
200
201    if sections.is_empty() {
202        None
203    } else {
204        Some(sections.join("\n\n"))
205    }
206}
207
208// ============================================================================
209// Skills → DeclarativeCapabilitySkill
210// ============================================================================
211
212fn compile_skills(
213    file_set: &PluginFileSet,
214    manifest: &PluginManifest,
215    warnings: &mut Vec<String>,
216) -> Vec<DeclarativeCapabilitySkill> {
217    let skill_dirs = match &manifest.skills {
218        Some(paths) => resolve_component_paths(paths),
219        None => vec!["skills".to_string()],
220    };
221
222    let mut skills = Vec::new();
223
224    for skill_dir in &skill_dirs {
225        let dir = strip_dot_slash(skill_dir);
226        // Enumerate immediate subdirectories of the skills dir by finding
227        // files under `dir/` and extracting the first path component.
228        let prefix = format!("{dir}/");
229        let mut seen_subdirs = std::collections::BTreeSet::new();
230        for key in file_set.files.keys() {
231            if let Some(rest) = key.strip_prefix(&prefix)
232                && let Some(slash_pos) = rest.find('/')
233            {
234                seen_subdirs.insert(rest[..slash_pos].to_string());
235            }
236        }
237
238        for subdir_name in &seen_subdirs {
239            let skill_path = format!("{dir}/{subdir_name}");
240            let skill_md_path = format!("{skill_path}/SKILL.md");
241
242            let Some(skill_md_content) = file_set.text_file(&skill_md_path) else {
243                continue;
244            };
245
246            // Parse SKILL.md using the existing parser.
247            match crate::skill::parse_skill_md(&skill_md_content) {
248                Ok(parsed) => {
249                    // Collect sibling files (non-SKILL.md, text only).
250                    let mut skill_files = Vec::new();
251                    let all_skill_files = file_set.list_dir_recursive(&skill_path);
252                    for file_path in all_skill_files {
253                        if file_path == skill_md_path {
254                            continue;
255                        }
256                        let rel_within_skill = file_path
257                            .strip_prefix(&format!("{skill_path}/"))
258                            .unwrap_or(file_path);
259
260                        if let Some(bytes) = file_set.files.get(file_path) {
261                            match String::from_utf8(bytes.clone()) {
262                                Ok(text) => {
263                                    skill_files.push(DeclarativeCapabilitySkillFile {
264                                        path: rel_within_skill.to_string(),
265                                        content: text,
266                                    });
267                                }
268                                Err(_) => {
269                                    warnings.push(format!(
270                                        "skill '{}': binary file '{}' skipped (text only)",
271                                        parsed.name, rel_within_skill
272                                    ));
273                                }
274                            }
275                        }
276                    }
277
278                    skills.push(DeclarativeCapabilitySkill {
279                        name: parsed.name,
280                        description: parsed.description,
281                        instructions: parsed.instructions,
282                        files: skill_files,
283                        user_invocable: parsed.user_invocable,
284                        disable_model_invocation: parsed.disable_model_invocation,
285                    });
286                }
287                Err(errors) => {
288                    warnings.push(format!(
289                        "skill '{}': SKILL.md parse errors — {}: skill skipped",
290                        subdir_name,
291                        errors.join("; ")
292                    ));
293                }
294            }
295        }
296    }
297
298    skills
299}
300
301// ============================================================================
302// Commands → user-invocable DeclarativeCapabilitySkill
303// ============================================================================
304
305fn compile_commands(
306    file_set: &PluginFileSet,
307    manifest: &PluginManifest,
308    _warnings: &mut Vec<String>,
309) -> Vec<DeclarativeCapabilitySkill> {
310    let command_dirs = match &manifest.commands {
311        Some(paths) => resolve_component_paths(paths),
312        None => vec!["commands".to_string()],
313    };
314
315    let mut skills = Vec::new();
316
317    for command_dir in &command_dirs {
318        let dir = strip_dot_slash(command_dir);
319        let mut entries: Vec<(&str, &str)> = file_set.list_dir(dir);
320        entries.sort_by_key(|(name, _)| *name);
321
322        for (filename, full_path) in entries {
323            if !filename.ends_with(".md") {
324                continue;
325            }
326
327            let Some(content) = file_set.text_file(full_path) else {
328                continue;
329            };
330
331            let (fm_name, fm_desc, body) = parse_simple_frontmatter(&content);
332            let stem = filename.trim_end_matches(".md");
333            let name = fm_name.unwrap_or_else(|| stem.to_string());
334            let description = fm_desc.unwrap_or_else(|| format!("/{name} command"));
335
336            skills.push(DeclarativeCapabilitySkill {
337                name,
338                description,
339                instructions: body.trim().to_string(),
340                files: Vec::new(),
341                user_invocable: true,
342                disable_model_invocation: false,
343            });
344        }
345    }
346
347    skills
348}
349
350// ============================================================================
351// MCP Servers
352// ============================================================================
353
354/// Parse and compile MCP server configuration.
355///
356/// v1 supports HTTP transport only. Stdio entries produce a warning and are skipped.
357fn compile_mcp_servers(
358    file_set: &PluginFileSet,
359    manifest: &PluginManifest,
360    warnings: &mut Vec<String>,
361) -> Result<Option<ScopedMcpServers>, String> {
362    // Resolve where to look for MCP config.
363    let mcp_source = match &manifest.mcp_servers {
364        Some(McpServersField::Path(path)) => {
365            // Load the referenced file.
366            let p = strip_dot_slash(path);
367            match file_set.text_file(p) {
368                Some(content) => McpConfigSource::File(content),
369                None => return Ok(None),
370            }
371        }
372        Some(McpServersField::Paths(paths)) => {
373            // Merge all referenced files.
374            let mut merged: BTreeMap<String, serde_json::Value> = BTreeMap::new();
375            for path in paths {
376                let p = strip_dot_slash(path);
377                if let Some(content) = file_set.text_file(p) {
378                    let parsed = parse_mcp_json_file(&content, p)?;
379                    merged.extend(parsed);
380                }
381            }
382            McpConfigSource::Map(merged)
383        }
384        Some(McpServersField::Inline(map)) => {
385            McpConfigSource::Map(map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
386        }
387        None => {
388            // Default: look for `.mcp.json` in the plugin root.
389            match file_set.text_file(".mcp.json") {
390                Some(content) => McpConfigSource::File(content),
391                None => return Ok(None),
392            }
393        }
394    };
395
396    let raw_map = match mcp_source {
397        McpConfigSource::File(content) => parse_mcp_json_file(&content, ".mcp.json")?,
398        McpConfigSource::Map(m) => m,
399    };
400
401    if raw_map.is_empty() {
402        return Ok(None);
403    }
404
405    let mut servers = ScopedMcpServers::new();
406
407    for (server_name, server_config) in raw_map {
408        // Extract transport type.
409        let transport_str = server_config
410            .get("type")
411            .and_then(|v| v.as_str())
412            .unwrap_or("http");
413
414        // Detect stdio by command presence or explicit "stdio" type.
415        let has_command = server_config.get("command").is_some();
416        let is_stdio = transport_str == "stdio" || has_command;
417
418        if is_stdio {
419            warnings.push(format!(
420                "MCP server '{server_name}': stdio transport is not supported in v1 and will be skipped"
421            ));
422            continue;
423        }
424
425        let url = server_config
426            .get("url")
427            .and_then(|v| v.as_str())
428            .unwrap_or("")
429            .to_string();
430
431        // Literal headers (sent only to the plugin's own server URL).
432        let mut headers = std::collections::HashMap::new();
433        if let Some(header_map) = server_config.get("headers").and_then(|v| v.as_object()) {
434            for (header_name, header_value) in header_map {
435                match header_value.as_str() {
436                    Some(value) => {
437                        headers.insert(header_name.clone(), value.to_string());
438                    }
439                    None => warnings.push(format!(
440                        "MCP server '{server_name}': header '{header_name}' is not a string and will be ignored"
441                    )),
442                }
443            }
444        }
445
446        // Authentication. `"auth": "oauth"` (alias `auth_mode`) is an Everruns
447        // extension marking the server as OAuth-authenticated; other hosts
448        // ignore it and negotiate OAuth at the protocol level. `api_key` is
449        // rejected — a plugin package cannot carry key material.
450        let auth_value = server_config
451            .get("auth")
452            .or_else(|| server_config.get("auth_mode"))
453            .and_then(|v| v.as_str());
454        let auth_mode = match auth_value.map(str::to_ascii_lowercase).as_deref() {
455            Some("oauth") => McpServerAuthMode::OAuth,
456            Some("none") | None => McpServerAuthMode::None,
457            Some(other) => {
458                warnings.push(format!(
459                    "MCP server '{server_name}': auth mode '{other}' is not supported for plugin servers and will be ignored"
460                ));
461                McpServerAuthMode::None
462            }
463        };
464
465        // A plugin must never bind to an existing OAuth provider — that would
466        // let third-party plugin content read tokens connected for other
467        // providers (e.g. github). The host assigns the provider id at
468        // install time (see specs/plugins.md).
469        if server_config.get("oauth_provider_id").is_some() {
470            warnings.push(format!(
471                "MCP server '{server_name}': 'oauth_provider_id' cannot be set by a plugin and will be ignored"
472            ));
473        }
474
475        servers.insert(
476            server_name,
477            ScopedMcpServer {
478                transport_type: McpServerTransportType::Http,
479                url,
480                headers,
481                auth_mode,
482                ..ScopedMcpServer::default()
483            },
484        );
485    }
486
487    if servers.is_empty() {
488        Ok(None)
489    } else {
490        Ok(Some(servers))
491    }
492}
493
494enum McpConfigSource {
495    File(String),
496    Map(BTreeMap<String, serde_json::Value>),
497}
498
499/// Parse a `.mcp.json` file and return the `mcpServers` object as a flat map.
500fn parse_mcp_json_file(
501    content: &str,
502    path: &str,
503) -> Result<BTreeMap<String, serde_json::Value>, String> {
504    let value: serde_json::Value =
505        serde_json::from_str(content).map_err(|e| format!("failed to parse {path}: {e}"))?;
506
507    // Top-level `mcpServers` key (standard .mcp.json format).
508    if let Some(servers) = value.get("mcpServers").and_then(|v| v.as_object()) {
509        return Ok(servers
510            .iter()
511            .map(|(k, v)| (k.clone(), v.clone()))
512            .collect());
513    }
514
515    // Fallback: treat the root object itself as the servers map.
516    if let Some(obj) = value.as_object() {
517        return Ok(obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
518    }
519
520    Ok(BTreeMap::new())
521}
522
523// ============================================================================
524// Helpers
525// ============================================================================
526
527/// Resolve a `StringOrArray` component path override to a list of dir strings.
528fn resolve_component_paths(field: &super::manifest::StringOrArray) -> Vec<String> {
529    field.to_vec()
530}
531
532/// Normalize a component path: strip leading `./` and trailing `/`.
533fn strip_dot_slash(path: &str) -> &str {
534    let p = path.strip_prefix("./").unwrap_or(path);
535    p.trim_end_matches('/')
536}
537
538/// Escape a string for use in an XML attribute value.
539fn escape_attr(s: &str) -> String {
540    s.replace('&', "&amp;")
541        .replace('"', "&quot;")
542        .replace('<', "&lt;")
543        .replace('>', "&gt;")
544}
545
546/// Minimal YAML-style frontmatter parser for `name` and `description` fields
547/// only. Returns `(name, description, body)`.
548///
549/// We don't use the full `serde_yaml` parser here because plugin agent/command
550/// files may use different frontmatter schemas; we only need two fields.
551fn parse_simple_frontmatter(content: &str) -> (Option<String>, Option<String>, &str) {
552    let trimmed = content.trim_start();
553    if !trimmed.starts_with("---") {
554        return (None, None, content);
555    }
556    let after_first = &trimmed[3..];
557    let Some(closing) = after_first.find("\n---") else {
558        return (None, None, content);
559    };
560
561    let fm_text = &after_first[..closing];
562    let body_start = closing + 4;
563    let body = if body_start < after_first.len() {
564        after_first[body_start..].trim_start_matches('\n')
565    } else {
566        ""
567    };
568
569    let mut name = None;
570    let mut description = None;
571    for line in fm_text.lines() {
572        if let Some(rest) = line.strip_prefix("name:") {
573            name = Some(rest.trim().trim_matches('"').trim_matches('\'').to_string());
574        } else if let Some(rest) = line.strip_prefix("description:") {
575            description = Some(rest.trim().trim_matches('"').trim_matches('\'').to_string());
576        }
577    }
578
579    (name, description, body)
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    // ---- fixture integration test ----
587
588    #[test]
589    fn compile_microsoft_docs_fixture() {
590        let fixture = std::path::Path::new(concat!(
591            env!("CARGO_MANIFEST_DIR"),
592            "/../../testdata/plugins/microsoft-docs"
593        ));
594        let file_set = PluginFileSet::from_dir(fixture).expect("load fixture");
595        let compiled = compile_plugin(&file_set).expect("compile fixture");
596
597        // --- name and display name ---
598        assert_eq!(compiled.definition.name, "microsoft-docs");
599        assert_eq!(
600            compiled.definition.display_name.as_deref(),
601            Some("Microsoft Docs")
602        );
603
604        // --- description ---
605        assert!(!compiled.definition.description.is_empty());
606
607        // --- MCP server ---
608        let mcp = compiled
609            .definition
610            .mcp_servers
611            .as_ref()
612            .expect("mcp_servers");
613        let server = mcp.get("microsoft-learn").expect("microsoft-learn server");
614        assert_eq!(server.url, "https://learn.microsoft.com/api/mcp");
615        assert!(matches!(
616            server.transport_type,
617            McpServerTransportType::Http
618        ));
619
620        // --- skills ---
621        let skill = compiled
622            .definition
623            .skills
624            .iter()
625            .find(|s| s.name == "microsoft-docs")
626            .expect("microsoft-docs skill");
627        assert!(!skill.instructions.is_empty());
628
629        // --- commands (user-invocable skill) ---
630        let command = compiled
631            .definition
632            .skills
633            .iter()
634            .find(|s| s.name == "ms-docs")
635            .expect("ms-docs command skill");
636        assert!(command.user_invocable);
637
638        // --- agent → system_prompt ---
639        let prompt = compiled
640            .definition
641            .system_prompt
642            .as_ref()
643            .expect("system_prompt");
644        assert!(
645            prompt.contains("docs-researcher"),
646            "expected docs-researcher in system_prompt, got: {prompt}"
647        );
648
649        // --- interface warning ---
650        assert!(
651            compiled.warnings.iter().any(|w| w.contains("interface")),
652            "expected interface warning, got: {:?}",
653            compiled.warnings
654        );
655    }
656
657    // ---- targeted unit tests ----
658
659    #[test]
660    fn traversal_rejection() {
661        // The OS won't allow `..` in actual directory paths, so we test the
662        // name validation logic that rejects traversal-like plugin names and
663        // also verify the file_set traversal guard via file_set::tests.
664        // `../evil` fails at the first char check (not lowercase).
665        let err = sanitize_plugin_name("../evil").unwrap_err();
666        assert!(err.contains("must start with a lowercase letter"), "{err}");
667        // A name that starts with a letter but contains traversal separators.
668        let err2 = sanitize_plugin_name("a/b").unwrap_err();
669        assert!(err2.contains("only contain"), "{err2}");
670    }
671
672    #[test]
673    fn stdio_mcp_produces_warning() {
674        let mut warnings = Vec::new();
675        let file_set_files = {
676            let mut f = std::collections::BTreeMap::new();
677            f.insert(
678                ".claude-plugin/plugin.json".to_string(),
679                serde_json::json!({
680                    "name": "test-plugin",
681                    "description": "A test plugin."
682                })
683                .to_string()
684                .into_bytes(),
685            );
686            f.insert(
687                ".mcp.json".to_string(),
688                serde_json::json!({
689                    "mcpServers": {
690                        "my-server": {
691                            "type": "stdio",
692                            "command": "npx",
693                            "args": ["-y", "@some/mcp-server"]
694                        }
695                    }
696                })
697                .to_string()
698                .into_bytes(),
699            );
700            f
701        };
702        let file_set = PluginFileSet {
703            files: file_set_files,
704            dir_name: "test-plugin".to_string(),
705        };
706        let manifest = PluginManifest {
707            name: "test-plugin".to_string(),
708            display_name: None,
709            version: None,
710            description: Some("test".to_string()),
711            author: None,
712            homepage: None,
713            repository: None,
714            license: None,
715            keywords: Vec::new(),
716            skills: None,
717            commands: None,
718            agents: None,
719            mcp_servers: None,
720            extra: Default::default(),
721        };
722        let result = compile_mcp_servers(&file_set, &manifest, &mut warnings);
723        assert!(result.is_ok());
724        assert!(
725            warnings.iter().any(|w| w.contains("stdio")),
726            "expected stdio warning, got: {warnings:?}"
727        );
728        // No servers compiled since only one was stdio.
729        assert!(result.unwrap().is_none());
730    }
731
732    #[test]
733    fn oauth_mcp_server_preserves_auth_and_headers() {
734        let mut warnings = Vec::new();
735        let file_set = PluginFileSet {
736            files: {
737                let mut f = std::collections::BTreeMap::new();
738                f.insert(
739                    ".mcp.json".to_string(),
740                    serde_json::json!({
741                        "mcpServers": {
742                            "resend": {
743                                "type": "http",
744                                "url": "https://mcp.resend.com/mcp",
745                                "auth": "oauth",
746                                "headers": { "X-Custom": "1", "X-Bad": 5 },
747                                "oauth_provider_id": "github"
748                            }
749                        }
750                    })
751                    .to_string()
752                    .into_bytes(),
753                );
754                f
755            },
756            dir_name: "resend".to_string(),
757        };
758        let manifest = PluginManifest {
759            name: "resend".to_string(),
760            display_name: None,
761            version: None,
762            description: Some("test".to_string()),
763            author: None,
764            homepage: None,
765            repository: None,
766            license: None,
767            keywords: Vec::new(),
768            skills: None,
769            commands: None,
770            agents: None,
771            mcp_servers: None,
772            extra: Default::default(),
773        };
774        let servers = compile_mcp_servers(&file_set, &manifest, &mut warnings)
775            .expect("compile")
776            .expect("servers");
777        let server = servers.get("resend").expect("resend server");
778        assert_eq!(server.auth_mode, McpServerAuthMode::OAuth);
779        // Plugin content must never bind a provider id; the host assigns it
780        // at install time.
781        assert!(server.oauth_provider_id.is_none());
782        assert_eq!(
783            server.headers.get("X-Custom").map(String::as_str),
784            Some("1")
785        );
786        assert!(!server.headers.contains_key("X-Bad"));
787        assert!(
788            warnings
789                .iter()
790                .any(|w| w.contains("oauth_provider_id") && w.contains("ignored")),
791            "expected oauth_provider_id warning, got: {warnings:?}"
792        );
793        assert!(
794            warnings.iter().any(|w| w.contains("X-Bad")),
795            "expected non-string header warning, got: {warnings:?}"
796        );
797    }
798
799    #[test]
800    fn unsupported_auth_mode_produces_warning() {
801        let mut warnings = Vec::new();
802        let file_set = PluginFileSet {
803            files: {
804                let mut f = std::collections::BTreeMap::new();
805                f.insert(
806                    ".mcp.json".to_string(),
807                    serde_json::json!({
808                        "mcpServers": {
809                            "svc": { "url": "https://example.com/mcp", "auth": "api_key" }
810                        }
811                    })
812                    .to_string()
813                    .into_bytes(),
814                );
815                f
816            },
817            dir_name: "svc".to_string(),
818        };
819        let manifest = PluginManifest {
820            name: "svc".to_string(),
821            display_name: None,
822            version: None,
823            description: Some("test".to_string()),
824            author: None,
825            homepage: None,
826            repository: None,
827            license: None,
828            keywords: Vec::new(),
829            skills: None,
830            commands: None,
831            agents: None,
832            mcp_servers: None,
833            extra: Default::default(),
834        };
835        let servers = compile_mcp_servers(&file_set, &manifest, &mut warnings)
836            .expect("compile")
837            .expect("servers");
838        assert_eq!(
839            servers.get("svc").expect("svc").auth_mode,
840            McpServerAuthMode::None
841        );
842        assert!(
843            warnings.iter().any(|w| w.contains("api_key")),
844            "expected auth warning, got: {warnings:?}"
845        );
846    }
847
848    #[test]
849    fn missing_description_is_error() {
850        let file_set_files = {
851            let mut f = std::collections::BTreeMap::new();
852            f.insert(
853                ".claude-plugin/plugin.json".to_string(),
854                serde_json::json!({
855                    "name": "nodesc-plugin"
856                })
857                .to_string()
858                .into_bytes(),
859            );
860            f
861        };
862        let file_set = PluginFileSet {
863            files: file_set_files,
864            dir_name: "nodesc-plugin".to_string(),
865        };
866        let err = compile_plugin(&file_set).unwrap_err();
867        assert!(err.contains("description"), "error was: {err}");
868    }
869
870    #[test]
871    fn oversized_name_is_error() {
872        // A name longer than MAX_PLUGIN_NAME_BYTES (43) should fail.
873        let long_name = "a".repeat(MAX_PLUGIN_NAME_BYTES + 1);
874        let file_set_files = {
875            let mut f = std::collections::BTreeMap::new();
876            f.insert(
877                ".claude-plugin/plugin.json".to_string(),
878                serde_json::json!({
879                    "name": long_name,
880                    "description": "test"
881                })
882                .to_string()
883                .into_bytes(),
884            );
885            f
886        };
887        let file_set = PluginFileSet {
888            files: file_set_files,
889            dir_name: "aaa".to_string(),
890        };
891        let err = compile_plugin(&file_set).unwrap_err();
892        assert!(err.contains("bytes"), "error was: {err}");
893    }
894}