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();
776
777 if parts.len() == 1 {
778 return pattern == text;
780 }
781
782 let mut pos = 0;
783 for (i, part) in parts.iter().enumerate() {
784 if part.is_empty() {
785 continue;
786 }
787
788 if i == 0 {
789 if !text.starts_with(part) {
791 return false;
792 }
793 pos = part.len();
794 } else if i == parts.len() - 1 {
795 if !text[pos..].ends_with(part) {
797 return false;
798 }
799 } else {
800 match text[pos..].find(part) {
802 Some(found) => pos += found + part.len(),
803 None => return false,
804 }
805 }
806 }
807
808 true
809}
810
811pub struct SchemaDeriver {
817 operations: Vec<Operation>,
819
820 policy: McpExposurePolicy,
822
823 source_hash: String,
825
826 policy_hash: String,
828}
829
830impl SchemaDeriver {
831 pub fn new(operations: Vec<Operation>, policy: McpExposurePolicy, source_hash: String) -> Self {
833 let policy_hash = Self::compute_policy_hash(&policy);
834 Self {
835 operations,
836 policy,
837 source_hash,
838 policy_hash,
839 }
840 }
841
842 pub fn derive_tools_schema(&self) -> DerivedSchema {
844 let mut included = Vec::new();
845 let mut filtered = Vec::new();
846
847 for op in &self.operations {
848 if let Some(reason) = self.policy.global_blocklist.is_blocked(op) {
850 filtered.push(FilteredOperation {
851 operation_id: op.id.clone(),
852 operation_name: op.name.clone(),
853 reason,
854 policy: "global_blocklist".to_string(),
855 });
856 continue;
857 }
858
859 if let Some(reason) = self.policy.tools.is_allowed(op) {
861 filtered.push(FilteredOperation {
862 operation_id: op.id.clone(),
863 operation_name: op.name.clone(),
864 reason,
865 policy: "tools".to_string(),
866 });
867 continue;
868 }
869
870 let op = self.apply_tool_overrides(op);
872 included.push(op);
873 }
874
875 self.build_derived_schema(included, filtered, "tools")
876 }
877
878 pub fn derive_code_mode_schema(&self) -> DerivedSchema {
880 let mut included = Vec::new();
881 let mut filtered = Vec::new();
882
883 for op in &self.operations {
884 if let Some(reason) = self.policy.global_blocklist.is_blocked(op) {
886 filtered.push(FilteredOperation {
887 operation_id: op.id.clone(),
888 operation_name: op.name.clone(),
889 reason,
890 policy: "global_blocklist".to_string(),
891 });
892 continue;
893 }
894
895 if let Some(reason) = self.policy.code_mode.is_allowed(op) {
897 let policy_name = match op.category {
898 OperationCategory::Read => "code_mode.reads",
899 OperationCategory::Delete => "code_mode.deletes",
900 _ => "code_mode.writes",
901 };
902 filtered.push(FilteredOperation {
903 operation_id: op.id.clone(),
904 operation_name: op.name.clone(),
905 reason,
906 policy: policy_name.to_string(),
907 });
908 continue;
909 }
910
911 included.push(op.clone());
912 }
913
914 self.build_derived_schema(included, filtered, "code_mode")
915 }
916
917 pub fn is_tool_allowed(&self, operation_id: &str) -> bool {
919 self.operations
920 .iter()
921 .find(|op| op.id == operation_id)
922 .map(|op| {
923 self.policy.global_blocklist.is_blocked(op).is_none()
924 && self.policy.tools.is_allowed(op).is_none()
925 })
926 .unwrap_or(false)
927 }
928
929 pub fn is_code_mode_allowed(&self, operation_id: &str) -> bool {
931 self.operations
932 .iter()
933 .find(|op| op.id == operation_id)
934 .map(|op| {
935 self.policy.global_blocklist.is_blocked(op).is_none()
936 && self.policy.code_mode.is_allowed(op).is_none()
937 })
938 .unwrap_or(false)
939 }
940
941 pub fn get_tool_filter_reason(&self, operation_id: &str) -> Option<FilterReason> {
943 self.operations
944 .iter()
945 .find(|op| op.id == operation_id)
946 .and_then(|op| {
947 self.policy
948 .global_blocklist
949 .is_blocked(op)
950 .or_else(|| self.policy.tools.is_allowed(op))
951 })
952 }
953
954 pub fn get_code_mode_filter_reason(&self, operation_id: &str) -> Option<FilterReason> {
956 self.operations
957 .iter()
958 .find(|op| op.id == operation_id)
959 .and_then(|op| {
960 self.policy
961 .global_blocklist
962 .is_blocked(op)
963 .or_else(|| self.policy.code_mode.is_allowed(op))
964 })
965 }
966
967 pub fn find_operation_id(&self, method: &str, path_pattern: &str) -> Option<String> {
979 let method_upper = method.to_uppercase();
980 let normalized_pattern = Self::normalize_path_for_matching(path_pattern);
981
982 for op in &self.operations {
983 if let OperationDetails::OpenAPI {
984 method: op_method,
985 path: op_path,
986 ..
987 } = &op.details
988 {
989 if op_method.to_uppercase() == method_upper {
990 let normalized_op_path = Self::normalize_path_for_matching(op_path);
991 if Self::paths_match(&normalized_pattern, &normalized_op_path) {
992 return Some(op.id.clone());
993 }
994 }
995 }
996 }
997 None
998 }
999
1000 pub fn get_operations_for_allowlist(&self) -> Vec<(String, String, String)> {
1004 self.operations
1005 .iter()
1006 .filter_map(|op| {
1007 if let OperationDetails::OpenAPI { method, path, .. } = &op.details {
1008 let method_path = format!("{}:{}", method.to_uppercase(), path);
1009 let description = op.description.clone().unwrap_or_else(|| op.name.clone());
1010 Some((op.id.clone(), method_path, description))
1011 } else {
1012 None
1013 }
1014 })
1015 .collect()
1016 }
1017
1018 fn normalize_path_for_matching(path: &str) -> String {
1020 path.split('/')
1021 .map(|segment| {
1022 if segment.starts_with('{') && segment.ends_with('}') {
1023 "*" } else if segment.starts_with(':') {
1025 "*" } else if segment == "*" {
1027 "*"
1028 } else {
1029 segment
1030 }
1031 })
1032 .collect::<Vec<_>>()
1033 .join("/")
1034 }
1035
1036 fn paths_match(pattern: &str, path: &str) -> bool {
1038 let pattern_parts: Vec<_> = pattern.split('/').collect();
1039 let path_parts: Vec<_> = path.split('/').collect();
1040
1041 if pattern_parts.len() != path_parts.len() {
1042 return false;
1043 }
1044
1045 for (p, s) in pattern_parts.iter().zip(path_parts.iter()) {
1046 if *p == "*" || *s == "*" {
1047 continue; }
1049 if p != s {
1050 return false;
1051 }
1052 }
1053 true
1054 }
1055
1056 fn apply_tool_overrides(&self, op: &Operation) -> Operation {
1058 let mut op = op.clone();
1059
1060 if let Some(override_config) = self.policy.tools.overrides.get(&op.id) {
1061 if let Some(name) = &override_config.name {
1062 op.name = name.clone();
1063 }
1064 if let Some(description) = &override_config.description {
1065 op.description = Some(description.clone());
1066 }
1067 if override_config.dangerous {
1068 op.risk_level = OperationRiskLevel::High;
1069 }
1070 }
1071
1072 op
1073 }
1074
1075 fn build_derived_schema(
1077 &self,
1078 operations: Vec<Operation>,
1079 filtered: Vec<FilteredOperation>,
1080 context: &str,
1081 ) -> DerivedSchema {
1082 let mut filtered_by_reason: HashMap<String, usize> = HashMap::new();
1084 for f in &filtered {
1085 let reason_type = match &f.reason {
1086 FilterReason::GlobalBlocklistOperation { .. } => "global_blocklist_operation",
1087 FilterReason::GlobalBlocklistPattern { .. } => "global_blocklist_pattern",
1088 FilterReason::GlobalBlocklistCategory { .. } => "global_blocklist_category",
1089 FilterReason::GlobalBlocklistRiskLevel { .. } => "global_blocklist_risk_level",
1090 FilterReason::ToolBlocklist => "tool_blocklist",
1091 FilterReason::ToolBlocklistPattern { .. } => "tool_blocklist_pattern",
1092 FilterReason::ToolNotInAllowlist => "tool_not_in_allowlist",
1093 FilterReason::ToolDenyAllMode => "tool_deny_all",
1094 FilterReason::CodeModeBlocklist => "code_mode_blocklist",
1095 FilterReason::CodeModeBlocklistPattern { .. } => "code_mode_blocklist_pattern",
1096 FilterReason::MethodBlocklist { .. } => "method_blocklist",
1097 FilterReason::MethodBlocklistPattern { .. } => "method_blocklist_pattern",
1098 FilterReason::MethodNotInAllowlist { .. } => "method_not_in_allowlist",
1099 FilterReason::MethodDenyAllMode { .. } => "method_deny_all",
1100 };
1101 *filtered_by_reason
1102 .entry(reason_type.to_string())
1103 .or_default() += 1;
1104 }
1105
1106 let stats = DerivationStats {
1107 source_total: self.operations.len(),
1108 derived_total: operations.len(),
1109 filtered_total: filtered.len(),
1110 filtered_by_reason,
1111 };
1112
1113 let documentation = self.generate_documentation(&operations, context);
1115
1116 let cache_key = format!("{}:{}:{}", context, self.source_hash, self.policy_hash);
1118
1119 let now = std::time::SystemTime::now()
1120 .duration_since(std::time::UNIX_EPOCH)
1121 .map(|d| d.as_secs() as i64)
1122 .unwrap_or(0);
1123
1124 DerivedSchema {
1125 operations,
1126 documentation,
1127 metadata: DerivationMetadata {
1128 context: context.to_string(),
1129 derived_at: now,
1130 source_hash: self.source_hash.clone(),
1131 policy_hash: self.policy_hash.clone(),
1132 cache_key,
1133 filtered,
1134 stats,
1135 },
1136 }
1137 }
1138
1139 fn generate_documentation(&self, operations: &[Operation], context: &str) -> String {
1141 let mut doc = String::new();
1142
1143 if context == "code_mode" {
1144 doc.push_str("# API Operations Available in Code Mode\n\n");
1145 } else {
1146 doc.push_str("# API Operations Available as MCP Tools\n\n");
1147 }
1148
1149 doc.push_str(&format!(
1150 "**{} of {} operations available**\n\n",
1151 operations.len(),
1152 self.operations.len()
1153 ));
1154
1155 let reads: Vec<_> = operations
1157 .iter()
1158 .filter(|o| o.category == OperationCategory::Read)
1159 .collect();
1160 let writes: Vec<_> = operations
1161 .iter()
1162 .filter(|o| {
1163 matches!(
1164 o.category,
1165 OperationCategory::Create | OperationCategory::Update
1166 )
1167 })
1168 .collect();
1169 let deletes: Vec<_> = operations
1170 .iter()
1171 .filter(|o| o.category == OperationCategory::Delete)
1172 .collect();
1173
1174 doc.push_str(&format!(
1176 "## Read Operations ({} available)\n\n",
1177 reads.len()
1178 ));
1179 if reads.is_empty() {
1180 doc.push_str("_No read operations available._\n\n");
1181 } else {
1182 for op in reads {
1183 self.document_operation(&mut doc, op, context);
1184 }
1185 }
1186
1187 doc.push_str(&format!(
1189 "\n## Write Operations ({} available)\n\n",
1190 writes.len()
1191 ));
1192 if writes.is_empty() {
1193 doc.push_str("_No write operations available._\n\n");
1194 } else {
1195 for op in writes {
1196 self.document_operation(&mut doc, op, context);
1197 }
1198 }
1199
1200 doc.push_str(&format!(
1202 "\n## Delete Operations ({} available)\n\n",
1203 deletes.len()
1204 ));
1205 if deletes.is_empty() {
1206 doc.push_str("_No delete operations available._\n\n");
1207 } else {
1208 for op in deletes {
1209 self.document_operation(&mut doc, op, context);
1210 }
1211 }
1212
1213 doc
1214 }
1215
1216 fn document_operation(&self, doc: &mut String, op: &Operation, context: &str) {
1218 match &op.details {
1219 OperationDetails::OpenAPI { method, path, .. } => {
1220 if context == "code_mode" {
1221 let method_lower = method.to_lowercase();
1222 doc.push_str(&format!(
1223 "- `api.{}(\"{}\")` - {}\n",
1224 method_lower, path, op.name
1225 ));
1226 } else {
1227 doc.push_str(&format!("- **{}**: `{} {}`\n", op.name, method, path));
1228 }
1229 },
1230 OperationDetails::GraphQL {
1231 operation_type,
1232 field_name,
1233 ..
1234 } => {
1235 doc.push_str(&format!(
1236 "- **{}**: `{:?}.{}`\n",
1237 op.name, operation_type, field_name
1238 ));
1239 },
1240 OperationDetails::Sql {
1241 statement_type,
1242 table,
1243 ..
1244 } => {
1245 doc.push_str(&format!(
1246 "- **{}**: `{:?} {}`\n",
1247 op.name, statement_type, table
1248 ));
1249 },
1250 OperationDetails::Unknown => {
1251 doc.push_str(&format!("- **{}** ({})\n", op.name, op.id));
1252 },
1253 }
1254
1255 if let Some(desc) = &op.description {
1256 doc.push_str(&format!(" {}\n", desc));
1257 }
1258 }
1259
1260 fn compute_policy_hash(policy: &McpExposurePolicy) -> String {
1262 use std::collections::hash_map::DefaultHasher;
1263 use std::hash::{Hash, Hasher};
1264
1265 let mut hasher = DefaultHasher::new();
1266
1267 let mut ops: Vec<_> = policy.global_blocklist.operations.iter().collect();
1269 ops.sort();
1270 for op in ops {
1271 op.hash(&mut hasher);
1272 }
1273
1274 let mut patterns: Vec<_> = policy.global_blocklist.patterns.iter().collect();
1275 patterns.sort();
1276 for p in patterns {
1277 p.hash(&mut hasher);
1278 }
1279
1280 format!("{:?}", policy.tools.mode).hash(&mut hasher);
1282 let mut allowlist: Vec<_> = policy.tools.allowlist.iter().collect();
1283 allowlist.sort();
1284 for a in allowlist {
1285 a.hash(&mut hasher);
1286 }
1287
1288 format!("{:?}", policy.code_mode.reads.mode).hash(&mut hasher);
1290 format!("{:?}", policy.code_mode.writes.mode).hash(&mut hasher);
1291 format!("{:?}", policy.code_mode.deletes.mode).hash(&mut hasher);
1292
1293 format!("{:016x}", hasher.finish())
1294 }
1295}
1296
1297#[cfg(test)]
1302mod tests {
1303 use super::*;
1304
1305 #[test]
1306 fn test_pattern_matching() {
1307 assert!(pattern_matches("GET /users", "GET /users"));
1309 assert!(!pattern_matches("GET /users", "POST /users"));
1310
1311 assert!(pattern_matches("GET /users/*", "GET /users/123"));
1313 assert!(pattern_matches("GET /users/*", "GET /users/123/posts"));
1314 assert!(!pattern_matches("GET /users/*", "GET /posts/123"));
1315
1316 assert!(pattern_matches("* /admin/*", "GET /admin/users"));
1318 assert!(pattern_matches("* /admin/*", "DELETE /admin/config"));
1319
1320 assert!(pattern_matches(
1322 "GET /users/*/posts",
1323 "GET /users/123/posts"
1324 ));
1325
1326 assert!(pattern_matches("*/admin/*", "DELETE /admin/all"));
1328
1329 assert!(pattern_matches("GET /USERS", "get /users"));
1331 }
1332
1333 #[test]
1334 fn test_global_blocklist() {
1335 let blocklist = GlobalBlocklist {
1336 operations: ["factoryReset".to_string()].into_iter().collect(),
1337 patterns: ["* /admin/*".to_string()].into_iter().collect(),
1338 categories: [OperationCategory::Internal].into_iter().collect(),
1339 risk_levels: [OperationRiskLevel::Critical].into_iter().collect(),
1340 };
1341
1342 let op = Operation {
1344 id: "factoryReset".to_string(),
1345 name: "Factory Reset".to_string(),
1346 description: None,
1347 category: OperationCategory::Admin,
1348 is_read_only: false,
1349 risk_level: OperationRiskLevel::Critical,
1350 tags: vec![],
1351 details: OperationDetails::Unknown,
1352 };
1353 assert!(blocklist.is_blocked(&op).is_some());
1354
1355 let op = Operation {
1357 id: "listAdminUsers".to_string(),
1358 name: "List Admin Users".to_string(),
1359 description: None,
1360 category: OperationCategory::Read,
1361 is_read_only: true,
1362 risk_level: OperationRiskLevel::Safe,
1363 tags: vec![],
1364 details: OperationDetails::OpenAPI {
1365 method: "GET".to_string(),
1366 path: "/admin/users".to_string(),
1367 parameters: vec![],
1368 has_request_body: false,
1369 },
1370 };
1371 assert!(blocklist.is_blocked(&op).is_some());
1372
1373 let op = Operation {
1375 id: "internalSync".to_string(),
1376 name: "Internal Sync".to_string(),
1377 description: None,
1378 category: OperationCategory::Internal,
1379 is_read_only: false,
1380 risk_level: OperationRiskLevel::Low,
1381 tags: vec![],
1382 details: OperationDetails::Unknown,
1383 };
1384 assert!(blocklist.is_blocked(&op).is_some());
1385
1386 let op = Operation {
1388 id: "listUsers".to_string(),
1389 name: "List Users".to_string(),
1390 description: None,
1391 category: OperationCategory::Read,
1392 is_read_only: true,
1393 risk_level: OperationRiskLevel::Safe,
1394 tags: vec![],
1395 details: OperationDetails::OpenAPI {
1396 method: "GET".to_string(),
1397 path: "/users".to_string(),
1398 parameters: vec![],
1399 has_request_body: false,
1400 },
1401 };
1402 assert!(blocklist.is_blocked(&op).is_none());
1403 }
1404
1405 #[test]
1406 fn test_exposure_modes() {
1407 let policy = ToolExposurePolicy {
1409 mode: ExposureMode::AllowAll,
1410 blocklist: ["blocked".to_string()].into_iter().collect(),
1411 ..Default::default()
1412 };
1413
1414 let allowed_op = Operation::new("allowed", "Allowed", OperationCategory::Read);
1415 let blocked_op = Operation::new("blocked", "Blocked", OperationCategory::Read);
1416
1417 assert!(policy.is_allowed(&allowed_op).is_none());
1418 assert!(policy.is_allowed(&blocked_op).is_some());
1419
1420 let policy = ToolExposurePolicy {
1422 mode: ExposureMode::Allowlist,
1423 allowlist: ["allowed".to_string()].into_iter().collect(),
1424 ..Default::default()
1425 };
1426
1427 assert!(policy.is_allowed(&allowed_op).is_none());
1428 assert!(policy.is_allowed(&blocked_op).is_some());
1429
1430 let policy = ToolExposurePolicy {
1432 mode: ExposureMode::DenyAll,
1433 ..Default::default()
1434 };
1435
1436 assert!(policy.is_allowed(&allowed_op).is_some());
1437 }
1438
1439 #[test]
1440 fn test_schema_deriver() {
1441 let operations = vec![
1442 Operation::new("listUsers", "List Users", OperationCategory::Read),
1443 Operation::new("createUser", "Create User", OperationCategory::Create),
1444 Operation::new("deleteUser", "Delete User", OperationCategory::Delete),
1445 Operation::new("factoryReset", "Factory Reset", OperationCategory::Admin),
1446 ];
1447
1448 let policy = McpExposurePolicy {
1449 global_blocklist: GlobalBlocklist {
1450 operations: ["factoryReset".to_string()].into_iter().collect(),
1451 ..Default::default()
1452 },
1453 tools: ToolExposurePolicy {
1454 mode: ExposureMode::AllowAll,
1455 ..Default::default()
1456 },
1457 code_mode: CodeModeExposurePolicy {
1458 reads: MethodExposurePolicy {
1459 mode: ExposureMode::AllowAll,
1460 ..Default::default()
1461 },
1462 writes: MethodExposurePolicy {
1463 mode: ExposureMode::Allowlist,
1464 allowlist: ["createUser".to_string()].into_iter().collect(),
1465 ..Default::default()
1466 },
1467 deletes: MethodExposurePolicy {
1468 mode: ExposureMode::DenyAll,
1469 ..Default::default()
1470 },
1471 ..Default::default()
1472 },
1473 };
1474
1475 let deriver = SchemaDeriver::new(operations, policy, "test-hash".to_string());
1476
1477 let tools = deriver.derive_tools_schema();
1479 assert_eq!(tools.operations.len(), 3);
1480 assert!(tools.contains("listUsers"));
1481 assert!(tools.contains("createUser"));
1482 assert!(tools.contains("deleteUser"));
1483 assert!(!tools.contains("factoryReset"));
1484
1485 let code_mode = deriver.derive_code_mode_schema();
1491 assert_eq!(code_mode.operations.len(), 2);
1492 assert!(code_mode.contains("listUsers"));
1493 assert!(code_mode.contains("createUser"));
1494 assert!(!code_mode.contains("deleteUser"));
1495 assert!(!code_mode.contains("factoryReset"));
1496 }
1497}