1use 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
44pub const CODE_EMPTY_COMMAND: &str = "stdio.command.empty";
48
49pub const CODE_INVALID_URL: &str = "url.invalid";
51
52pub const CODE_INVALID_SCHEME: &str = "url.scheme.invalid";
54
55pub const CODE_TIMEOUT_EXCESSIVE: &str = "timeout.excessive";
57
58pub const CODE_SUSPICIOUS_ENV: &str = "env.suspicious_name";
60
61pub const CODE_CWD_UNSUPPORTED: &str = "harness.cwd.unsupported";
63
64pub const CODE_TOGGLE_UNSUPPORTED: &str = "harness.toggle.unsupported";
66
67pub const CODE_SSE_DEPRECATED: &str = "harness.transport.sse_deprecated";
69
70pub const CODE_AGENT_TOOLS_FORMAT: &str = "agent.tools.format";
74
75pub const CODE_AGENT_COLOR_FORMAT: &str = "agent.color.format";
77
78pub const CODE_AGENT_MODE_UNSUPPORTED: &str = "agent.mode.unsupported";
80
81pub const CODE_AGENT_UNSUPPORTED: &str = "agent.unsupported";
83
84pub const CODE_AGENT_PARSE_ERROR: &str = "agent.parse_error";
86
87pub const CODE_SKILL_NAME_FORMAT: &str = "skill.name.invalid_format";
91
92pub const CODE_SKILL_NAME_LENGTH: &str = "skill.name.length";
94
95pub const CODE_SKILL_DESCRIPTION_LENGTH: &str = "skill.description.length";
97
98pub const CODE_SKILL_NAME_DIRECTORY_MISMATCH: &str = "skill.name.directory_mismatch";
100
101pub const CODE_SKILL_UNSUPPORTED: &str = "skill.unsupported";
103
104pub const CODE_SKILL_PARSE_ERROR: &str = "skill.parse_error";
106
107pub const CODE_SKILL_DESCRIPTION_MISSING: &str = "skill.description.missing";
109
110pub const SKILL_NAME_REGEX: &str = r"^[a-z0-9]+(-[a-z0-9]+)*$";
112
113pub const SKILL_NAME_MAX_LEN: usize = 64;
115
116pub const SKILL_DESCRIPTION_MAX_LEN: usize = 1024;
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
123pub enum Severity {
124 Error,
128
129 Warning,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum ToolsFormat {
138 BooleanRecord,
140 CommaSeparatedString,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum ColorFormat {
147 HexOnly,
149 NamedOrHex,
151}
152
153#[derive(Debug, Clone)]
155pub struct AgentCapabilities {
156 pub tools_format: ToolsFormat,
158 pub color_format: ColorFormat,
160 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum NameFormat {
191 LowercaseHyphenated,
193 Any,
195}
196
197#[derive(Debug, Clone)]
199pub struct SkillCapabilities {
200 pub name_format: NameFormat,
202 pub name_must_match_directory: bool,
204 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 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#[non_exhaustive]
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct ValidationIssue {
253 pub severity: Severity,
255
256 pub field: String,
258
259 pub message: String,
261
262 pub code: Option<&'static str>,
266}
267
268impl ValidationIssue {
269 #[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 #[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
312const MAX_RECOMMENDED_TIMEOUT_MS: u64 = 300_000;
314
315const 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#[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#[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#[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#[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 #[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 #[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}