1use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13pub use crate::graphql::GraphQLOperationType;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum SchemaFormat {
24 OpenAPI3,
26 GraphQL,
28 Sql,
30 AsyncAPI,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type", rename_all = "lowercase")]
37pub enum SchemaSource {
38 Embedded { path: String },
40 Remote {
42 url: String,
43 #[serde(default)]
44 refresh_interval_seconds: Option<u64>,
45 },
46 Introspection { endpoint: String },
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SchemaMetadata {
53 pub source: SchemaSource,
55
56 #[serde(default)]
58 pub last_updated: Option<i64>,
59
60 #[serde(default)]
62 pub content_hash: Option<String>,
63
64 #[serde(default)]
66 pub version: Option<String>,
67
68 #[serde(default)]
70 pub title: Option<String>,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
75#[serde(rename_all = "lowercase")]
76pub enum OperationCategory {
77 Read,
79 Create,
81 Update,
83 Delete,
85 Admin,
87 Internal,
89}
90
91impl OperationCategory {
92 pub fn is_read_only(&self) -> bool {
94 matches!(self, OperationCategory::Read)
95 }
96
97 pub fn is_write(&self) -> bool {
99 matches!(self, OperationCategory::Create | OperationCategory::Update)
100 }
101
102 pub fn is_delete(&self) -> bool {
104 matches!(self, OperationCategory::Delete)
105 }
106}
107
108#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
110#[serde(rename_all = "lowercase")]
111pub enum OperationRiskLevel {
112 Safe,
114 Low,
116 #[default]
118 Medium,
119 High,
121 Critical,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct Operation {
128 pub id: String,
133
134 pub name: String,
136
137 #[serde(default)]
139 pub description: Option<String>,
140
141 pub category: OperationCategory,
143
144 pub is_read_only: bool,
146
147 #[serde(default)]
149 pub risk_level: OperationRiskLevel,
150
151 #[serde(default)]
153 pub tags: Vec<String>,
154
155 pub details: OperationDetails,
157}
158
159impl Operation {
160 pub fn new(
162 id: impl Into<String>,
163 name: impl Into<String>,
164 category: OperationCategory,
165 ) -> Self {
166 let is_read_only = category.is_read_only();
167 Self {
168 id: id.into(),
169 name: name.into(),
170 description: None,
171 category,
172 is_read_only,
173 risk_level: if is_read_only {
174 OperationRiskLevel::Safe
175 } else if category.is_delete() {
176 OperationRiskLevel::High
177 } else {
178 OperationRiskLevel::Low
179 },
180 tags: Vec::new(),
181 details: OperationDetails::Unknown,
182 }
183 }
184
185 pub fn matches_pattern(&self, pattern: &str) -> bool {
188 match &self.details {
189 OperationDetails::OpenAPI { method, path, .. } => {
190 let endpoint = format!("{} {}", method.to_uppercase(), path);
192 pattern_matches(pattern, &endpoint) || pattern_matches(pattern, &self.id)
193 },
194 OperationDetails::GraphQL {
195 operation_type,
196 field_name,
197 ..
198 } => {
199 let full_name = format!("{:?}.{}", operation_type, field_name);
201 pattern_matches(pattern, &full_name) || pattern_matches(pattern, &self.id)
202 },
203 OperationDetails::Sql {
204 statement_type,
205 table,
206 ..
207 } => {
208 let full_name = format!("{:?} {}", statement_type, table);
210 pattern_matches(pattern, &full_name.to_lowercase())
211 || pattern_matches(pattern, &self.id)
212 },
213 OperationDetails::Unknown => pattern_matches(pattern, &self.id),
214 }
215 }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(tag = "format", rename_all = "lowercase")]
221pub enum OperationDetails {
222 #[serde(rename = "openapi")]
224 OpenAPI {
225 method: String,
226 path: String,
227 #[serde(default)]
228 parameters: Vec<OperationParameter>,
229 #[serde(default)]
230 has_request_body: bool,
231 },
232
233 #[serde(rename = "graphql")]
235 GraphQL {
236 operation_type: GraphQLOperationType,
237 field_name: String,
238 #[serde(default)]
239 arguments: Vec<OperationParameter>,
240 #[serde(default)]
241 return_type: Option<String>,
242 },
243
244 #[serde(rename = "sql")]
246 Sql {
247 statement_type: SqlStatementType,
248 table: String,
249 #[serde(default)]
250 columns: Vec<String>,
251 },
252
253 Unknown,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(rename_all = "UPPERCASE")]
260pub enum SqlStatementType {
261 Select,
262 Insert,
263 Update,
264 Delete,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct OperationParameter {
270 pub name: String,
271 #[serde(default)]
272 pub description: Option<String>,
273 pub required: bool,
274 #[serde(default)]
275 pub param_type: Option<String>,
276}
277
278#[derive(Debug, Clone, Default, Serialize, Deserialize)]
284pub struct McpExposurePolicy {
285 #[serde(default)]
287 pub global_blocklist: GlobalBlocklist,
288
289 #[serde(default)]
291 pub tools: ToolExposurePolicy,
292
293 #[serde(default)]
295 pub code_mode: CodeModeExposurePolicy,
296}
297
298#[derive(Debug, Clone, Default, Serialize, Deserialize)]
300pub struct GlobalBlocklist {
301 #[serde(default)]
303 pub operations: HashSet<String>,
304
305 #[serde(default)]
310 pub patterns: HashSet<String>,
311
312 #[serde(default)]
314 pub categories: HashSet<OperationCategory>,
315
316 #[serde(default)]
318 pub risk_levels: HashSet<OperationRiskLevel>,
319}
320
321impl GlobalBlocklist {
322 pub fn is_blocked(&self, operation: &Operation) -> Option<FilterReason> {
324 if self.operations.contains(&operation.id) {
326 return Some(FilterReason::GlobalBlocklistOperation {
327 operation_id: operation.id.clone(),
328 });
329 }
330
331 for pattern in &self.patterns {
333 if operation.matches_pattern(pattern) {
334 return Some(FilterReason::GlobalBlocklistPattern {
335 pattern: pattern.clone(),
336 });
337 }
338 }
339
340 if self.categories.contains(&operation.category) {
342 return Some(FilterReason::GlobalBlocklistCategory {
343 category: operation.category,
344 });
345 }
346
347 if self.risk_levels.contains(&operation.risk_level) {
349 return Some(FilterReason::GlobalBlocklistRiskLevel {
350 level: operation.risk_level,
351 });
352 }
353
354 None
355 }
356}
357
358#[derive(Debug, Clone, Default, Serialize, Deserialize)]
360pub struct ToolExposurePolicy {
361 #[serde(default)]
363 pub mode: ExposureMode,
364
365 #[serde(default)]
367 pub allowlist: HashSet<String>,
368
369 #[serde(default)]
371 pub blocklist: HashSet<String>,
372
373 #[serde(default)]
375 pub overrides: HashMap<String, ToolOverride>,
376}
377
378impl ToolExposurePolicy {
379 pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
381 if self.blocklist.contains(&operation.id) {
383 return Some(FilterReason::ToolBlocklist);
384 }
385
386 for pattern in &self.blocklist {
388 if pattern.contains('*') && operation.matches_pattern(pattern) {
389 return Some(FilterReason::ToolBlocklistPattern {
390 pattern: pattern.clone(),
391 });
392 }
393 }
394
395 match self.mode {
396 ExposureMode::AllowAll => None,
397 ExposureMode::DenyAll => Some(FilterReason::ToolDenyAllMode),
398 ExposureMode::Allowlist => {
399 if self.allowlist.contains(&operation.id) {
401 return None;
402 }
403 for pattern in &self.allowlist {
405 if pattern.contains('*') && operation.matches_pattern(pattern) {
406 return None;
407 }
408 }
409 Some(FilterReason::ToolNotInAllowlist)
410 },
411 ExposureMode::Blocklist => None, }
413 }
414}
415
416#[derive(Debug, Clone, Default, Serialize, Deserialize)]
418pub struct CodeModeExposurePolicy {
419 #[serde(default)]
421 pub reads: MethodExposurePolicy,
422
423 #[serde(default)]
425 pub writes: MethodExposurePolicy,
426
427 #[serde(default)]
429 pub deletes: MethodExposurePolicy,
430
431 #[serde(default)]
433 pub blocklist: HashSet<String>,
434}
435
436impl CodeModeExposurePolicy {
437 pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
439 if self.blocklist.contains(&operation.id) {
441 return Some(FilterReason::CodeModeBlocklist);
442 }
443
444 for pattern in &self.blocklist {
446 if pattern.contains('*') && operation.matches_pattern(pattern) {
447 return Some(FilterReason::CodeModeBlocklistPattern {
448 pattern: pattern.clone(),
449 });
450 }
451 }
452
453 let method_policy = self.get_method_policy(operation);
455 method_policy.is_allowed(operation)
456 }
457
458 fn get_method_policy(&self, operation: &Operation) -> &MethodExposurePolicy {
460 match operation.category {
461 OperationCategory::Read => &self.reads,
462 OperationCategory::Delete => &self.deletes,
463 OperationCategory::Create | OperationCategory::Update => &self.writes,
464 OperationCategory::Admin | OperationCategory::Internal => &self.writes,
465 }
466 }
467}
468
469#[derive(Debug, Clone, Default, Serialize, Deserialize)]
471pub struct MethodExposurePolicy {
472 #[serde(default)]
474 pub mode: ExposureMode,
475
476 #[serde(default)]
479 pub allowlist: HashSet<String>,
480
481 #[serde(default)]
483 pub blocklist: HashSet<String>,
484}
485
486impl MethodExposurePolicy {
487 pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
489 if self.blocklist.contains(&operation.id) {
491 return Some(FilterReason::MethodBlocklist {
492 method_type: Self::method_type_name(operation),
493 });
494 }
495
496 for pattern in &self.blocklist {
498 if pattern.contains('*') && operation.matches_pattern(pattern) {
499 return Some(FilterReason::MethodBlocklistPattern {
500 method_type: Self::method_type_name(operation),
501 pattern: pattern.clone(),
502 });
503 }
504 }
505
506 match self.mode {
507 ExposureMode::AllowAll => None,
508 ExposureMode::DenyAll => Some(FilterReason::MethodDenyAllMode {
509 method_type: Self::method_type_name(operation),
510 }),
511 ExposureMode::Allowlist => {
512 if self.allowlist.contains(&operation.id) {
514 return None;
515 }
516 for pattern in &self.allowlist {
518 if pattern.contains('*') && operation.matches_pattern(pattern) {
519 return None;
520 }
521 }
522 Some(FilterReason::MethodNotInAllowlist {
523 method_type: Self::method_type_name(operation),
524 })
525 },
526 ExposureMode::Blocklist => None, }
528 }
529
530 fn method_type_name(operation: &Operation) -> String {
531 match operation.category {
532 OperationCategory::Read => "reads".to_string(),
533 OperationCategory::Delete => "deletes".to_string(),
534 _ => "writes".to_string(),
535 }
536 }
537}
538
539#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(rename_all = "snake_case")]
542pub enum ExposureMode {
543 #[default]
545 AllowAll,
546 DenyAll,
548 Allowlist,
550 Blocklist,
552}
553
554#[derive(Debug, Clone, Default, Serialize, Deserialize)]
556pub struct ToolOverride {
557 #[serde(default)]
559 pub name: Option<String>,
560
561 #[serde(default)]
563 pub description: Option<String>,
564
565 #[serde(default)]
567 pub dangerous: bool,
568
569 #[serde(default)]
571 pub hidden: bool,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct DerivedSchema {
581 pub operations: Vec<Operation>,
583
584 pub documentation: String,
586
587 pub metadata: DerivationMetadata,
589}
590
591impl DerivedSchema {
592 pub fn get_operation(&self, id: &str) -> Option<&Operation> {
594 self.operations.iter().find(|op| op.id == id)
595 }
596
597 pub fn contains(&self, id: &str) -> bool {
599 self.operations.iter().any(|op| op.id == id)
600 }
601
602 pub fn operation_ids(&self) -> HashSet<String> {
604 self.operations.iter().map(|op| op.id.clone()).collect()
605 }
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct DerivationMetadata {
611 pub context: String,
613
614 pub derived_at: i64,
616
617 pub source_hash: String,
619
620 pub policy_hash: String,
622
623 pub cache_key: String,
625
626 pub filtered: Vec<FilteredOperation>,
628
629 pub stats: DerivationStats,
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize)]
635pub struct FilteredOperation {
636 pub operation_id: String,
638
639 pub operation_name: String,
641
642 pub reason: FilterReason,
644
645 pub policy: String,
647}
648
649#[derive(Debug, Clone, Serialize, Deserialize)]
651#[serde(tag = "type", rename_all = "snake_case")]
652pub enum FilterReason {
653 GlobalBlocklistOperation { operation_id: String },
655
656 GlobalBlocklistPattern { pattern: String },
658
659 GlobalBlocklistCategory { category: OperationCategory },
661
662 GlobalBlocklistRiskLevel { level: OperationRiskLevel },
664
665 ToolBlocklist,
667
668 ToolBlocklistPattern { pattern: String },
670
671 ToolNotInAllowlist,
673
674 ToolDenyAllMode,
676
677 CodeModeBlocklist,
679
680 CodeModeBlocklistPattern { pattern: String },
682
683 MethodBlocklist { method_type: String },
685
686 MethodBlocklistPattern {
688 method_type: String,
689 pattern: String,
690 },
691
692 MethodNotInAllowlist { method_type: String },
694
695 MethodDenyAllMode { method_type: String },
697}
698
699impl FilterReason {
700 pub fn description(&self) -> String {
702 match self {
703 FilterReason::GlobalBlocklistOperation { operation_id } => {
704 format!("Operation '{}' is in the global blocklist", operation_id)
705 },
706 FilterReason::GlobalBlocklistPattern { pattern } => {
707 format!("Matches global blocklist pattern '{}'", pattern)
708 },
709 FilterReason::GlobalBlocklistCategory { category } => {
710 format!("Category '{:?}' is blocked globally", category)
711 },
712 FilterReason::GlobalBlocklistRiskLevel { level } => {
713 format!("Risk level '{:?}' is blocked globally", level)
714 },
715 FilterReason::ToolBlocklist => "Operation is in the tool blocklist".to_string(),
716 FilterReason::ToolBlocklistPattern { pattern } => {
717 format!("Matches tool blocklist pattern '{}'", pattern)
718 },
719 FilterReason::ToolNotInAllowlist => {
720 "Operation is not in the tool allowlist".to_string()
721 },
722 FilterReason::ToolDenyAllMode => "Tool exposure is set to deny_all".to_string(),
723 FilterReason::CodeModeBlocklist => {
724 "Operation is in the Code Mode blocklist".to_string()
725 },
726 FilterReason::CodeModeBlocklistPattern { pattern } => {
727 format!("Matches Code Mode blocklist pattern '{}'", pattern)
728 },
729 FilterReason::MethodBlocklist { method_type } => {
730 format!("Operation is in the {} blocklist", method_type)
731 },
732 FilterReason::MethodBlocklistPattern {
733 method_type,
734 pattern,
735 } => {
736 format!("Matches {} blocklist pattern '{}'", method_type, pattern)
737 },
738 FilterReason::MethodNotInAllowlist { method_type } => {
739 format!("Operation is not in the {} allowlist", method_type)
740 },
741 FilterReason::MethodDenyAllMode { method_type } => {
742 format!("{} exposure is set to deny_all", method_type)
743 },
744 }
745 }
746}
747
748#[derive(Debug, Clone, Default, Serialize, Deserialize)]
750pub struct DerivationStats {
751 pub source_total: usize,
753
754 pub derived_total: usize,
756
757 pub filtered_total: usize,
759
760 pub filtered_by_reason: HashMap<String, usize>,
762}
763
764pub fn pattern_matches(pattern: &str, text: &str) -> bool {
771 let pattern = pattern.to_lowercase();
772 let text = text.to_lowercase();
773
774 let parts: Vec<&str> = pattern.split('*').collect();
775 if parts.len() == 1 {
776 return pattern == text;
777 }
778
779 match_glob_parts(&parts, &text)
780}
781
782fn match_glob_parts(parts: &[&str], text: &str) -> bool {
785 let last_idx = parts.len() - 1;
786 let mut pos = 0;
787 for (i, part) in parts.iter().enumerate() {
788 if part.is_empty() {
789 continue;
790 }
791 match match_glob_segment(part, text, pos, i, last_idx) {
792 Some(new_pos) => pos = new_pos,
793 None => return false,
794 }
795 }
796 true
797}
798
799fn match_glob_segment(
805 part: &str,
806 text: &str,
807 pos: usize,
808 i: usize,
809 last_idx: usize,
810) -> Option<usize> {
811 if i == 0 {
812 if !text.starts_with(part) {
813 return None;
814 }
815 return Some(part.len());
816 }
817 if i == last_idx {
818 if !text[pos..].ends_with(part) {
819 return None;
820 }
821 return Some(pos);
822 }
823 text[pos..].find(part).map(|found| pos + found + part.len())
825}
826
827pub struct SchemaDeriver {
833 operations: Vec<Operation>,
835
836 policy: McpExposurePolicy,
838
839 source_hash: String,
841
842 policy_hash: String,
844}
845
846impl SchemaDeriver {
847 pub fn new(operations: Vec<Operation>, policy: McpExposurePolicy, source_hash: String) -> Self {
849 let policy_hash = Self::compute_policy_hash(&policy);
850 Self {
851 operations,
852 policy,
853 source_hash,
854 policy_hash,
855 }
856 }
857
858 pub fn derive_tools_schema(&self) -> DerivedSchema {
860 let mut included = Vec::new();
861 let mut filtered = Vec::new();
862
863 for op in &self.operations {
864 if let Some(reason) = self.policy.global_blocklist.is_blocked(op) {
866 filtered.push(FilteredOperation {
867 operation_id: op.id.clone(),
868 operation_name: op.name.clone(),
869 reason,
870 policy: "global_blocklist".to_string(),
871 });
872 continue;
873 }
874
875 if let Some(reason) = self.policy.tools.is_allowed(op) {
877 filtered.push(FilteredOperation {
878 operation_id: op.id.clone(),
879 operation_name: op.name.clone(),
880 reason,
881 policy: "tools".to_string(),
882 });
883 continue;
884 }
885
886 let op = self.apply_tool_overrides(op);
888 included.push(op);
889 }
890
891 self.build_derived_schema(included, filtered, "tools")
892 }
893
894 pub fn derive_code_mode_schema(&self) -> DerivedSchema {
896 let mut included = Vec::new();
897 let mut filtered = Vec::new();
898
899 for op in &self.operations {
900 if let Some(reason) = self.policy.global_blocklist.is_blocked(op) {
902 filtered.push(FilteredOperation {
903 operation_id: op.id.clone(),
904 operation_name: op.name.clone(),
905 reason,
906 policy: "global_blocklist".to_string(),
907 });
908 continue;
909 }
910
911 if let Some(reason) = self.policy.code_mode.is_allowed(op) {
913 let policy_name = match op.category {
914 OperationCategory::Read => "code_mode.reads",
915 OperationCategory::Delete => "code_mode.deletes",
916 _ => "code_mode.writes",
917 };
918 filtered.push(FilteredOperation {
919 operation_id: op.id.clone(),
920 operation_name: op.name.clone(),
921 reason,
922 policy: policy_name.to_string(),
923 });
924 continue;
925 }
926
927 included.push(op.clone());
928 }
929
930 self.build_derived_schema(included, filtered, "code_mode")
931 }
932
933 pub fn is_tool_allowed(&self, operation_id: &str) -> bool {
935 self.operations
936 .iter()
937 .find(|op| op.id == operation_id)
938 .map(|op| {
939 self.policy.global_blocklist.is_blocked(op).is_none()
940 && self.policy.tools.is_allowed(op).is_none()
941 })
942 .unwrap_or(false)
943 }
944
945 pub fn is_code_mode_allowed(&self, operation_id: &str) -> bool {
947 self.operations
948 .iter()
949 .find(|op| op.id == operation_id)
950 .map(|op| {
951 self.policy.global_blocklist.is_blocked(op).is_none()
952 && self.policy.code_mode.is_allowed(op).is_none()
953 })
954 .unwrap_or(false)
955 }
956
957 pub fn get_tool_filter_reason(&self, operation_id: &str) -> Option<FilterReason> {
959 self.operations
960 .iter()
961 .find(|op| op.id == operation_id)
962 .and_then(|op| {
963 self.policy
964 .global_blocklist
965 .is_blocked(op)
966 .or_else(|| self.policy.tools.is_allowed(op))
967 })
968 }
969
970 pub fn get_code_mode_filter_reason(&self, operation_id: &str) -> Option<FilterReason> {
972 self.operations
973 .iter()
974 .find(|op| op.id == operation_id)
975 .and_then(|op| {
976 self.policy
977 .global_blocklist
978 .is_blocked(op)
979 .or_else(|| self.policy.code_mode.is_allowed(op))
980 })
981 }
982
983 pub fn find_operation_id(&self, method: &str, path_pattern: &str) -> Option<String> {
995 let method_upper = method.to_uppercase();
996 let normalized_pattern = Self::normalize_path_for_matching(path_pattern);
997
998 for op in &self.operations {
999 if let OperationDetails::OpenAPI {
1000 method: op_method,
1001 path: op_path,
1002 ..
1003 } = &op.details
1004 {
1005 if op_method.to_uppercase() == method_upper {
1006 let normalized_op_path = Self::normalize_path_for_matching(op_path);
1007 if Self::paths_match(&normalized_pattern, &normalized_op_path) {
1008 return Some(op.id.clone());
1009 }
1010 }
1011 }
1012 }
1013 None
1014 }
1015
1016 pub fn get_operations_for_allowlist(&self) -> Vec<(String, String, String)> {
1020 self.operations
1021 .iter()
1022 .filter_map(|op| {
1023 if let OperationDetails::OpenAPI { method, path, .. } = &op.details {
1024 let method_path = format!("{}:{}", method.to_uppercase(), path);
1025 let description = op.description.clone().unwrap_or_else(|| op.name.clone());
1026 Some((op.id.clone(), method_path, description))
1027 } else {
1028 None
1029 }
1030 })
1031 .collect()
1032 }
1033
1034 fn normalize_path_for_matching(path: &str) -> String {
1036 path.split('/')
1037 .map(|segment| {
1038 if segment.starts_with('{') && segment.ends_with('}') {
1039 "*" } else if segment.starts_with(':') {
1041 "*" } else if segment == "*" {
1043 "*"
1044 } else {
1045 segment
1046 }
1047 })
1048 .collect::<Vec<_>>()
1049 .join("/")
1050 }
1051
1052 fn paths_match(pattern: &str, path: &str) -> bool {
1054 let pattern_parts: Vec<_> = pattern.split('/').collect();
1055 let path_parts: Vec<_> = path.split('/').collect();
1056
1057 if pattern_parts.len() != path_parts.len() {
1058 return false;
1059 }
1060
1061 for (p, s) in pattern_parts.iter().zip(path_parts.iter()) {
1062 if *p == "*" || *s == "*" {
1063 continue; }
1065 if p != s {
1066 return false;
1067 }
1068 }
1069 true
1070 }
1071
1072 fn apply_tool_overrides(&self, op: &Operation) -> Operation {
1074 let mut op = op.clone();
1075
1076 if let Some(override_config) = self.policy.tools.overrides.get(&op.id) {
1077 if let Some(name) = &override_config.name {
1078 op.name = name.clone();
1079 }
1080 if let Some(description) = &override_config.description {
1081 op.description = Some(description.clone());
1082 }
1083 if override_config.dangerous {
1084 op.risk_level = OperationRiskLevel::High;
1085 }
1086 }
1087
1088 op
1089 }
1090
1091 fn build_derived_schema(
1093 &self,
1094 operations: Vec<Operation>,
1095 filtered: Vec<FilteredOperation>,
1096 context: &str,
1097 ) -> DerivedSchema {
1098 let mut filtered_by_reason: HashMap<String, usize> = HashMap::new();
1100 for f in &filtered {
1101 let reason_type = match &f.reason {
1102 FilterReason::GlobalBlocklistOperation { .. } => "global_blocklist_operation",
1103 FilterReason::GlobalBlocklistPattern { .. } => "global_blocklist_pattern",
1104 FilterReason::GlobalBlocklistCategory { .. } => "global_blocklist_category",
1105 FilterReason::GlobalBlocklistRiskLevel { .. } => "global_blocklist_risk_level",
1106 FilterReason::ToolBlocklist => "tool_blocklist",
1107 FilterReason::ToolBlocklistPattern { .. } => "tool_blocklist_pattern",
1108 FilterReason::ToolNotInAllowlist => "tool_not_in_allowlist",
1109 FilterReason::ToolDenyAllMode => "tool_deny_all",
1110 FilterReason::CodeModeBlocklist => "code_mode_blocklist",
1111 FilterReason::CodeModeBlocklistPattern { .. } => "code_mode_blocklist_pattern",
1112 FilterReason::MethodBlocklist { .. } => "method_blocklist",
1113 FilterReason::MethodBlocklistPattern { .. } => "method_blocklist_pattern",
1114 FilterReason::MethodNotInAllowlist { .. } => "method_not_in_allowlist",
1115 FilterReason::MethodDenyAllMode { .. } => "method_deny_all",
1116 };
1117 *filtered_by_reason
1118 .entry(reason_type.to_string())
1119 .or_default() += 1;
1120 }
1121
1122 let stats = DerivationStats {
1123 source_total: self.operations.len(),
1124 derived_total: operations.len(),
1125 filtered_total: filtered.len(),
1126 filtered_by_reason,
1127 };
1128
1129 let documentation = self.generate_documentation(&operations, context);
1131
1132 let cache_key = format!("{}:{}:{}", context, self.source_hash, self.policy_hash);
1134
1135 let now = std::time::SystemTime::now()
1136 .duration_since(std::time::UNIX_EPOCH)
1137 .map(|d| d.as_secs() as i64)
1138 .unwrap_or(0);
1139
1140 DerivedSchema {
1141 operations,
1142 documentation,
1143 metadata: DerivationMetadata {
1144 context: context.to_string(),
1145 derived_at: now,
1146 source_hash: self.source_hash.clone(),
1147 policy_hash: self.policy_hash.clone(),
1148 cache_key,
1149 filtered,
1150 stats,
1151 },
1152 }
1153 }
1154
1155 fn generate_documentation(&self, operations: &[Operation], context: &str) -> String {
1157 let mut doc = String::new();
1158
1159 if context == "code_mode" {
1160 doc.push_str("# API Operations Available in Code Mode\n\n");
1161 } else {
1162 doc.push_str("# API Operations Available as MCP Tools\n\n");
1163 }
1164
1165 doc.push_str(&format!(
1166 "**{} of {} operations available**\n\n",
1167 operations.len(),
1168 self.operations.len()
1169 ));
1170
1171 let reads: Vec<_> = operations
1173 .iter()
1174 .filter(|o| o.category == OperationCategory::Read)
1175 .collect();
1176 let writes: Vec<_> = operations
1177 .iter()
1178 .filter(|o| {
1179 matches!(
1180 o.category,
1181 OperationCategory::Create | OperationCategory::Update
1182 )
1183 })
1184 .collect();
1185 let deletes: Vec<_> = operations
1186 .iter()
1187 .filter(|o| o.category == OperationCategory::Delete)
1188 .collect();
1189
1190 doc.push_str(&format!(
1192 "## Read Operations ({} available)\n\n",
1193 reads.len()
1194 ));
1195 if reads.is_empty() {
1196 doc.push_str("_No read operations available._\n\n");
1197 } else {
1198 for op in reads {
1199 self.document_operation(&mut doc, op, context);
1200 }
1201 }
1202
1203 doc.push_str(&format!(
1205 "\n## Write Operations ({} available)\n\n",
1206 writes.len()
1207 ));
1208 if writes.is_empty() {
1209 doc.push_str("_No write operations available._\n\n");
1210 } else {
1211 for op in writes {
1212 self.document_operation(&mut doc, op, context);
1213 }
1214 }
1215
1216 doc.push_str(&format!(
1218 "\n## Delete Operations ({} available)\n\n",
1219 deletes.len()
1220 ));
1221 if deletes.is_empty() {
1222 doc.push_str("_No delete operations available._\n\n");
1223 } else {
1224 for op in deletes {
1225 self.document_operation(&mut doc, op, context);
1226 }
1227 }
1228
1229 doc
1230 }
1231
1232 fn document_operation(&self, doc: &mut String, op: &Operation, context: &str) {
1234 match &op.details {
1235 OperationDetails::OpenAPI { method, path, .. } => {
1236 if context == "code_mode" {
1237 let method_lower = method.to_lowercase();
1238 doc.push_str(&format!(
1239 "- `api.{}(\"{}\")` - {}\n",
1240 method_lower, path, op.name
1241 ));
1242 } else {
1243 doc.push_str(&format!("- **{}**: `{} {}`\n", op.name, method, path));
1244 }
1245 },
1246 OperationDetails::GraphQL {
1247 operation_type,
1248 field_name,
1249 ..
1250 } => {
1251 doc.push_str(&format!(
1252 "- **{}**: `{:?}.{}`\n",
1253 op.name, operation_type, field_name
1254 ));
1255 },
1256 OperationDetails::Sql {
1257 statement_type,
1258 table,
1259 ..
1260 } => {
1261 doc.push_str(&format!(
1262 "- **{}**: `{:?} {}`\n",
1263 op.name, statement_type, table
1264 ));
1265 },
1266 OperationDetails::Unknown => {
1267 doc.push_str(&format!("- **{}** ({})\n", op.name, op.id));
1268 },
1269 }
1270
1271 if let Some(desc) = &op.description {
1272 doc.push_str(&format!(" {}\n", desc));
1273 }
1274 }
1275
1276 fn compute_policy_hash(policy: &McpExposurePolicy) -> String {
1278 use std::collections::hash_map::DefaultHasher;
1279 use std::hash::{Hash, Hasher};
1280
1281 let mut hasher = DefaultHasher::new();
1282
1283 let mut ops: Vec<_> = policy.global_blocklist.operations.iter().collect();
1285 ops.sort();
1286 for op in ops {
1287 op.hash(&mut hasher);
1288 }
1289
1290 let mut patterns: Vec<_> = policy.global_blocklist.patterns.iter().collect();
1291 patterns.sort();
1292 for p in patterns {
1293 p.hash(&mut hasher);
1294 }
1295
1296 format!("{:?}", policy.tools.mode).hash(&mut hasher);
1298 let mut allowlist: Vec<_> = policy.tools.allowlist.iter().collect();
1299 allowlist.sort();
1300 for a in allowlist {
1301 a.hash(&mut hasher);
1302 }
1303
1304 format!("{:?}", policy.code_mode.reads.mode).hash(&mut hasher);
1306 format!("{:?}", policy.code_mode.writes.mode).hash(&mut hasher);
1307 format!("{:?}", policy.code_mode.deletes.mode).hash(&mut hasher);
1308
1309 format!("{:016x}", hasher.finish())
1310 }
1311}
1312
1313#[cfg(test)]
1318mod tests {
1319 use super::*;
1320
1321 #[test]
1322 fn test_pattern_matching() {
1323 assert!(pattern_matches("GET /users", "GET /users"));
1325 assert!(!pattern_matches("GET /users", "POST /users"));
1326
1327 assert!(pattern_matches("GET /users/*", "GET /users/123"));
1329 assert!(pattern_matches("GET /users/*", "GET /users/123/posts"));
1330 assert!(!pattern_matches("GET /users/*", "GET /posts/123"));
1331
1332 assert!(pattern_matches("* /admin/*", "GET /admin/users"));
1334 assert!(pattern_matches("* /admin/*", "DELETE /admin/config"));
1335
1336 assert!(pattern_matches(
1338 "GET /users/*/posts",
1339 "GET /users/123/posts"
1340 ));
1341
1342 assert!(pattern_matches("*/admin/*", "DELETE /admin/all"));
1344
1345 assert!(pattern_matches("GET /USERS", "get /users"));
1347 }
1348
1349 #[test]
1350 fn test_global_blocklist() {
1351 let blocklist = GlobalBlocklist {
1352 operations: ["factoryReset".to_string()].into_iter().collect(),
1353 patterns: ["* /admin/*".to_string()].into_iter().collect(),
1354 categories: [OperationCategory::Internal].into_iter().collect(),
1355 risk_levels: [OperationRiskLevel::Critical].into_iter().collect(),
1356 };
1357
1358 let op = Operation {
1360 id: "factoryReset".to_string(),
1361 name: "Factory Reset".to_string(),
1362 description: None,
1363 category: OperationCategory::Admin,
1364 is_read_only: false,
1365 risk_level: OperationRiskLevel::Critical,
1366 tags: vec![],
1367 details: OperationDetails::Unknown,
1368 };
1369 assert!(blocklist.is_blocked(&op).is_some());
1370
1371 let op = Operation {
1373 id: "listAdminUsers".to_string(),
1374 name: "List Admin Users".to_string(),
1375 description: None,
1376 category: OperationCategory::Read,
1377 is_read_only: true,
1378 risk_level: OperationRiskLevel::Safe,
1379 tags: vec![],
1380 details: OperationDetails::OpenAPI {
1381 method: "GET".to_string(),
1382 path: "/admin/users".to_string(),
1383 parameters: vec![],
1384 has_request_body: false,
1385 },
1386 };
1387 assert!(blocklist.is_blocked(&op).is_some());
1388
1389 let op = Operation {
1391 id: "internalSync".to_string(),
1392 name: "Internal Sync".to_string(),
1393 description: None,
1394 category: OperationCategory::Internal,
1395 is_read_only: false,
1396 risk_level: OperationRiskLevel::Low,
1397 tags: vec![],
1398 details: OperationDetails::Unknown,
1399 };
1400 assert!(blocklist.is_blocked(&op).is_some());
1401
1402 let op = Operation {
1404 id: "listUsers".to_string(),
1405 name: "List Users".to_string(),
1406 description: None,
1407 category: OperationCategory::Read,
1408 is_read_only: true,
1409 risk_level: OperationRiskLevel::Safe,
1410 tags: vec![],
1411 details: OperationDetails::OpenAPI {
1412 method: "GET".to_string(),
1413 path: "/users".to_string(),
1414 parameters: vec![],
1415 has_request_body: false,
1416 },
1417 };
1418 assert!(blocklist.is_blocked(&op).is_none());
1419 }
1420
1421 #[test]
1422 fn test_exposure_modes() {
1423 let policy = ToolExposurePolicy {
1425 mode: ExposureMode::AllowAll,
1426 blocklist: ["blocked".to_string()].into_iter().collect(),
1427 ..Default::default()
1428 };
1429
1430 let allowed_op = Operation::new("allowed", "Allowed", OperationCategory::Read);
1431 let blocked_op = Operation::new("blocked", "Blocked", OperationCategory::Read);
1432
1433 assert!(policy.is_allowed(&allowed_op).is_none());
1434 assert!(policy.is_allowed(&blocked_op).is_some());
1435
1436 let policy = ToolExposurePolicy {
1438 mode: ExposureMode::Allowlist,
1439 allowlist: ["allowed".to_string()].into_iter().collect(),
1440 ..Default::default()
1441 };
1442
1443 assert!(policy.is_allowed(&allowed_op).is_none());
1444 assert!(policy.is_allowed(&blocked_op).is_some());
1445
1446 let policy = ToolExposurePolicy {
1448 mode: ExposureMode::DenyAll,
1449 ..Default::default()
1450 };
1451
1452 assert!(policy.is_allowed(&allowed_op).is_some());
1453 }
1454
1455 #[test]
1456 fn test_schema_deriver() {
1457 let operations = vec![
1458 Operation::new("listUsers", "List Users", OperationCategory::Read),
1459 Operation::new("createUser", "Create User", OperationCategory::Create),
1460 Operation::new("deleteUser", "Delete User", OperationCategory::Delete),
1461 Operation::new("factoryReset", "Factory Reset", OperationCategory::Admin),
1462 ];
1463
1464 let policy = McpExposurePolicy {
1465 global_blocklist: GlobalBlocklist {
1466 operations: ["factoryReset".to_string()].into_iter().collect(),
1467 ..Default::default()
1468 },
1469 tools: ToolExposurePolicy {
1470 mode: ExposureMode::AllowAll,
1471 ..Default::default()
1472 },
1473 code_mode: CodeModeExposurePolicy {
1474 reads: MethodExposurePolicy {
1475 mode: ExposureMode::AllowAll,
1476 ..Default::default()
1477 },
1478 writes: MethodExposurePolicy {
1479 mode: ExposureMode::Allowlist,
1480 allowlist: ["createUser".to_string()].into_iter().collect(),
1481 ..Default::default()
1482 },
1483 deletes: MethodExposurePolicy {
1484 mode: ExposureMode::DenyAll,
1485 ..Default::default()
1486 },
1487 ..Default::default()
1488 },
1489 };
1490
1491 let deriver = SchemaDeriver::new(operations, policy, "test-hash".to_string());
1492
1493 let tools = deriver.derive_tools_schema();
1495 assert_eq!(tools.operations.len(), 3);
1496 assert!(tools.contains("listUsers"));
1497 assert!(tools.contains("createUser"));
1498 assert!(tools.contains("deleteUser"));
1499 assert!(!tools.contains("factoryReset"));
1500
1501 let code_mode = deriver.derive_code_mode_schema();
1507 assert_eq!(code_mode.operations.len(), 2);
1508 assert!(code_mode.contains("listUsers"));
1509 assert!(code_mode.contains("createUser"));
1510 assert!(!code_mode.contains("deleteUser"));
1511 assert!(!code_mode.contains("factoryReset"));
1512 }
1513}