1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3
4pub struct McpScopeCheckControl;
8
9fn matches_allowed(tool_ref: &str, pattern: &str) -> bool {
15 if pattern == tool_ref {
16 return true;
17 }
18 if let Some(prefix) = pattern.strip_suffix('*') {
19 tool_ref.starts_with(prefix)
20 } else {
21 false
22 }
23}
24
25impl Control for McpScopeCheckControl {
26 fn id(&self) -> ControlId {
27 builtin::id(builtin::MCP_SCOPE_CHECK)
28 }
29
30 fn description(&self) -> &'static str {
31 "MCP tool calls must stay within allowed scope (requires agent execution log)"
32 }
33
34 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
35 let id = self.id();
36
37 let calls = match &evidence.mcp_tool_calls {
39 EvidenceState::NotApplicable => {
40 return vec![ControlFinding::not_applicable(
41 id,
42 "No MCP tool call evidence applicable",
43 )];
44 }
45 EvidenceState::Missing { gaps } => {
46 return vec![ControlFinding::indeterminate(
47 id,
48 "MCP tool call evidence is missing",
49 vec![],
50 gaps.clone(),
51 )];
52 }
53 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => value,
54 };
55
56 if calls.is_empty() {
57 return vec![ControlFinding::satisfied(
58 id,
59 "No MCP tool calls recorded",
60 vec![],
61 )];
62 }
63
64 let (allowed_tools, forbidden_servers) = match &evidence.agent_spec {
66 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
67 (&value.allowed_tools, &value.forbidden_mcp_servers)
68 }
69 _ => (&vec![] as &Vec<String>, &vec![] as &Vec<String>),
70 };
71
72 let mut violations: Vec<String> = Vec::new();
73
74 for call in calls {
75 let tool_ref = format!("mcp:{}/{}", call.server, call.tool);
76
77 if forbidden_servers
79 .iter()
80 .any(|s| s.eq_ignore_ascii_case(&call.server))
81 {
82 violations.push(format!(
83 "{tool_ref} — server '{}' is forbidden",
84 call.server
85 ));
86 continue;
87 }
88
89 if !allowed_tools.is_empty()
91 && !allowed_tools.iter().any(|p| matches_allowed(&tool_ref, p))
92 {
93 violations.push(format!("{tool_ref} — not in allowed_tools"));
94 }
95 }
96
97 if violations.is_empty() {
98 vec![ControlFinding::satisfied(
99 id,
100 format!(
101 "All {} MCP tool call(s) are within allowed scope",
102 calls.len()
103 ),
104 vec![],
105 )]
106 } else {
107 let count = violations.len();
108 vec![ControlFinding::violated(
109 id,
110 format!("{count} MCP tool call(s) outside allowed scope"),
111 violations,
112 )]
113 }
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::control::ControlStatus;
121 use crate::evidence::*;
122
123 fn mcp_call(server: &str, tool: &str) -> McpToolCall {
124 McpToolCall {
125 server: server.to_string(),
126 tool: tool.to_string(),
127 success: true,
128 timestamp: None,
129 duration_ms: None,
130 }
131 }
132
133 #[test]
134 fn not_applicable_when_no_evidence() {
135 let b = EvidenceBundle::default();
136 let findings = McpScopeCheckControl.evaluate(&b);
137 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
138 }
139
140 #[test]
141 fn missing_evidence_indeterminate() {
142 let b = EvidenceBundle {
143 mcp_tool_calls: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
144 source: "mcp".into(),
145 subject: "calls".into(),
146 detail: "unavailable".into(),
147 }]),
148 ..Default::default()
149 };
150 let findings = McpScopeCheckControl.evaluate(&b);
151 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
152 }
153
154 #[test]
155 fn empty_calls_satisfied() {
156 let b = EvidenceBundle {
157 mcp_tool_calls: EvidenceState::complete(vec![]),
158 ..Default::default()
159 };
160 let findings = McpScopeCheckControl.evaluate(&b);
161 assert_eq!(findings[0].status, ControlStatus::Satisfied);
162 }
163
164 #[test]
165 fn no_restrictions_satisfied() {
166 let b = EvidenceBundle {
167 mcp_tool_calls: EvidenceState::complete(vec![
168 mcp_call("github", "create_pull_request"),
169 mcp_call("filesystem", "write_file"),
170 ]),
171 agent_spec: EvidenceState::complete(AgentSpec::default()),
172 ..Default::default()
173 };
174 let findings = McpScopeCheckControl.evaluate(&b);
175 assert_eq!(findings[0].status, ControlStatus::Satisfied);
176 }
177
178 #[test]
179 fn allowed_tools_exact_match() {
180 let b = EvidenceBundle {
181 mcp_tool_calls: EvidenceState::complete(vec![mcp_call(
182 "github",
183 "create_pull_request",
184 )]),
185 agent_spec: EvidenceState::complete(AgentSpec {
186 allowed_tools: vec!["mcp:github/create_pull_request".to_string()],
187 ..Default::default()
188 }),
189 ..Default::default()
190 };
191 let findings = McpScopeCheckControl.evaluate(&b);
192 assert_eq!(findings[0].status, ControlStatus::Satisfied);
193 }
194
195 #[test]
196 fn allowed_tools_wildcard_match() {
197 let b = EvidenceBundle {
198 mcp_tool_calls: EvidenceState::complete(vec![
199 mcp_call("github", "create_pull_request"),
200 mcp_call("github", "list_issues"),
201 ]),
202 agent_spec: EvidenceState::complete(AgentSpec {
203 allowed_tools: vec!["mcp:github/*".to_string()],
204 ..Default::default()
205 }),
206 ..Default::default()
207 };
208 let findings = McpScopeCheckControl.evaluate(&b);
209 assert_eq!(findings[0].status, ControlStatus::Satisfied);
210 }
211
212 #[test]
213 fn tool_not_in_allowed_list_violated() {
214 let b = EvidenceBundle {
215 mcp_tool_calls: EvidenceState::complete(vec![mcp_call("database", "execute_query")]),
216 agent_spec: EvidenceState::complete(AgentSpec {
217 allowed_tools: vec!["mcp:github/*".to_string()],
218 ..Default::default()
219 }),
220 ..Default::default()
221 };
222 let findings = McpScopeCheckControl.evaluate(&b);
223 assert_eq!(findings[0].status, ControlStatus::Violated);
224 assert!(findings[0].subjects[0].contains("not in allowed_tools"));
225 }
226
227 #[test]
228 fn forbidden_server_violated() {
229 let b = EvidenceBundle {
230 mcp_tool_calls: EvidenceState::complete(vec![mcp_call("database", "execute_query")]),
231 agent_spec: EvidenceState::complete(AgentSpec {
232 forbidden_mcp_servers: vec!["database".to_string()],
233 ..Default::default()
234 }),
235 ..Default::default()
236 };
237 let findings = McpScopeCheckControl.evaluate(&b);
238 assert_eq!(findings[0].status, ControlStatus::Violated);
239 assert!(findings[0].subjects[0].contains("forbidden"));
240 }
241
242 #[test]
243 fn forbidden_takes_precedence_over_allowed() {
244 let b = EvidenceBundle {
245 mcp_tool_calls: EvidenceState::complete(vec![mcp_call("database", "read_only")]),
246 agent_spec: EvidenceState::complete(AgentSpec {
247 allowed_tools: vec!["mcp:database/*".to_string()],
248 forbidden_mcp_servers: vec!["database".to_string()],
249 ..Default::default()
250 }),
251 ..Default::default()
252 };
253 let findings = McpScopeCheckControl.evaluate(&b);
254 assert_eq!(findings[0].status, ControlStatus::Violated);
255 assert!(findings[0].subjects[0].contains("forbidden"));
256 }
257
258 #[test]
259 fn mixed_allowed_and_forbidden() {
260 let b = EvidenceBundle {
261 mcp_tool_calls: EvidenceState::complete(vec![
262 mcp_call("github", "create_pr"),
263 mcp_call("admin", "delete_user"),
264 ]),
265 agent_spec: EvidenceState::complete(AgentSpec {
266 allowed_tools: vec!["mcp:github/*".to_string()],
267 forbidden_mcp_servers: vec!["admin".to_string()],
268 ..Default::default()
269 }),
270 ..Default::default()
271 };
272 let findings = McpScopeCheckControl.evaluate(&b);
273 assert_eq!(findings[0].status, ControlStatus::Violated);
274 assert_eq!(findings[0].subjects.len(), 1);
276 assert!(findings[0].subjects[0].contains("admin"));
277 }
278}