1use super::audit::{AuditEvent, AuditLog};
2use super::decision::{
3 reason_codes, refresh_contract_projections, Decision, DecisionEmitter, DecisionEvent,
4 FileDecisionEmitter, NullDecisionEmitter,
5};
6use super::jsonrpc::JsonRpcRequest;
7use super::policy::{
8 make_deny_response, McpPolicy, PolicyDecision, PolicyMatchMetadata, PolicyState,
9};
10use super::tool_definition::{binding_from_tools_list_tool, ToolDefinitionBinding};
11use std::{
12 collections::HashMap,
13 io::{self, BufRead, BufReader, Write},
14 process::{Child, Command, Stdio},
15 sync::{Arc, Mutex},
16 thread,
17};
18
19#[derive(Clone, Debug)]
23pub struct ProxyConfig {
24 pub dry_run: bool,
25 pub verbose: bool,
26 pub audit_log_path: Option<std::path::PathBuf>,
28 pub server_id: String,
29 pub decision_log_path: Option<std::path::PathBuf>,
31 pub event_source: Option<String>,
33}
34
35#[derive(Clone, Debug, Default)]
37pub struct ProxyConfigRaw {
38 pub dry_run: bool,
39 pub verbose: bool,
40 pub audit_log_path: Option<std::path::PathBuf>,
41 pub server_id: String,
42 pub decision_log_path: Option<std::path::PathBuf>,
43 pub event_source: Option<String>,
44}
45
46impl ProxyConfig {
47 pub fn try_from_raw(raw: ProxyConfigRaw) -> anyhow::Result<Self> {
53 let logging_enabled = raw.audit_log_path.is_some() || raw.decision_log_path.is_some();
54
55 let event_source = raw
56 .event_source
57 .map(|s| s.trim().to_string())
58 .filter(|s| !s.is_empty());
59
60 if logging_enabled && event_source.is_none() {
61 anyhow::bail!(
62 "event_source is required when logging is enabled (e.g. --event-source assay://org/app)"
63 );
64 }
65
66 if let Some(ref src) = event_source {
67 validate_event_source(src)?;
68 }
69
70 Ok(ProxyConfig {
71 dry_run: raw.dry_run,
72 verbose: raw.verbose,
73 audit_log_path: raw.audit_log_path,
74 server_id: raw.server_id,
75 decision_log_path: raw.decision_log_path,
76 event_source,
77 })
78 }
79}
80
81fn validate_event_source(s: &str) -> anyhow::Result<()> {
83 let s = s.trim();
84 if s.is_empty() {
85 anyhow::bail!("event_source must be absolute URI with scheme (e.g. assay://org/app)");
86 }
87 if s.chars().any(|c| c.is_whitespace()) {
88 anyhow::bail!("event_source must not contain whitespace");
89 }
90
91 let Some(pos) = s.find("://") else {
93 anyhow::bail!("event_source must be absolute URI with scheme (e.g. assay://org/app)");
94 };
95 if pos == 0 {
96 anyhow::bail!("event_source must have scheme before :// (e.g. assay://org/app)");
97 }
98
99 let scheme = &s[..pos];
101 let mut chars = scheme.chars();
102 match chars.next() {
103 Some(c) if c.is_ascii_alphabetic() => {}
104 _ => anyhow::bail!("event_source URI scheme must start with a letter"),
105 }
106 if !chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
107 anyhow::bail!("event_source URI scheme contains invalid characters");
108 }
109
110 Ok(())
111}
112
113pub struct McpProxy {
114 child: Child,
115 policy: McpPolicy,
116 config: ProxyConfig,
117 identity_cache: Arc<Mutex<HashMap<String, super::identity::ToolIdentity>>>,
119 tool_definition_cache: Arc<Mutex<HashMap<String, ToolDefinitionBinding>>>,
121}
122
123impl Drop for McpProxy {
124 fn drop(&mut self) {
125 let _ = self.child.kill();
127 }
128}
129
130impl McpProxy {
131 pub fn spawn(
132 command: &str,
133 args: &[String],
134 policy: McpPolicy,
135 config: ProxyConfig,
136 ) -> io::Result<Self> {
137 let child = Command::new(command)
138 .args(args)
139 .stdin(Stdio::piped())
140 .stdout(Stdio::piped())
141 .stderr(Stdio::inherit()) .spawn()?;
143
144 Ok(Self {
145 child,
146 policy,
147 config,
148 identity_cache: Arc::new(Mutex::new(HashMap::new())),
149 tool_definition_cache: Arc::new(Mutex::new(HashMap::new())),
150 })
151 }
152
153 pub fn run(mut self) -> io::Result<i32> {
154 let mut child_stdin = self.child.stdin.take().expect("child stdin");
155 let child_stdout = self.child.stdout.take().expect("child stdout");
156
157 let stdout = Arc::new(Mutex::new(io::stdout()));
158 let policy = self.policy.clone();
159 let config = self.config.clone();
160 let identity_cache_a = self.identity_cache.clone();
161 let identity_cache_b = self.identity_cache.clone();
162 let tool_definition_cache_a = self.tool_definition_cache.clone();
163 let tool_definition_cache_b = self.tool_definition_cache.clone();
164
165 let decision_emitter: Arc<dyn DecisionEmitter> =
167 if let Some(path) = &config.decision_log_path {
168 Arc::new(FileDecisionEmitter::new(path)?)
169 } else {
170 Arc::new(NullDecisionEmitter)
171 };
172 let event_source = config
173 .event_source
174 .clone()
175 .unwrap_or_else(|| format!("assay://{}", config.server_id));
176
177 let stdout_a = stdout.clone();
179 let t_server_to_client = thread::spawn(move || -> io::Result<()> {
180 let mut reader = BufReader::new(child_stdout);
181 let mut line = String::new();
182
183 while reader.read_line(&mut line)? > 0 {
184 let mut processed_line = line.clone();
185
186 if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&line) {
188 if let Some(result) = v.get_mut("result") {
189 if let Some(tools) = result.get_mut("tools").and_then(|t| t.as_array_mut())
190 {
191 for tool in tools {
192 if let Some(observation) =
193 Self::observe_tool_definition(tool, &config.server_id)
194 {
195 let mut identity_cache = identity_cache_a.lock().unwrap();
196 identity_cache.insert(
197 observation.name.clone(),
198 observation.identity.clone(),
199 );
200 drop(identity_cache);
201
202 if let Some(binding) = observation.binding {
203 let mut binding_cache =
204 tool_definition_cache_a.lock().unwrap();
205 binding_cache.insert(observation.name, binding);
206 }
207 }
208 }
209 processed_line =
210 serde_json::to_string(&v).unwrap_or(line.clone()) + "\n";
211 }
212 }
213 }
214
215 let mut out = stdout_a
216 .lock()
217 .map_err(|e| io::Error::other(e.to_string()))?;
218 out.write_all(processed_line.as_bytes())?;
219 out.flush()?;
220 line.clear();
221 }
222 Ok(())
223 });
224
225 let stdout_b = stdout.clone();
227 let emitter_b = decision_emitter.clone();
228 let event_source_b = event_source.clone();
229 let t_client_to_server = thread::spawn(move || -> io::Result<()> {
230 let stdin = io::stdin();
231 let mut reader = stdin.lock();
232 let mut line = String::new();
233
234 let mut state = PolicyState::default();
235 let mut audit_log = AuditLog::new(config.audit_log_path.as_deref());
236
237 while reader.read_line(&mut line)? > 0 {
238 match serde_json::from_str::<JsonRpcRequest>(&line) {
240 Ok(req) => {
241 let (runtime_id, tool_definition_binding) = if req.is_tool_call() {
243 let name = req.tool_params().map(|p| p.name).unwrap_or_default();
244 let runtime_id = {
245 let cache = identity_cache_b.lock().unwrap();
246 cache.get(&name).cloned()
247 };
248 let tool_definition_binding = {
249 let cache = tool_definition_cache_b.lock().unwrap();
250 cache.get(&name).cloned()
251 };
252 (runtime_id, tool_definition_binding)
253 } else {
254 (None, None)
255 };
256
257 let tool_name = req.tool_params().map(|p| p.name).unwrap_or_default();
258 let tool_call_id = Self::extract_tool_call_id(&req);
259
260 let policy_eval = policy.evaluate_with_metadata(
261 &tool_name,
262 &req.tool_params()
263 .map(|p| p.arguments)
264 .unwrap_or(serde_json::Value::Null),
265 &mut state,
266 runtime_id.as_ref(),
267 );
268
269 match policy_eval.decision {
270 PolicyDecision::Allow => {
271 Self::handle_allow(&req, &mut audit_log, config.verbose);
272 if req.is_tool_call() {
274 Self::emit_decision(
275 &emitter_b,
276 &event_source_b,
277 &tool_call_id,
278 &tool_name,
279 Decision::Allow,
280 reason_codes::P_POLICY_ALLOW,
281 None,
282 req.id.clone(),
283 &policy_eval.metadata,
284 tool_definition_binding.as_ref(),
285 );
286 }
287 }
288 PolicyDecision::AllowWithWarning { tool, code, reason } => {
289 if config.verbose {
291 eprintln!(
292 "[assay] WARNING: Allowing tool '{}' with warning (code: {}, reason: {}).",
293 tool,
294 code,
295 reason
296 );
297 }
298 audit_log.log(&AuditEvent {
299 timestamp: chrono::Utc::now().to_rfc3339(),
300 decision: "allow_with_warning".to_string(),
301 tool: Some(tool.clone()),
302 reason: Some(reason.clone()),
303 request_id: req.id.clone(),
304 agentic: None,
305 });
306 Self::emit_decision(
308 &emitter_b,
309 &event_source_b,
310 &tool_call_id,
311 &tool,
312 Decision::Allow,
313 &code,
314 Some(reason),
315 req.id.clone(),
316 &policy_eval.metadata,
317 tool_definition_binding.as_ref(),
318 );
319 Self::handle_allow(&req, &mut audit_log, false);
321 }
323 PolicyDecision::Deny {
324 tool,
325 code,
326 reason,
327 contract,
328 } => {
329 let decision_str =
331 if config.dry_run { "would_deny" } else { "deny" };
332
333 if config.verbose {
334 eprintln!(
335 "[assay] {} {} (reason: {})",
336 decision_str.to_uppercase(),
337 tool,
338 reason
339 );
340 }
341
342 audit_log.log(&AuditEvent {
343 timestamp: chrono::Utc::now().to_rfc3339(),
344 decision: decision_str.to_string(),
345 tool: Some(tool.clone()),
346 reason: Some(reason.clone()),
347 request_id: req.id.clone(),
348 agentic: Some(contract.clone()),
349 });
350
351 let reason_code = Self::map_policy_code(&code);
353 Self::emit_decision(
354 &emitter_b,
355 &event_source_b,
356 &tool_call_id,
357 &tool,
358 if config.dry_run {
359 Decision::Allow
360 } else {
361 Decision::Deny
362 },
363 &reason_code,
364 Some(reason),
365 req.id.clone(),
366 &policy_eval.metadata,
367 tool_definition_binding.as_ref(),
368 );
369
370 if config.dry_run {
371 } else {
374 let id = req.id.unwrap_or(serde_json::Value::Null);
376 let response_json = make_deny_response(
377 id,
378 "Content blocked by policy",
379 contract,
380 );
381
382 let mut out = stdout_b
383 .lock()
384 .map_err(|e| io::Error::other(e.to_string()))?;
385 out.write_all(response_json.as_bytes())?;
386 out.flush()?;
387
388 line.clear();
389 continue; }
391 }
392 }
393 }
394 Err(_) => {
395 let trimmed = line.trim();
397 if trimmed.starts_with('{')
398 && (trimmed.contains("\"method\"")
399 || trimmed.contains("\"params\"")
400 || trimmed.contains("\"tool\""))
401 {
402 eprintln!("[assay] WARNING: Suspicious unparsable JSON, forwarding anyway (potential bypass attempt?): {:.60}...", trimmed);
403 }
404 }
405 }
406
407 child_stdin.write_all(line.as_bytes())?;
409 child_stdin.flush()?;
410 line.clear();
411 }
412 Ok(())
413 });
414
415 t_client_to_server
417 .join()
418 .map_err(|_| io::Error::other("client->server thread panicked"))??;
419
420 let _ = t_server_to_client.join();
422
423 let status = self.child.wait()?;
425 Ok(status.code().unwrap_or(1))
426 }
427
428 fn handle_allow(req: &JsonRpcRequest, audit_log: &mut AuditLog, verbose: bool) {
429 if verbose && req.is_tool_call() {
430 let tool = req
431 .tool_params()
432 .map(|p| p.name)
433 .unwrap_or_else(|| "unknown".to_string());
434 eprintln!("[assay] ALLOW {}", tool);
435 }
436
437 if req.is_tool_call() {
438 let tool = req.tool_params().map(|p| p.name);
439 audit_log.log(&AuditEvent {
440 timestamp: chrono::Utc::now().to_rfc3339(),
441 decision: "allow".to_string(),
442 tool,
443 reason: None,
444 request_id: req.id.clone(),
445 agentic: None,
446 });
447 }
448 }
449
450 fn extract_tool_call_id(request: &JsonRpcRequest) -> String {
452 if let Some(params) = request.tool_params() {
454 if let Some(meta) = params.arguments.get("_meta") {
455 if let Some(id) = meta.get("tool_call_id").and_then(|v| v.as_str()) {
456 return id.to_string();
457 }
458 }
459 }
460
461 if let Some(id) = &request.id {
463 if let Some(s) = id.as_str() {
464 return format!("req_{}", s);
465 }
466 if let Some(n) = id.as_i64() {
467 return format!("req_{}", n);
468 }
469 }
470
471 format!("gen_{}", uuid::Uuid::new_v4())
473 }
474
475 fn map_policy_code(code: &str) -> String {
477 match code {
478 "E_TOOL_DENIED" => reason_codes::P_TOOL_DENIED.to_string(),
479 "E_TOOL_NOT_ALLOWED" => reason_codes::P_TOOL_NOT_ALLOWED.to_string(),
480 "E_ARG_SCHEMA" => reason_codes::P_ARG_SCHEMA.to_string(),
481 "E_RATE_LIMIT" => reason_codes::P_RATE_LIMIT.to_string(),
482 "E_TOOL_DRIFT" => reason_codes::P_TOOL_DRIFT.to_string(),
483 _ => reason_codes::P_POLICY_DENY.to_string(),
484 }
485 }
486
487 #[allow(clippy::too_many_arguments)]
489 fn emit_decision(
490 emitter: &Arc<dyn DecisionEmitter>,
491 source: &str,
492 tool_call_id: &str,
493 tool: &str,
494 decision: Decision,
495 reason_code: &str,
496 reason: Option<String>,
497 request_id: Option<serde_json::Value>,
498 metadata: &PolicyMatchMetadata,
499 tool_definition_binding: Option<&ToolDefinitionBinding>,
500 ) {
501 let mut event = DecisionEvent::new(
502 source.to_string(),
503 tool_call_id.to_string(),
504 tool.to_string(),
505 );
506 event.data.decision = decision;
507 event.data.reason_code = reason_code.to_string();
508 event.data.reason = reason;
509 event.data.request_id = request_id;
510 event.data.tool_classes = metadata.tool_classes.clone();
511 event.data.matched_tool_classes = metadata.matched_tool_classes.clone();
512 event.data.match_basis = metadata.match_basis.as_str().map(ToString::to_string);
513 event.data.matched_rule = metadata.matched_rule.clone();
514 event.data.typed_decision = metadata.typed_decision;
515 event.data.policy_version = metadata.policy_version.clone();
516 event.data.policy_digest = metadata.policy_digest.clone();
517 event.data.apply_policy_snapshot_projection();
518 event
519 .data
520 .apply_tool_definition_binding(tool_definition_binding);
521 event.data.obligations = metadata.obligations.clone();
522 event.data.obligation_outcomes =
523 super::obligations::execute_log_only(&metadata.obligations, tool);
524 event.data.approval_state = metadata.approval_state.clone();
525 if let Some(artifact) = &metadata.approval_artifact {
526 event.data.approval_id = Some(artifact.approval_id.clone());
527 event.data.approver = Some(artifact.approver.clone());
528 event.data.issued_at = Some(artifact.issued_at.clone());
529 event.data.expires_at = Some(artifact.expires_at.clone());
530 event.data.scope = Some(artifact.scope.clone());
531 event.data.approval_bound_tool = Some(artifact.bound_tool.clone());
532 event.data.approval_bound_resource = Some(artifact.bound_resource.clone());
533 }
534 event.data.approval_freshness = metadata.approval_freshness;
535 event.data.approval_failure_reason = metadata.approval_failure_reason.clone();
536 event.data.scope_type = metadata.scope_type.clone();
537 event.data.scope_value = metadata.scope_value.clone();
538 event.data.scope_match_mode = metadata.scope_match_mode.clone();
539 event.data.scope_evaluation_state = metadata.scope_evaluation_state.clone();
540 event.data.scope_failure_reason = metadata.scope_failure_reason.clone();
541 event.data.restrict_scope_present = metadata.restrict_scope_present;
542 event.data.restrict_scope_target = metadata.restrict_scope_target.clone();
543 event.data.restrict_scope_match = metadata.restrict_scope_match;
544 event.data.restrict_scope_reason = metadata.restrict_scope_reason.clone();
545 event.data.redaction_target = metadata.redaction_target.clone();
546 event.data.redaction_mode = metadata.redaction_mode.clone();
547 event.data.redaction_scope = metadata.redaction_scope.clone();
548 event.data.redaction_applied_state = metadata.redaction_applied_state.clone();
549 event.data.redaction_reason = metadata.redaction_reason.clone();
550 event.data.redaction_failure_reason = metadata.redaction_failure_reason.clone();
551 event.data.redact_args_present = metadata.redact_args_present;
552 event.data.redact_args_target = metadata.redact_args_target.clone();
553 event.data.redact_args_mode = metadata.redact_args_mode.clone();
554 event.data.redact_args_result = metadata.redact_args_result.clone();
555 event.data.redact_args_reason = metadata.redact_args_reason.clone();
556 event.data.fail_closed = metadata.fail_closed.clone();
557 event.data.lane = metadata.lane.clone();
558 event.data.principal = metadata.principal.clone();
559 event.data.auth_context_summary = metadata.auth_context_summary.clone();
560 event.data.auth_scheme = metadata.auth_scheme.clone();
561 event.data.auth_issuer = metadata.auth_issuer.clone();
562 event.data.delegated_from = metadata.delegated_from.clone();
563 event.data.delegation_depth = metadata.delegation_depth;
564 refresh_contract_projections(&mut event.data);
565 emitter.emit(&event);
566 }
567
568 fn observe_tool_definition(
569 tool: &mut serde_json::Value,
570 server_id: &str,
571 ) -> Option<ToolDefinitionObservation> {
572 let name = tool.get("name").and_then(|n| n.as_str())?;
573 if name.trim().is_empty() {
574 return None;
575 }
576 let name = name.to_string();
577 let description = tool
578 .get("description")
579 .and_then(|d| d.as_str())
580 .map(|s| s.to_string());
581 let input_schema = tool
582 .get("inputSchema")
583 .or_else(|| tool.get("input_schema"))
584 .cloned();
585
586 let identity =
587 super::identity::ToolIdentity::new(server_id, &name, &input_schema, &description);
588 let binding = binding_from_tools_list_tool(tool, Some(server_id))
589 .ok()
590 .flatten();
591
592 tool.as_object_mut().and_then(|m| {
594 m.insert(
595 "tool_identity".to_string(),
596 serde_json::to_value(&identity).unwrap(),
597 )
598 });
599
600 Some(ToolDefinitionObservation {
601 name,
602 identity,
603 binding,
604 })
605 }
606}
607
608struct ToolDefinitionObservation {
609 name: String,
610 identity: super::identity::ToolIdentity,
611 binding: Option<ToolDefinitionBinding>,
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::mcp::tool_definition::{
618 TOOL_DEFINITION_CANONICALIZATION_JCS_MCP_TOOL_DEFINITION_V1,
619 TOOL_DEFINITION_DIGEST_ALG_SHA256, TOOL_DEFINITION_SCHEMA_V1,
620 TOOL_DEFINITION_SOURCE_MCP_TOOLS_LIST,
621 };
622 use std::sync::Mutex as StdMutex;
623
624 struct CapturingEmitter {
625 events: StdMutex<Vec<DecisionEvent>>,
626 }
627
628 impl CapturingEmitter {
629 fn new() -> Self {
630 Self {
631 events: StdMutex::new(Vec::new()),
632 }
633 }
634 }
635
636 impl DecisionEmitter for CapturingEmitter {
637 fn emit(&self, event: &DecisionEvent) {
638 self.events.lock().unwrap().push(event.clone());
639 }
640 }
641
642 #[test]
643 fn event_source_accepts_assay_uri() {
644 validate_event_source("assay://myorg/myapp").unwrap();
645 }
646
647 #[test]
648 fn event_source_accepts_https_uri() {
649 validate_event_source("https://example.com/agent").unwrap();
650 }
651
652 #[test]
653 fn event_source_rejects_empty() {
654 assert!(validate_event_source("").is_err());
655 assert!(validate_event_source(" ").is_err());
656 }
657
658 #[test]
659 fn event_source_rejects_whitespace() {
660 assert!(validate_event_source("assay://myorg/my app").is_err());
661 assert!(validate_event_source("assay://myorg/\tmyapp").is_err());
662 }
663
664 #[test]
665 fn event_source_rejects_missing_scheme() {
666 assert!(validate_event_source("myorg/myapp").is_err());
667 assert!(validate_event_source("://myorg/myapp").is_err());
668 }
669
670 #[test]
671 fn event_source_rejects_did_and_urn() {
672 assert!(validate_event_source("did:example:123").is_err());
674 assert!(validate_event_source("urn:example:foo").is_err());
675 }
676
677 #[test]
678 fn event_source_rejects_scheme_starting_with_non_letter() {
679 assert!(validate_event_source("1assay://myorg/myapp").is_err());
680 assert!(validate_event_source("-assay://myorg/myapp").is_err());
681 }
682
683 #[test]
684 fn event_source_rejects_scheme_with_invalid_chars() {
685 assert!(validate_event_source("as_say://myorg/myapp").is_err());
686 assert!(validate_event_source("as@say://myorg/myapp").is_err());
687 }
688
689 #[test]
690 fn config_requires_event_source_when_logging_enabled() {
691 let raw = ProxyConfigRaw {
692 dry_run: false,
693 verbose: false,
694 audit_log_path: None,
695 decision_log_path: Some(std::path::PathBuf::from("decisions.ndjson")),
696 event_source: None,
697 server_id: "srv".to_string(),
698 };
699
700 let err = ProxyConfig::try_from_raw(raw).unwrap_err();
701 let msg = format!("{err:#}");
702 assert!(msg.contains("event_source is required"));
703 }
704
705 #[test]
706 fn config_allows_no_event_source_when_logging_disabled() {
707 let raw = ProxyConfigRaw {
708 dry_run: false,
709 verbose: false,
710 audit_log_path: None,
711 decision_log_path: None,
712 event_source: None,
713 server_id: "srv".to_string(),
714 };
715
716 ProxyConfig::try_from_raw(raw).unwrap();
717 }
718
719 #[test]
720 fn config_accepts_valid_event_source() {
721 let raw = ProxyConfigRaw {
722 dry_run: false,
723 verbose: false,
724 audit_log_path: None,
725 decision_log_path: Some(std::path::PathBuf::from("decisions.ndjson")),
726 event_source: Some("assay://myorg/myapp".to_string()),
727 server_id: "srv".to_string(),
728 };
729
730 let cfg = ProxyConfig::try_from_raw(raw).unwrap();
731 assert_eq!(cfg.event_source.as_deref(), Some("assay://myorg/myapp"));
732 }
733
734 #[test]
735 fn config_rejects_invalid_event_source_uri() {
736 let raw = ProxyConfigRaw {
737 dry_run: false,
738 verbose: false,
739 audit_log_path: None,
740 decision_log_path: Some(std::path::PathBuf::from("decisions.ndjson")),
741 event_source: Some("not a uri".to_string()),
742 server_id: "srv".to_string(),
743 };
744
745 assert!(ProxyConfig::try_from_raw(raw).is_err());
746 }
747
748 #[test]
749 fn observe_tool_definition_computes_identity_and_binding() {
750 let mut tool = serde_json::json!({
751 "name": "read_file",
752 "description": " Read files ",
753 "inputSchema": {"type": "object"},
754 "annotations": {"title": "Read"},
755 "x-assay-sig": {"signature": "opaque"}
756 });
757
758 let observation = McpProxy::observe_tool_definition(&mut tool, "server-a")
759 .expect("supported tool definition should be observed");
760
761 assert_eq!(observation.name, "read_file");
762 assert_eq!(observation.identity.server_id, "server-a");
763 assert!(observation.binding.is_some());
764 assert!(tool.get("tool_identity").is_some());
765 }
766
767 #[test]
768 fn emit_decision_projects_tool_definition_binding_atomically() {
769 let mut tool = serde_json::json!({
770 "name": "read_file",
771 "description": "Read files",
772 "inputSchema": {"type": "object"}
773 });
774 let observation = McpProxy::observe_tool_definition(&mut tool, "server-a")
775 .expect("supported tool definition should be observed");
776 let binding = observation.binding.expect("binding should be visible");
777 let emitter = Arc::new(CapturingEmitter::new());
778 let emitter_trait: Arc<dyn DecisionEmitter> = emitter.clone();
779
780 McpProxy::emit_decision(
781 &emitter_trait,
782 "assay://test",
783 "tc_tool_definition",
784 "read_file",
785 Decision::Allow,
786 reason_codes::P_POLICY_ALLOW,
787 None,
788 None,
789 &PolicyMatchMetadata::default(),
790 Some(&binding),
791 );
792
793 let events = emitter.events.lock().unwrap();
794 let data = &events[0].data;
795 assert!(data.tool_definition_digest.is_some());
796 assert_eq!(
797 data.tool_definition_digest_alg.as_deref(),
798 Some(TOOL_DEFINITION_DIGEST_ALG_SHA256)
799 );
800 assert_eq!(
801 data.tool_definition_canonicalization.as_deref(),
802 Some(TOOL_DEFINITION_CANONICALIZATION_JCS_MCP_TOOL_DEFINITION_V1)
803 );
804 assert_eq!(
805 data.tool_definition_schema.as_deref(),
806 Some(TOOL_DEFINITION_SCHEMA_V1)
807 );
808 assert_eq!(
809 data.tool_definition_source.as_deref(),
810 Some(TOOL_DEFINITION_SOURCE_MCP_TOOLS_LIST)
811 );
812 }
813}