From 65aa5b2fb161cd4b62f21826b4a597813af5b929 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 15 Apr 2026 21:08:23 -0400 Subject: [PATCH] feat: support snake_case MCP schema variants --- src/config.rs | 63 ++++++++++++++++++++++++++++++++++-- testdata/snake-case-mcp.json | 10 ++++++ tests/integration_tests.rs | 21 ++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 testdata/snake-case-mcp.json diff --git a/src/config.rs b/src/config.rs index cf11c0c..b930b73 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,9 +25,15 @@ pub struct McpServer { pub url: Option, #[serde(default)] pub transport: Option, - #[serde(rename = "allowedDirectories", default)] + #[serde(rename = "allowedDirectories", alias = "allowed_directories", default)] pub allowed_directories: Option>, - #[serde(rename = "allowedTools", default)] + #[serde( + rename = "allowedTools", + alias = "allowed_tools", + alias = "toolAllowlist", + alias = "tool_allowlist", + default + )] pub allowed_tools: Option>, #[serde(default)] pub disabled: Option, @@ -96,19 +102,27 @@ pub struct ParsedConfig { /// /// Supports multiple common schema variants: /// - { "mcpServers": { ... } } +/// - { "mcp_servers": { ... } } /// - { "context_servers": { ... } } (e.g. Zed) +/// - { "contextServers": { ... } } /// - { "lsp": { "mcpServers": { ... } } } +/// - { "lsp": { "mcp_servers": { ... } } } /// - { "lsp": { "context_servers": { ... } } } +/// - { "lsp": { "contextServers": { ... } } } pub fn parse_config(json: &str) -> Result { let root: Value = serde_json::from_str(json)?; let mut servers: HashMap = HashMap::new(); merge_server_map(root.get("mcpServers"), &mut servers)?; + merge_server_map(root.get("mcp_servers"), &mut servers)?; merge_server_map(root.get("context_servers"), &mut servers)?; + merge_server_map(root.get("contextServers"), &mut servers)?; if let Some(lsp) = root.get("lsp") { merge_server_map(lsp.get("mcpServers"), &mut servers)?; + merge_server_map(lsp.get("mcp_servers"), &mut servers)?; merge_server_map(lsp.get("context_servers"), &mut servers)?; + merge_server_map(lsp.get("contextServers"), &mut servers)?; } Ok(McpConfig { @@ -354,6 +368,33 @@ mod tests { assert!(config.mcp_servers.contains_key("zed")); } + #[test] + fn test_parse_snake_case_mcp_servers_and_fields() { + let json = r#"{ + "mcp_servers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem@2025.7.1", "/Users/me/projects"], + "allowed_tools": ["read_file", "list_files"], + "allowed_directories": ["/Users/me/projects"] + } + } + }"#; + + let config = parse_config(json).unwrap(); + assert_eq!(config.mcp_servers.len(), 1); + + let server = &config.mcp_servers["filesystem"]; + assert_eq!( + server.allowed_tools.as_ref().map(|tools| tools.len()), + Some(2) + ); + assert_eq!( + server.allowed_directories.as_ref().map(|dirs| dirs.len()), + Some(1) + ); + } + #[test] fn test_parse_lsp_nested_mcp_servers() { let json = r#"{ @@ -371,6 +412,24 @@ mod tests { assert!(config.mcp_servers.contains_key("nested")); } + #[test] + fn test_parse_lsp_nested_snake_case_mcp_servers() { + let json = r#"{ + "lsp": { + "mcp_servers": { + "nested_snake": { + "command": "uvx", + "args": ["mcp-server"] + } + } + } + }"#; + + let config = parse_config(json).unwrap(); + assert_eq!(config.mcp_servers.len(), 1); + assert!(config.mcp_servers.contains_key("nested_snake")); + } + #[test] fn test_parse_empty_config() { let json = r#"{"mcpServers": {}}"#; diff --git a/testdata/snake-case-mcp.json b/testdata/snake-case-mcp.json new file mode 100644 index 0000000..6078595 --- /dev/null +++ b/testdata/snake-case-mcp.json @@ -0,0 +1,10 @@ +{ + "mcp_servers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem@2025.7.1", "/Users/me/projects/myapp"], + "allowed_directories": ["/Users/me/projects/myapp"], + "allowed_tools": ["read_file", "list_files"] + } + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index efc18a7..2625c94 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -363,6 +363,27 @@ fn test_detects_missing_allowlist() { assert!(findings.iter().any(|f| f["rule_id"] == "AW-007")); } +#[test] +fn test_snake_case_allowlist_and_directories_are_respected() { + let output = agentwise() + .args(["scan", "testdata/snake-case-mcp.json", "--format", "json"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let findings = parsed["findings"].as_array().unwrap(); + + assert!( + !findings + .iter() + .any(|f| f["rule_id"] == "AW-002" || f["rule_id"] == "AW-007"), + "Expected no AW-002/AW-007 findings for snake_case allowlist fields, got: {}", + stdout + ); +} + #[test] fn test_detects_pattern_wildcard_allowlist() { let output = agentwise()