Skip to main content

harness_locate/
validation.rs

1//! MCP server configuration validation.
2//!
3//! This module provides validation for [`McpServer`] configurations,
4//! checking for structural issues like empty commands, invalid URLs,
5//! excessive timeouts, and suspicious environment variable names.
6//!
7//! Unlike the fail-fast error handling elsewhere in this crate,
8//! validation collects all issues found, allowing callers to see
9//! the complete picture rather than stopping at the first problem.
10//!
11//! # Example
12//!
13//! ```
14//! use harness_locate::mcp::{McpServer, StdioMcpServer};
15//! use harness_locate::validation::{validate_mcp_server, Severity};
16//!
17//! let server = McpServer::Stdio(StdioMcpServer {
18//!     command: String::new(), // Empty command - will be flagged
19//!     args: vec![],
20//!     env: std::collections::HashMap::new(),
21//!     cwd: None,
22//!     enabled: true,
23//!     timeout_ms: None,
24//! });
25//!
26//! let issues = validate_mcp_server(&server);
27//! assert!(!issues.is_empty());
28//! assert!(issues.iter().any(|i| i.severity == Severity::Error));
29//! ```
30
31use std::collections::HashMap;
32use std::sync::LazyLock;
33
34use regex::Regex;
35use serde::{Deserialize, Serialize};
36use url::Url;
37
38use crate::mcp::{HttpMcpServer, McpCapabilities, McpServer, SseMcpServer, StdioMcpServer};
39use crate::types::{EnvValue, HarnessKind};
40
41static SKILL_NAME_RE: LazyLock<Regex> =
42    LazyLock::new(|| Regex::new(SKILL_NAME_REGEX).expect("invalid skill name regex"));
43
44// Issue code constants for machine-readable classification.
45
46/// Empty command in stdio transport.
47pub const CODE_EMPTY_COMMAND: &str = "stdio.command.empty";
48
49/// URL failed to parse.
50pub const CODE_INVALID_URL: &str = "url.invalid";
51
52/// URL has non-http(s) scheme.
53pub const CODE_INVALID_SCHEME: &str = "url.scheme.invalid";
54
55/// Timeout exceeds recommended maximum.
56pub const CODE_TIMEOUT_EXCESSIVE: &str = "timeout.excessive";
57
58/// Environment variable name suggests sensitive data.
59pub const CODE_SUSPICIOUS_ENV: &str = "env.suspicious_name";
60
61/// Working directory (cwd) not supported by harness.
62pub const CODE_CWD_UNSUPPORTED: &str = "harness.cwd.unsupported";
63
64/// Toggle (enabled field) not supported by harness.
65pub const CODE_TOGGLE_UNSUPPORTED: &str = "harness.toggle.unsupported";
66
67/// SSE transport deprecated for this harness (prefer HTTP).
68pub const CODE_SSE_DEPRECATED: &str = "harness.transport.sse_deprecated";
69
70// Agent validation codes.
71
72/// Agent tools field has wrong type for harness.
73pub const CODE_AGENT_TOOLS_FORMAT: &str = "agent.tools.format";
74
75/// Agent color field has invalid format for harness.
76pub const CODE_AGENT_COLOR_FORMAT: &str = "agent.color.format";
77
78/// Agent mode value not supported by harness.
79pub const CODE_AGENT_MODE_UNSUPPORTED: &str = "agent.mode.unsupported";
80
81/// Harness does not support agents.
82pub const CODE_AGENT_UNSUPPORTED: &str = "agent.unsupported";
83
84/// Agent frontmatter failed to parse.
85pub const CODE_AGENT_PARSE_ERROR: &str = "agent.parse_error";
86
87// Skill validation codes.
88
89/// Skill name has invalid format for harness.
90pub const CODE_SKILL_NAME_FORMAT: &str = "skill.name.invalid_format";
91
92/// Skill name exceeds maximum length.
93pub const CODE_SKILL_NAME_LENGTH: &str = "skill.name.length";
94
95/// Skill description exceeds maximum length.
96pub const CODE_SKILL_DESCRIPTION_LENGTH: &str = "skill.description.length";
97
98/// Skill name does not match directory name.
99pub const CODE_SKILL_NAME_DIRECTORY_MISMATCH: &str = "skill.name.directory_mismatch";
100
101/// Harness does not support skills.
102pub const CODE_SKILL_UNSUPPORTED: &str = "skill.unsupported";
103
104/// Skill frontmatter failed to parse.
105pub const CODE_SKILL_PARSE_ERROR: &str = "skill.parse_error";
106
107/// Skill is missing required description field.
108pub const CODE_SKILL_DESCRIPTION_MISSING: &str = "skill.description.missing";
109
110/// Skill name validation regex: lowercase alphanumeric with single hyphens.
111pub const SKILL_NAME_REGEX: &str = r"^[a-z0-9]+(-[a-z0-9]+)*$";
112
113/// Maximum length for skill name.
114pub const SKILL_NAME_MAX_LEN: usize = 64;
115
116/// Maximum length for skill description.
117pub const SKILL_DESCRIPTION_MAX_LEN: usize = 1024;
118
119/// Severity level for validation issues.
120///
121/// Determines how the issue should be treated by callers.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
123pub enum Severity {
124    /// Critical issue that will likely cause the server to fail.
125    ///
126    /// Examples: empty command, unparseable URL.
127    Error,
128
129    /// Non-critical issue that may cause problems or is worth reviewing.
130    ///
131    /// Examples: very long timeout, suspicious environment variable name.
132    Warning,
133}
134
135/// Expected format for agent `tools` field.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum ToolsFormat {
138    /// `Record<string, boolean>` - OpenCode style: `{ bash: true, edit: false }`
139    BooleanRecord,
140    /// Comma-separated string - Claude Code style: `"Glob, Grep, Read"`
141    CommaSeparatedString,
142}
143
144/// Expected format for agent `color` field.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum ColorFormat {
147    /// Only hex colors: `#RRGGBB`
148    HexOnly,
149    /// Named colors (red, blue) or hex - accepts any string.
150    NamedOrHex,
151}
152
153/// Describes agent validation requirements for a harness.
154#[derive(Debug, Clone)]
155pub struct AgentCapabilities {
156    /// Expected format for `tools` field.
157    pub tools_format: ToolsFormat,
158    /// Expected format for `color` field.
159    pub color_format: ColorFormat,
160    /// Supported mode values.
161    pub supported_modes: &'static [&'static str],
162}
163
164impl AgentCapabilities {
165    #[must_use]
166    pub fn for_kind(kind: HarnessKind) -> Option<Self> {
167        match kind {
168            HarnessKind::OpenCode => Some(Self {
169                tools_format: ToolsFormat::BooleanRecord,
170                color_format: ColorFormat::HexOnly,
171                supported_modes: &["subagent", "primary", "all"],
172            }),
173            HarnessKind::ClaudeCode | HarnessKind::AmpCode => Some(Self {
174                tools_format: ToolsFormat::CommaSeparatedString,
175                color_format: ColorFormat::NamedOrHex,
176                supported_modes: &["subagent", "primary"],
177            }),
178            HarnessKind::CopilotCli | HarnessKind::Droid => Some(Self {
179                tools_format: ToolsFormat::CommaSeparatedString,
180                color_format: ColorFormat::NamedOrHex,
181                supported_modes: &["subagent", "primary"],
182            }),
183            HarnessKind::Goose | HarnessKind::Crush => None,
184        }
185    }
186}
187
188/// Expected format for skill `name` field.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum NameFormat {
191    /// Lowercase alphanumeric with hyphens only: `^[a-z0-9]+(-[a-z0-9]+)*$`
192    LowercaseHyphenated,
193    /// Any string format accepted.
194    Any,
195}
196
197/// Describes skill validation requirements for a harness.
198#[derive(Debug, Clone)]
199pub struct SkillCapabilities {
200    /// Expected format for `name` field.
201    pub name_format: NameFormat,
202    /// Whether skill name must match parent directory name.
203    pub name_must_match_directory: bool,
204    /// Whether description field is required.
205    pub description_required: bool,
206}
207
208impl SkillCapabilities {
209    #[must_use]
210    pub fn for_kind(kind: HarnessKind) -> Option<Self> {
211        match kind {
212            HarnessKind::OpenCode => Some(Self {
213                name_format: NameFormat::LowercaseHyphenated,
214                name_must_match_directory: true,
215                description_required: true,
216            }),
217            HarnessKind::ClaudeCode | HarnessKind::AmpCode | HarnessKind::Droid => Some(Self {
218                name_format: NameFormat::Any,
219                name_must_match_directory: false,
220                description_required: false,
221            }),
222            // Copilot CLI follows agentskills.io spec: lowercase hyphenated names,
223            // name must match directory, description required
224            HarnessKind::CopilotCli => Some(Self {
225                name_format: NameFormat::LowercaseHyphenated,
226                name_must_match_directory: true,
227                description_required: true,
228            }),
229            HarnessKind::Goose => None,
230            HarnessKind::Crush => Some(Self {
231                name_format: NameFormat::Any,
232                name_must_match_directory: false,
233                description_required: false,
234            }),
235        }
236    }
237}
238
239/// A validation issue found in an MCP server configuration.
240///
241/// Issues are collected by [`validate_mcp_server`] and returned as a `Vec`.
242/// An empty result means the configuration passed all checks.
243///
244/// # Extensibility
245///
246/// This struct is marked `#[non_exhaustive]` to allow adding new fields
247/// in future versions without breaking changes. Use the constructor
248/// methods [`ValidationIssue::error`] and [`ValidationIssue::warning`]
249/// rather than constructing directly.
250#[non_exhaustive]
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct ValidationIssue {
253    /// Severity of the issue.
254    pub severity: Severity,
255
256    /// The field path where the issue was found (e.g., "command", "url", "env.SECRET_KEY").
257    pub field: String,
258
259    /// Human-readable description of the issue.
260    pub message: String,
261
262    /// Machine-readable issue code for programmatic filtering.
263    ///
264    /// See the `CODE_*` constants in this module.
265    pub code: Option<&'static str>,
266}
267
268impl ValidationIssue {
269    /// Creates an error-level validation issue.
270    ///
271    /// # Arguments
272    ///
273    /// * `field` - The field path where the issue was found
274    /// * `message` - Human-readable description
275    /// * `code` - Optional machine-readable code
276    #[must_use]
277    pub fn error(
278        field: impl Into<String>,
279        message: impl Into<String>,
280        code: Option<&'static str>,
281    ) -> Self {
282        Self {
283            severity: Severity::Error,
284            field: field.into(),
285            message: message.into(),
286            code,
287        }
288    }
289
290    /// Creates a warning-level validation issue.
291    ///
292    /// # Arguments
293    ///
294    /// * `field` - The field path where the issue was found
295    /// * `message` - Human-readable description
296    /// * `code` - Optional machine-readable code
297    #[must_use]
298    pub fn warning(
299        field: impl Into<String>,
300        message: impl Into<String>,
301        code: Option<&'static str>,
302    ) -> Self {
303        Self {
304            severity: Severity::Warning,
305            field: field.into(),
306            message: message.into(),
307            code,
308        }
309    }
310}
311
312/// Maximum recommended timeout in milliseconds (5 minutes).
313const MAX_RECOMMENDED_TIMEOUT_MS: u64 = 300_000;
314
315/// Patterns that suggest an environment variable contains sensitive data.
316///
317/// These are checked case-insensitively against variable names.
318const SUSPICIOUS_ENV_PATTERNS: &[&str] = &[
319    "PASSWORD",
320    "PASSWD",
321    "SECRET",
322    "TOKEN",
323    "API_KEY",
324    "PRIVATE_KEY",
325    "ACCESS_KEY",
326    "CREDENTIAL",
327    "BEARER",
328    "AUTH",
329];
330
331/// Validates an MCP server configuration.
332///
333/// Checks for structural issues like empty commands, invalid URLs,
334/// excessive timeouts, and suspicious environment variable names.
335/// Returns all issues found, allowing callers to see the complete picture.
336///
337/// # Arguments
338///
339/// * `server` - The MCP server configuration to validate
340///
341/// # Returns
342///
343/// A vector of validation issues. An empty vector means no issues were found.
344///
345/// # Example
346///
347/// ```
348/// use harness_locate::mcp::{McpServer, StdioMcpServer};
349/// use harness_locate::validation::validate_mcp_server;
350///
351/// let server = McpServer::Stdio(StdioMcpServer {
352///     command: "node".to_string(),
353///     args: vec!["server.js".to_string()],
354///     env: std::collections::HashMap::new(),
355///     cwd: None,
356///     enabled: true,
357///     timeout_ms: None,
358/// });
359///
360/// let issues = validate_mcp_server(&server);
361/// assert!(issues.is_empty()); // Valid configuration
362/// ```
363#[must_use]
364pub fn validate_mcp_server(server: &McpServer) -> Vec<ValidationIssue> {
365    match server {
366        McpServer::Stdio(s) => validate_stdio(s),
367        McpServer::Sse(s) => validate_sse(s),
368        McpServer::Http(s) => validate_http(s),
369    }
370}
371
372/// Validates an MCP server configuration for a specific harness.
373///
374/// Combines base validation with harness-specific capability checks.
375/// Returns all issues found, including structural problems and harness incompatibilities.
376#[must_use]
377pub fn validate_for_harness(server: &McpServer, kind: HarnessKind) -> Vec<ValidationIssue> {
378    let mut issues = validate_mcp_server(server);
379    let caps = McpCapabilities::for_kind(kind);
380    let harness_name = kind.as_str();
381
382    match server {
383        McpServer::Stdio(s) => {
384            if s.cwd.is_some() && !caps.cwd {
385                issues.push(ValidationIssue::error(
386                    "cwd",
387                    format!("Working directory not supported by {harness_name}"),
388                    Some(CODE_CWD_UNSUPPORTED),
389                ));
390            }
391            if !s.enabled && !caps.toggle {
392                issues.push(ValidationIssue::warning(
393                    "enabled",
394                    format!("{harness_name} ignores the enabled field; server will always run"),
395                    Some(CODE_TOGGLE_UNSUPPORTED),
396                ));
397            }
398        }
399        McpServer::Sse(s) => {
400            if kind == HarnessKind::ClaudeCode {
401                issues.push(ValidationIssue::warning(
402                    "transport",
403                    "SSE transport works but HTTP is preferred for Claude Code",
404                    Some(CODE_SSE_DEPRECATED),
405                ));
406            }
407            if !s.enabled && !caps.toggle {
408                issues.push(ValidationIssue::warning(
409                    "enabled",
410                    format!("{harness_name} ignores the enabled field; server will always run"),
411                    Some(CODE_TOGGLE_UNSUPPORTED),
412                ));
413            }
414        }
415        McpServer::Http(s) => {
416            if !s.enabled && !caps.toggle {
417                issues.push(ValidationIssue::warning(
418                    "enabled",
419                    format!("{harness_name} ignores the enabled field; server will always run"),
420                    Some(CODE_TOGGLE_UNSUPPORTED),
421                ));
422            }
423        }
424    }
425
426    issues
427}
428
429/// Validates agent frontmatter content for a specific harness.
430///
431/// Returns an empty vector if valid, or a list of issues found.
432/// Returns a single `CODE_AGENT_UNSUPPORTED` error if harness doesn't support agents.
433#[must_use]
434pub fn validate_agent_for_harness(content: &str, kind: HarnessKind) -> Vec<ValidationIssue> {
435    let mut issues = Vec::new();
436
437    let caps = match AgentCapabilities::for_kind(kind) {
438        Some(c) => c,
439        None => {
440            issues.push(ValidationIssue::error(
441                "agent",
442                format!("{} does not support agents", kind.as_str()),
443                Some(CODE_AGENT_UNSUPPORTED),
444            ));
445            return issues;
446        }
447    };
448
449    let frontmatter = match crate::skill::parse_frontmatter(content) {
450        Ok(fm) => fm,
451        Err(e) => {
452            issues.push(ValidationIssue::error(
453                "frontmatter",
454                format!("failed to parse frontmatter: {e}"),
455                Some(CODE_AGENT_PARSE_ERROR),
456            ));
457            return issues;
458        }
459    };
460
461    let yaml = match &frontmatter.yaml {
462        Some(y) => y,
463        None => return issues,
464    };
465
466    if let Some(tools) = yaml.get("tools") {
467        issues.extend(validate_tools_format(tools, caps.tools_format, kind));
468    }
469
470    if let Some(color) = yaml.get("color").and_then(|v| v.as_str()) {
471        issues.extend(validate_color_format(color, caps.color_format, kind));
472    }
473
474    if let Some(mode) = yaml.get("mode").and_then(|v| v.as_str())
475        && !caps.supported_modes.contains(&mode)
476    {
477        issues.push(ValidationIssue::error(
478            "mode",
479            format!(
480                "mode '{}' not supported by {}; valid: {:?}",
481                mode,
482                kind.as_str(),
483                caps.supported_modes
484            ),
485            Some(CODE_AGENT_MODE_UNSUPPORTED),
486        ));
487    }
488
489    issues
490}
491
492/// Validates skill frontmatter content for a specific harness.
493///
494/// Returns an empty vector if valid, or a list of issues found.
495/// Returns a single `CODE_SKILL_UNSUPPORTED` error if harness doesn't support skills.
496#[must_use]
497pub fn validate_skill_for_harness(
498    content: &str,
499    directory_name: &str,
500    kind: HarnessKind,
501) -> Vec<ValidationIssue> {
502    let mut issues = Vec::new();
503
504    let caps = match SkillCapabilities::for_kind(kind) {
505        Some(c) => c,
506        None => {
507            issues.push(ValidationIssue::error(
508                "skill",
509                format!("{} does not support skills", kind.as_str()),
510                Some(CODE_SKILL_UNSUPPORTED),
511            ));
512            return issues;
513        }
514    };
515
516    let frontmatter = match crate::skill::parse_frontmatter(content) {
517        Ok(fm) => fm,
518        Err(e) => {
519            issues.push(ValidationIssue::error(
520                "frontmatter",
521                format!("failed to parse frontmatter: {e}"),
522                Some(CODE_SKILL_PARSE_ERROR),
523            ));
524            return issues;
525        }
526    };
527
528    let yaml = match &frontmatter.yaml {
529        Some(y) => y,
530        None => return issues,
531    };
532
533    if let Some(name) = yaml.get("name").and_then(|v| v.as_str()) {
534        if caps.name_format == NameFormat::LowercaseHyphenated && !SKILL_NAME_RE.is_match(name) {
535            issues.push(ValidationIssue::error(
536                "name",
537                format!(
538                    "name '{}' must be lowercase alphanumeric with hyphens (regex: {})",
539                    name, SKILL_NAME_REGEX
540                ),
541                Some(CODE_SKILL_NAME_FORMAT),
542            ));
543        }
544
545        if name.len() > SKILL_NAME_MAX_LEN {
546            issues.push(ValidationIssue::error(
547                "name",
548                format!("name exceeds {} characters", SKILL_NAME_MAX_LEN),
549                Some(CODE_SKILL_NAME_LENGTH),
550            ));
551        }
552
553        if caps.name_must_match_directory && name != directory_name {
554            issues.push(ValidationIssue::error(
555                "name",
556                format!(
557                    "name '{}' must match directory name '{}'",
558                    name, directory_name
559                ),
560                Some(CODE_SKILL_NAME_DIRECTORY_MISMATCH),
561            ));
562        }
563    }
564
565    if let Some(description) = yaml.get("description").and_then(|v| v.as_str()) {
566        if description.len() > SKILL_DESCRIPTION_MAX_LEN {
567            issues.push(ValidationIssue::error(
568                "description",
569                format!(
570                    "description exceeds {} characters",
571                    SKILL_DESCRIPTION_MAX_LEN
572                ),
573                Some(CODE_SKILL_DESCRIPTION_LENGTH),
574            ));
575        }
576    } else if caps.description_required {
577        issues.push(ValidationIssue::warning(
578            "description",
579            format!("{} recommends a description field", kind.as_str()),
580            Some(CODE_SKILL_DESCRIPTION_MISSING),
581        ));
582    }
583
584    issues
585}
586
587fn validate_tools_format(
588    tools: &serde_yaml::Value,
589    expected: ToolsFormat,
590    kind: HarnessKind,
591) -> Vec<ValidationIssue> {
592    let mut issues = Vec::new();
593
594    match expected {
595        ToolsFormat::BooleanRecord => {
596            if !tools.is_mapping() {
597                issues.push(ValidationIssue::error(
598                    "tools",
599                    format!(
600                        "{} requires tools as object (e.g., {{ bash: true }}), got {}",
601                        kind.as_str(),
602                        yaml_type_name(tools)
603                    ),
604                    Some(CODE_AGENT_TOOLS_FORMAT),
605                ));
606            }
607        }
608        ToolsFormat::CommaSeparatedString => {
609            if !tools.is_string() {
610                issues.push(ValidationIssue::error(
611                    "tools",
612                    format!(
613                        "{} requires tools as comma-separated string, got {}",
614                        kind.as_str(),
615                        yaml_type_name(tools)
616                    ),
617                    Some(CODE_AGENT_TOOLS_FORMAT),
618                ));
619            }
620        }
621    }
622
623    issues
624}
625
626fn validate_color_format(
627    color: &str,
628    expected: ColorFormat,
629    kind: HarnessKind,
630) -> Vec<ValidationIssue> {
631    let mut issues = Vec::new();
632
633    match expected {
634        ColorFormat::HexOnly => {
635            if !is_hex_color(color) {
636                issues.push(ValidationIssue::error(
637                    "color",
638                    format!(
639                        "{} requires hex color (#RRGGBB), got '{}'",
640                        kind.as_str(),
641                        color
642                    ),
643                    Some(CODE_AGENT_COLOR_FORMAT),
644                ));
645            }
646        }
647        ColorFormat::NamedOrHex => {}
648    }
649
650    issues
651}
652
653fn yaml_type_name(value: &serde_yaml::Value) -> &'static str {
654    match value {
655        serde_yaml::Value::Null => "null",
656        serde_yaml::Value::Bool(_) => "boolean",
657        serde_yaml::Value::Number(_) => "number",
658        serde_yaml::Value::String(_) => "string",
659        serde_yaml::Value::Sequence(_) => "array",
660        serde_yaml::Value::Mapping(_) => "object",
661        serde_yaml::Value::Tagged(_) => "tagged",
662    }
663}
664
665fn is_hex_color(s: &str) -> bool {
666    s.len() == 7 && s.starts_with('#') && s[1..].chars().all(|c| c.is_ascii_hexdigit())
667}
668
669fn validate_stdio(server: &StdioMcpServer) -> Vec<ValidationIssue> {
670    let mut issues = Vec::new();
671
672    if server.command.trim().is_empty() {
673        issues.push(ValidationIssue::error(
674            "command",
675            "Command must not be empty",
676            Some(CODE_EMPTY_COMMAND),
677        ));
678    }
679
680    issues.extend(validate_timeout(server.timeout_ms, "timeout_ms"));
681    issues.extend(validate_env(&server.env, "env"));
682    issues
683}
684
685fn validate_sse(server: &SseMcpServer) -> Vec<ValidationIssue> {
686    let mut issues = Vec::new();
687
688    issues.extend(validate_url(&server.url, "url"));
689    issues.extend(validate_timeout(server.timeout_ms, "timeout_ms"));
690    issues.extend(validate_env(&server.headers, "headers"));
691    issues
692}
693
694fn validate_http(server: &HttpMcpServer) -> Vec<ValidationIssue> {
695    let mut issues = Vec::new();
696
697    issues.extend(validate_url(&server.url, "url"));
698    issues.extend(validate_timeout(server.timeout_ms, "timeout_ms"));
699    issues.extend(validate_env(&server.headers, "headers"));
700    issues
701}
702fn validate_url(url: &str, field: &str) -> Vec<ValidationIssue> {
703    let mut issues = Vec::new();
704
705    match Url::parse(url) {
706        Ok(parsed) => {
707            let scheme = parsed.scheme();
708            if scheme != "http" && scheme != "https" {
709                issues.push(ValidationIssue::error(
710                    field,
711                    format!("URL scheme must be http or https, got '{scheme}'"),
712                    Some(CODE_INVALID_SCHEME),
713                ));
714            }
715        }
716        Err(e) => {
717            issues.push(ValidationIssue::error(
718                field,
719                format!("Invalid URL: {e}"),
720                Some(CODE_INVALID_URL),
721            ));
722        }
723    }
724
725    issues
726}
727
728fn validate_timeout(timeout_ms: Option<u64>, field: &str) -> Vec<ValidationIssue> {
729    let mut issues = Vec::new();
730
731    if let Some(ms) = timeout_ms
732        && ms > MAX_RECOMMENDED_TIMEOUT_MS
733    {
734        issues.push(ValidationIssue::warning(
735            field,
736            format!(
737                "Timeout of {}ms exceeds recommended maximum of {}ms (5 minutes)",
738                ms, MAX_RECOMMENDED_TIMEOUT_MS
739            ),
740            Some(CODE_TIMEOUT_EXCESSIVE),
741        ));
742    }
743
744    issues
745}
746
747fn validate_env(env: &HashMap<String, EnvValue>, field_prefix: &str) -> Vec<ValidationIssue> {
748    let mut issues = Vec::new();
749
750    for key in env.keys() {
751        let upper = key.to_uppercase();
752        for pattern in SUSPICIOUS_ENV_PATTERNS {
753            if upper.contains(pattern) {
754                issues.push(ValidationIssue::warning(
755                    format!("{field_prefix}.{key}"),
756                    format!(
757                        "Variable name '{key}' suggests sensitive data; \
758                         consider using environment variable references"
759                    ),
760                    Some(CODE_SUSPICIOUS_ENV),
761                ));
762                break;
763            }
764        }
765    }
766
767    issues
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    fn make_stdio(command: &str) -> McpServer {
775        McpServer::Stdio(StdioMcpServer {
776            command: command.to_string(),
777            args: vec![],
778            env: HashMap::new(),
779            cwd: None,
780            enabled: true,
781            timeout_ms: None,
782        })
783    }
784
785    fn make_sse(url: &str) -> McpServer {
786        McpServer::Sse(SseMcpServer {
787            url: url.to_string(),
788            headers: HashMap::new(),
789            enabled: true,
790            timeout_ms: None,
791        })
792    }
793
794    fn make_http(url: &str) -> McpServer {
795        McpServer::Http(HttpMcpServer {
796            url: url.to_string(),
797            headers: HashMap::new(),
798            oauth: None,
799            enabled: true,
800            timeout_ms: None,
801        })
802    }
803
804    #[test]
805    fn empty_command_returns_error() {
806        let server = make_stdio("");
807        let issues = validate_mcp_server(&server);
808
809        assert_eq!(issues.len(), 1);
810        assert_eq!(issues[0].severity, Severity::Error);
811        assert_eq!(issues[0].field, "command");
812        assert_eq!(issues[0].code, Some(CODE_EMPTY_COMMAND));
813    }
814
815    #[test]
816    fn valid_command_returns_no_issues() {
817        let server = make_stdio("node");
818        let issues = validate_mcp_server(&server);
819
820        assert!(issues.is_empty());
821    }
822
823    #[test]
824    fn invalid_url_returns_error() {
825        let server = make_sse("not-a-valid-url");
826        let issues = validate_mcp_server(&server);
827
828        assert_eq!(issues.len(), 1);
829        assert_eq!(issues[0].severity, Severity::Error);
830        assert_eq!(issues[0].field, "url");
831        assert_eq!(issues[0].code, Some(CODE_INVALID_URL));
832    }
833
834    #[test]
835    fn valid_https_url_returns_no_issues() {
836        let server = make_http("https://example.com/mcp");
837        let issues = validate_mcp_server(&server);
838
839        assert!(issues.is_empty());
840    }
841
842    #[test]
843    fn ftp_scheme_returns_error() {
844        let server = make_sse("ftp://files.example.com");
845        let issues = validate_mcp_server(&server);
846
847        assert_eq!(issues.len(), 1);
848        assert_eq!(issues[0].severity, Severity::Error);
849        assert_eq!(issues[0].field, "url");
850        assert_eq!(issues[0].code, Some(CODE_INVALID_SCHEME));
851        assert!(issues[0].message.contains("ftp"));
852    }
853
854    #[test]
855    fn excessive_timeout_returns_warning() {
856        let server = McpServer::Stdio(StdioMcpServer {
857            command: "node".to_string(),
858            args: vec![],
859            env: HashMap::new(),
860            cwd: None,
861            enabled: true,
862            timeout_ms: Some(600_000),
863        });
864        let issues = validate_mcp_server(&server);
865
866        assert_eq!(issues.len(), 1);
867        assert_eq!(issues[0].severity, Severity::Warning);
868        assert_eq!(issues[0].field, "timeout_ms");
869        assert_eq!(issues[0].code, Some(CODE_TIMEOUT_EXCESSIVE));
870    }
871
872    #[test]
873    fn normal_timeout_returns_no_issues() {
874        let server = McpServer::Stdio(StdioMcpServer {
875            command: "node".to_string(),
876            args: vec![],
877            env: HashMap::new(),
878            cwd: None,
879            enabled: true,
880            timeout_ms: Some(30_000),
881        });
882        let issues = validate_mcp_server(&server);
883
884        assert!(issues.is_empty());
885    }
886
887    #[test]
888    fn suspicious_env_name_returns_warning() {
889        let mut env = HashMap::new();
890        env.insert("DB_PASSWORD".to_string(), EnvValue::plain("secret123"));
891
892        let server = McpServer::Stdio(StdioMcpServer {
893            command: "node".to_string(),
894            args: vec![],
895            env,
896            cwd: None,
897            enabled: true,
898            timeout_ms: None,
899        });
900        let issues = validate_mcp_server(&server);
901
902        assert_eq!(issues.len(), 1);
903        assert_eq!(issues[0].severity, Severity::Warning);
904        assert_eq!(issues[0].field, "env.DB_PASSWORD");
905        assert_eq!(issues[0].code, Some(CODE_SUSPICIOUS_ENV));
906    }
907
908    #[test]
909    fn normal_env_name_returns_no_issues() {
910        let mut env = HashMap::new();
911        env.insert("NODE_ENV".to_string(), EnvValue::plain("production"));
912        env.insert("PORT".to_string(), EnvValue::plain("3000"));
913
914        let server = McpServer::Stdio(StdioMcpServer {
915            command: "node".to_string(),
916            args: vec![],
917            env,
918            cwd: None,
919            enabled: true,
920            timeout_ms: None,
921        });
922        let issues = validate_mcp_server(&server);
923
924        assert!(issues.is_empty());
925    }
926
927    #[test]
928    fn multiple_issues_collected() {
929        let mut env = HashMap::new();
930        env.insert("API_TOKEN".to_string(), EnvValue::plain("tok_123"));
931
932        let server = McpServer::Stdio(StdioMcpServer {
933            command: "".to_string(),
934            args: vec![],
935            env,
936            cwd: None,
937            enabled: true,
938            timeout_ms: Some(600_000),
939        });
940        let issues = validate_mcp_server(&server);
941
942        assert_eq!(issues.len(), 3);
943        let error_count = issues
944            .iter()
945            .filter(|i| i.severity == Severity::Error)
946            .count();
947        let warning_count = issues
948            .iter()
949            .filter(|i| i.severity == Severity::Warning)
950            .count();
951        assert_eq!(error_count, 1);
952        assert_eq!(warning_count, 2);
953        assert!(issues.iter().any(|i| i.code == Some(CODE_EMPTY_COMMAND)));
954        assert!(
955            issues
956                .iter()
957                .any(|i| i.code == Some(CODE_TIMEOUT_EXCESSIVE))
958        );
959        assert!(issues.iter().any(|i| i.code == Some(CODE_SUSPICIOUS_ENV)));
960    }
961
962    #[test]
963    fn valid_config_returns_empty_vec() {
964        let mut env = HashMap::new();
965        env.insert("NODE_ENV".to_string(), EnvValue::plain("production"));
966
967        let server = McpServer::Stdio(StdioMcpServer {
968            command: "node".to_string(),
969            args: vec!["server.js".to_string()],
970            env,
971            cwd: None,
972            enabled: true,
973            timeout_ms: Some(30_000),
974        });
975        let issues = validate_mcp_server(&server);
976
977        assert!(issues.is_empty());
978    }
979
980    // Harness-specific validation tests
981
982    #[test]
983    fn cwd_on_any_harness_returns_error() {
984        let server = McpServer::Stdio(StdioMcpServer {
985            command: "node".to_string(),
986            args: vec![],
987            env: HashMap::new(),
988            cwd: Some(std::path::PathBuf::from("/tmp")),
989            enabled: true,
990            timeout_ms: None,
991        });
992
993        for kind in HarnessKind::ALL {
994            let issues = validate_for_harness(&server, *kind);
995            assert!(issues.iter().any(|i| i.code == Some(CODE_CWD_UNSUPPORTED)));
996        }
997    }
998
999    #[test]
1000    fn disabled_on_claude_code_returns_warning() {
1001        let server = McpServer::Stdio(StdioMcpServer {
1002            command: "node".to_string(),
1003            args: vec![],
1004            env: HashMap::new(),
1005            cwd: None,
1006            enabled: false,
1007            timeout_ms: None,
1008        });
1009
1010        let issues = validate_for_harness(&server, HarnessKind::ClaudeCode);
1011        assert!(
1012            issues
1013                .iter()
1014                .any(|i| i.code == Some(CODE_TOGGLE_UNSUPPORTED))
1015        );
1016    }
1017
1018    #[test]
1019    fn disabled_on_opencode_returns_no_warning() {
1020        let server = McpServer::Stdio(StdioMcpServer {
1021            command: "node".to_string(),
1022            args: vec![],
1023            env: HashMap::new(),
1024            cwd: None,
1025            enabled: false,
1026            timeout_ms: None,
1027        });
1028
1029        let issues = validate_for_harness(&server, HarnessKind::OpenCode);
1030        assert!(
1031            !issues
1032                .iter()
1033                .any(|i| i.code == Some(CODE_TOGGLE_UNSUPPORTED))
1034        );
1035    }
1036
1037    #[test]
1038    fn sse_on_claude_code_returns_warning() {
1039        let server = McpServer::Sse(SseMcpServer {
1040            url: "https://example.com/sse".to_string(),
1041            headers: HashMap::new(),
1042            enabled: true,
1043            timeout_ms: None,
1044        });
1045
1046        let issues = validate_for_harness(&server, HarnessKind::ClaudeCode);
1047        assert!(issues.iter().any(|i| i.code == Some(CODE_SSE_DEPRECATED)));
1048    }
1049
1050    #[test]
1051    fn sse_on_opencode_returns_no_warning() {
1052        let server = McpServer::Sse(SseMcpServer {
1053            url: "https://example.com/sse".to_string(),
1054            headers: HashMap::new(),
1055            enabled: true,
1056            timeout_ms: None,
1057        });
1058
1059        let issues = validate_for_harness(&server, HarnessKind::OpenCode);
1060        assert!(!issues.iter().any(|i| i.code == Some(CODE_SSE_DEPRECATED)));
1061    }
1062
1063    #[test]
1064    fn validate_for_harness_includes_base_validation() {
1065        let server = McpServer::Stdio(StdioMcpServer {
1066            command: "".to_string(),
1067            args: vec![],
1068            env: HashMap::new(),
1069            cwd: Some(std::path::PathBuf::from("/tmp")),
1070            enabled: true,
1071            timeout_ms: None,
1072        });
1073
1074        let issues = validate_for_harness(&server, HarnessKind::ClaudeCode);
1075        assert!(issues.iter().any(|i| i.code == Some(CODE_EMPTY_COMMAND)));
1076        assert!(issues.iter().any(|i| i.code == Some(CODE_CWD_UNSUPPORTED)));
1077    }
1078
1079    // Agent validation tests
1080
1081    #[test]
1082    fn opencode_rejects_comma_string_tools() {
1083        let content = "---\ntools: Glob, Grep, Read\n---\nAgent prompt";
1084        let issues = validate_agent_for_harness(content, HarnessKind::OpenCode);
1085        assert!(
1086            issues
1087                .iter()
1088                .any(|i| i.code == Some(CODE_AGENT_TOOLS_FORMAT))
1089        );
1090    }
1091
1092    #[test]
1093    fn opencode_accepts_boolean_record_tools() {
1094        let content = "---\ntools:\n  bash: true\n  edit: false\n---\nAgent prompt";
1095        let issues = validate_agent_for_harness(content, HarnessKind::OpenCode);
1096        assert!(
1097            !issues
1098                .iter()
1099                .any(|i| i.code == Some(CODE_AGENT_TOOLS_FORMAT))
1100        );
1101    }
1102
1103    #[test]
1104    fn opencode_rejects_named_color() {
1105        let content = "---\ncolor: red\n---\nAgent prompt";
1106        let issues = validate_agent_for_harness(content, HarnessKind::OpenCode);
1107        assert!(
1108            issues
1109                .iter()
1110                .any(|i| i.code == Some(CODE_AGENT_COLOR_FORMAT))
1111        );
1112    }
1113
1114    #[test]
1115    fn opencode_accepts_hex_color() {
1116        let content = "---\ncolor: \"#FF5733\"\n---\nAgent prompt";
1117        let issues = validate_agent_for_harness(content, HarnessKind::OpenCode);
1118        assert!(
1119            !issues
1120                .iter()
1121                .any(|i| i.code == Some(CODE_AGENT_COLOR_FORMAT))
1122        );
1123    }
1124
1125    #[test]
1126    fn claude_code_accepts_comma_string_tools() {
1127        let content = "---\ntools: Glob, Grep, Read\n---\nAgent prompt";
1128        let issues = validate_agent_for_harness(content, HarnessKind::ClaudeCode);
1129        assert!(
1130            !issues
1131                .iter()
1132                .any(|i| i.code == Some(CODE_AGENT_TOOLS_FORMAT))
1133        );
1134    }
1135
1136    #[test]
1137    fn claude_code_accepts_named_color() {
1138        let content = "---\ncolor: red\n---\nAgent prompt";
1139        let issues = validate_agent_for_harness(content, HarnessKind::ClaudeCode);
1140        assert!(
1141            !issues
1142                .iter()
1143                .any(|i| i.code == Some(CODE_AGENT_COLOR_FORMAT))
1144        );
1145    }
1146
1147    #[test]
1148    fn goose_returns_unsupported_error() {
1149        let content = "---\nname: test\n---\nAgent prompt";
1150        let issues = validate_agent_for_harness(content, HarnessKind::Goose);
1151        assert!(
1152            issues
1153                .iter()
1154                .any(|i| i.code == Some(CODE_AGENT_UNSUPPORTED))
1155        );
1156    }
1157
1158    #[test]
1159    fn invalid_yaml_returns_parse_error() {
1160        let content = "---\ntools: [unclosed bracket\n---\nAgent prompt";
1161        let issues = validate_agent_for_harness(content, HarnessKind::OpenCode);
1162        assert!(
1163            issues
1164                .iter()
1165                .any(|i| i.code == Some(CODE_AGENT_PARSE_ERROR))
1166        );
1167    }
1168
1169    #[test]
1170    fn missing_frontmatter_is_valid() {
1171        let content = "Just the agent prompt, no frontmatter";
1172        let issues = validate_agent_for_harness(content, HarnessKind::OpenCode);
1173        assert!(issues.is_empty());
1174    }
1175
1176    #[test]
1177    fn invalid_mode_returns_error() {
1178        let content = "---\nmode: invalid_mode\n---\nAgent prompt";
1179        let issues = validate_agent_for_harness(content, HarnessKind::OpenCode);
1180        assert!(
1181            issues
1182                .iter()
1183                .any(|i| i.code == Some(CODE_AGENT_MODE_UNSUPPORTED))
1184        );
1185    }
1186
1187    #[test]
1188    fn valid_mode_accepted() {
1189        let content = "---\nmode: subagent\n---\nAgent prompt";
1190        let issues = validate_agent_for_harness(content, HarnessKind::OpenCode);
1191        assert!(
1192            !issues
1193                .iter()
1194                .any(|i| i.code == Some(CODE_AGENT_MODE_UNSUPPORTED))
1195        );
1196    }
1197
1198    #[test]
1199    fn is_hex_color_validates_correctly() {
1200        assert!(is_hex_color("#FF5733"));
1201        assert!(is_hex_color("#000000"));
1202        assert!(is_hex_color("#ffffff"));
1203        assert!(!is_hex_color("red"));
1204        assert!(!is_hex_color("#FFF"));
1205        assert!(!is_hex_color("FF5733"));
1206        assert!(!is_hex_color("#GGGGGG"));
1207    }
1208
1209    #[test]
1210    fn opencode_rejects_uppercase_skill_name() {
1211        let content = "---\nname: Hook Development\ndescription: test\n---\nSkill content";
1212        let issues = validate_skill_for_harness(content, "hook-development", HarnessKind::OpenCode);
1213        assert!(
1214            issues
1215                .iter()
1216                .any(|i| i.code == Some(CODE_SKILL_NAME_FORMAT))
1217        );
1218    }
1219
1220    #[test]
1221    fn opencode_rejects_name_directory_mismatch() {
1222        let content = "---\nname: other-name\ndescription: test\n---\nSkill content";
1223        let issues = validate_skill_for_harness(content, "actual-directory", HarnessKind::OpenCode);
1224        assert!(
1225            issues
1226                .iter()
1227                .any(|i| i.code == Some(CODE_SKILL_NAME_DIRECTORY_MISMATCH))
1228        );
1229    }
1230
1231    #[test]
1232    fn opencode_accepts_valid_skill() {
1233        let content = "---\nname: my-skill\ndescription: A valid skill\n---\nSkill content";
1234        let issues = validate_skill_for_harness(content, "my-skill", HarnessKind::OpenCode);
1235        assert!(issues.is_empty());
1236    }
1237
1238    #[test]
1239    fn claude_code_accepts_any_skill_name() {
1240        let content = "---\nname: Hook Development\ndescription: test\n---\nSkill content";
1241        let issues =
1242            validate_skill_for_harness(content, "Hook Development", HarnessKind::ClaudeCode);
1243        assert!(issues.is_empty());
1244    }
1245
1246    #[test]
1247    fn opencode_warns_missing_description() {
1248        let content = "---\nname: my-skill\n---\nSkill content";
1249        let issues = validate_skill_for_harness(content, "my-skill", HarnessKind::OpenCode);
1250        assert!(
1251            issues
1252                .iter()
1253                .any(|i| i.code == Some(CODE_SKILL_DESCRIPTION_MISSING))
1254        );
1255        assert!(issues.iter().all(|i| i.severity == Severity::Warning));
1256    }
1257
1258    #[test]
1259    fn opencode_rejects_long_skill_name() {
1260        let long_name = "a".repeat(65);
1261        let content = format!(
1262            "---\nname: {}\ndescription: test\n---\nSkill content",
1263            long_name
1264        );
1265        let issues = validate_skill_for_harness(&content, &long_name, HarnessKind::OpenCode);
1266        assert!(
1267            issues
1268                .iter()
1269                .any(|i| i.code == Some(CODE_SKILL_NAME_LENGTH))
1270        );
1271    }
1272
1273    #[test]
1274    fn opencode_rejects_long_description() {
1275        let long_desc = "a".repeat(1025);
1276        let content = format!(
1277            "---\nname: my-skill\ndescription: {}\n---\nSkill content",
1278            long_desc
1279        );
1280        let issues = validate_skill_for_harness(&content, "my-skill", HarnessKind::OpenCode);
1281        assert!(
1282            issues
1283                .iter()
1284                .any(|i| i.code == Some(CODE_SKILL_DESCRIPTION_LENGTH))
1285        );
1286    }
1287
1288    #[test]
1289    fn skill_name_regex_validates_correctly() {
1290        assert!(SKILL_NAME_RE.is_match("my-skill"));
1291        assert!(SKILL_NAME_RE.is_match("a"));
1292        assert!(SKILL_NAME_RE.is_match("skill123"));
1293        assert!(SKILL_NAME_RE.is_match("my-long-skill-name"));
1294        assert!(!SKILL_NAME_RE.is_match("My-Skill"));
1295        assert!(!SKILL_NAME_RE.is_match("my--skill"));
1296        assert!(!SKILL_NAME_RE.is_match("-my-skill"));
1297        assert!(!SKILL_NAME_RE.is_match("my-skill-"));
1298        assert!(!SKILL_NAME_RE.is_match("my skill"));
1299    }
1300
1301    #[test]
1302    fn skill_capabilities_for_opencode() {
1303        let caps = SkillCapabilities::for_kind(HarnessKind::OpenCode).unwrap();
1304        assert_eq!(caps.name_format, NameFormat::LowercaseHyphenated);
1305        assert!(caps.name_must_match_directory);
1306        assert!(caps.description_required);
1307    }
1308
1309    #[test]
1310    fn skill_capabilities_for_claude_code() {
1311        let caps = SkillCapabilities::for_kind(HarnessKind::ClaudeCode).unwrap();
1312        assert_eq!(caps.name_format, NameFormat::Any);
1313        assert!(!caps.name_must_match_directory);
1314        assert!(!caps.description_required);
1315    }
1316
1317    #[test]
1318    fn goose_returns_skill_unsupported() {
1319        let content = "---\nname: test\n---\nSkill content";
1320        let issues = validate_skill_for_harness(content, "test", HarnessKind::Goose);
1321        assert!(
1322            issues
1323                .iter()
1324                .any(|i| i.code == Some(CODE_SKILL_UNSUPPORTED))
1325        );
1326    }
1327
1328    #[test]
1329    fn skill_invalid_yaml_returns_parse_error() {
1330        let content = "---\nname: [unclosed\n---\nSkill content";
1331        let issues = validate_skill_for_harness(content, "test", HarnessKind::OpenCode);
1332        assert!(
1333            issues
1334                .iter()
1335                .any(|i| i.code == Some(CODE_SKILL_PARSE_ERROR))
1336        );
1337    }
1338}