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, PartialEq, Eq, Hash, Serialize, Deserialize)]
110#[serde(rename_all = "lowercase")]
111pub enum OperationRiskLevel {
112 Safe,
114 Low,
116 Medium,
118 High,
120 Critical,
122}
123
124impl Default for OperationRiskLevel {
125 fn default() -> Self {
126 Self::Medium
127 }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct Operation {
133 pub id: String,
138
139 pub name: String,
141
142 #[serde(default)]
144 pub description: Option<String>,
145
146 pub category: OperationCategory,
148
149 pub is_read_only: bool,
151
152 #[serde(default)]
154 pub risk_level: OperationRiskLevel,
155
156 #[serde(default)]
158 pub tags: Vec<String>,
159
160 pub details: OperationDetails,
162}
163
164impl Operation {
165 pub fn new(
167 id: impl Into<String>,
168 name: impl Into<String>,
169 category: OperationCategory,
170 ) -> Self {
171 let is_read_only = category.is_read_only();
172 Self {
173 id: id.into(),
174 name: name.into(),
175 description: None,
176 category,
177 is_read_only,
178 risk_level: if is_read_only {
179 OperationRiskLevel::Safe
180 } else if category.is_delete() {
181 OperationRiskLevel::High
182 } else {
183 OperationRiskLevel::Low
184 },
185 tags: Vec::new(),
186 details: OperationDetails::Unknown,
187 }
188 }
189
190 pub fn matches_pattern(&self, pattern: &str) -> bool {
193 match &self.details {
194 OperationDetails::OpenAPI { method, path, .. } => {
195 let endpoint = format!("{} {}", method.to_uppercase(), path);
197 pattern_matches(pattern, &endpoint) || pattern_matches(pattern, &self.id)
198 }
199 OperationDetails::GraphQL {
200 operation_type,
201 field_name,
202 ..
203 } => {
204 let full_name = format!("{:?}.{}", operation_type, field_name);
206 pattern_matches(pattern, &full_name) || pattern_matches(pattern, &self.id)
207 }
208 OperationDetails::Sql {
209 statement_type,
210 table,
211 ..
212 } => {
213 let full_name = format!("{:?} {}", statement_type, table);
215 pattern_matches(pattern, &full_name.to_lowercase())
216 || pattern_matches(pattern, &self.id)
217 }
218 OperationDetails::Unknown => pattern_matches(pattern, &self.id),
219 }
220 }
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(tag = "format", rename_all = "lowercase")]
226pub enum OperationDetails {
227 #[serde(rename = "openapi")]
229 OpenAPI {
230 method: String,
231 path: String,
232 #[serde(default)]
233 parameters: Vec<OperationParameter>,
234 #[serde(default)]
235 has_request_body: bool,
236 },
237
238 #[serde(rename = "graphql")]
240 GraphQL {
241 operation_type: GraphQLOperationType,
242 field_name: String,
243 #[serde(default)]
244 arguments: Vec<OperationParameter>,
245 #[serde(default)]
246 return_type: Option<String>,
247 },
248
249 #[serde(rename = "sql")]
251 Sql {
252 statement_type: SqlStatementType,
253 table: String,
254 #[serde(default)]
255 columns: Vec<String>,
256 },
257
258 Unknown,
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
264#[serde(rename_all = "UPPERCASE")]
265pub enum SqlStatementType {
266 Select,
267 Insert,
268 Update,
269 Delete,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct OperationParameter {
275 pub name: String,
276 #[serde(default)]
277 pub description: Option<String>,
278 pub required: bool,
279 #[serde(default)]
280 pub param_type: Option<String>,
281}
282
283#[derive(Debug, Clone, Default, Serialize, Deserialize)]
289pub struct McpExposurePolicy {
290 #[serde(default)]
292 pub global_blocklist: GlobalBlocklist,
293
294 #[serde(default)]
296 pub tools: ToolExposurePolicy,
297
298 #[serde(default)]
300 pub code_mode: CodeModeExposurePolicy,
301}
302
303#[derive(Debug, Clone, Default, Serialize, Deserialize)]
305pub struct GlobalBlocklist {
306 #[serde(default)]
308 pub operations: HashSet<String>,
309
310 #[serde(default)]
315 pub patterns: HashSet<String>,
316
317 #[serde(default)]
319 pub categories: HashSet<OperationCategory>,
320
321 #[serde(default)]
323 pub risk_levels: HashSet<OperationRiskLevel>,
324}
325
326impl GlobalBlocklist {
327 pub fn is_blocked(&self, operation: &Operation) -> Option<FilterReason> {
329 if self.operations.contains(&operation.id) {
331 return Some(FilterReason::GlobalBlocklistOperation {
332 operation_id: operation.id.clone(),
333 });
334 }
335
336 for pattern in &self.patterns {
338 if operation.matches_pattern(pattern) {
339 return Some(FilterReason::GlobalBlocklistPattern {
340 pattern: pattern.clone(),
341 });
342 }
343 }
344
345 if self.categories.contains(&operation.category) {
347 return Some(FilterReason::GlobalBlocklistCategory {
348 category: operation.category,
349 });
350 }
351
352 if self.risk_levels.contains(&operation.risk_level) {
354 return Some(FilterReason::GlobalBlocklistRiskLevel {
355 level: operation.risk_level,
356 });
357 }
358
359 None
360 }
361}
362
363#[derive(Debug, Clone, Default, Serialize, Deserialize)]
365pub struct ToolExposurePolicy {
366 #[serde(default)]
368 pub mode: ExposureMode,
369
370 #[serde(default)]
372 pub allowlist: HashSet<String>,
373
374 #[serde(default)]
376 pub blocklist: HashSet<String>,
377
378 #[serde(default)]
380 pub overrides: HashMap<String, ToolOverride>,
381}
382
383impl ToolExposurePolicy {
384 pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
386 if self.blocklist.contains(&operation.id) {
388 return Some(FilterReason::ToolBlocklist);
389 }
390
391 for pattern in &self.blocklist {
393 if pattern.contains('*') && operation.matches_pattern(pattern) {
394 return Some(FilterReason::ToolBlocklistPattern {
395 pattern: pattern.clone(),
396 });
397 }
398 }
399
400 match self.mode {
401 ExposureMode::AllowAll => None,
402 ExposureMode::DenyAll => Some(FilterReason::ToolDenyAllMode),
403 ExposureMode::Allowlist => {
404 if self.allowlist.contains(&operation.id) {
406 return None;
407 }
408 for pattern in &self.allowlist {
410 if pattern.contains('*') && operation.matches_pattern(pattern) {
411 return None;
412 }
413 }
414 Some(FilterReason::ToolNotInAllowlist)
415 }
416 ExposureMode::Blocklist => None, }
418 }
419}
420
421#[derive(Debug, Clone, Default, Serialize, Deserialize)]
423pub struct CodeModeExposurePolicy {
424 #[serde(default)]
426 pub reads: MethodExposurePolicy,
427
428 #[serde(default)]
430 pub writes: MethodExposurePolicy,
431
432 #[serde(default)]
434 pub deletes: MethodExposurePolicy,
435
436 #[serde(default)]
438 pub blocklist: HashSet<String>,
439}
440
441impl CodeModeExposurePolicy {
442 pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
444 if self.blocklist.contains(&operation.id) {
446 return Some(FilterReason::CodeModeBlocklist);
447 }
448
449 for pattern in &self.blocklist {
451 if pattern.contains('*') && operation.matches_pattern(pattern) {
452 return Some(FilterReason::CodeModeBlocklistPattern {
453 pattern: pattern.clone(),
454 });
455 }
456 }
457
458 let method_policy = self.get_method_policy(operation);
460 method_policy.is_allowed(operation)
461 }
462
463 fn get_method_policy(&self, operation: &Operation) -> &MethodExposurePolicy {
465 match operation.category {
466 OperationCategory::Read => &self.reads,
467 OperationCategory::Delete => &self.deletes,
468 OperationCategory::Create | OperationCategory::Update => &self.writes,
469 OperationCategory::Admin | OperationCategory::Internal => &self.writes,
470 }
471 }
472}
473
474#[derive(Debug, Clone, Default, Serialize, Deserialize)]
476pub struct MethodExposurePolicy {
477 #[serde(default)]
479 pub mode: ExposureMode,
480
481 #[serde(default)]
484 pub allowlist: HashSet<String>,
485
486 #[serde(default)]
488 pub blocklist: HashSet<String>,
489}
490
491impl MethodExposurePolicy {
492 pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
494 if self.blocklist.contains(&operation.id) {
496 return Some(FilterReason::MethodBlocklist {
497 method_type: Self::method_type_name(operation),
498 });
499 }
500
501 for pattern in &self.blocklist {
503 if pattern.contains('*') && operation.matches_pattern(pattern) {
504 return Some(FilterReason::MethodBlocklistPattern {
505 method_type: Self::method_type_name(operation),
506 pattern: pattern.clone(),
507 });
508 }
509 }
510
511 match self.mode {
512 ExposureMode::AllowAll => None,
513 ExposureMode::DenyAll => Some(FilterReason::MethodDenyAllMode {
514 method_type: Self::method_type_name(operation),
515 }),
516 ExposureMode::Allowlist => {
517 if self.allowlist.contains(&operation.id) {
519 return None;
520 }
521 for pattern in &self.allowlist {
523 if pattern.contains('*') && operation.matches_pattern(pattern) {
524 return None;
525 }
526 }
527 Some(FilterReason::MethodNotInAllowlist {
528 method_type: Self::method_type_name(operation),
529 })
530 }
531 ExposureMode::Blocklist => None, }
533 }
534
535 fn method_type_name(operation: &Operation) -> String {
536 match operation.category {
537 OperationCategory::Read => "reads".to_string(),
538 OperationCategory::Delete => "deletes".to_string(),
539 _ => "writes".to_string(),
540 }
541 }
542}
543
544#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(rename_all = "snake_case")]
547pub enum ExposureMode {
548 #[default]
550 AllowAll,
551 DenyAll,
553 Allowlist,
555 Blocklist,
557}
558
559#[derive(Debug, Clone, Default, Serialize, Deserialize)]
561pub struct ToolOverride {
562 #[serde(default)]
564 pub name: Option<String>,
565
566 #[serde(default)]
568 pub description: Option<String>,
569
570 #[serde(default)]
572 pub dangerous: bool,
573
574 #[serde(default)]
576 pub hidden: bool,
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
585pub struct DerivedSchema {
586 pub operations: Vec<Operation>,
588
589 pub documentation: String,
591
592 pub metadata: DerivationMetadata,
594}
595
596impl DerivedSchema {
597 pub fn get_operation(&self, id: &str) -> Option<&Operation> {
599 self.operations.iter().find(|op| op.id == id)
600 }
601
602 pub fn contains(&self, id: &str) -> bool {
604 self.operations.iter().any(|op| op.id == id)
605 }
606
607 pub fn operation_ids(&self) -> HashSet<String> {
609 self.operations.iter().map(|op| op.id.clone()).collect()
610 }
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize)]
615pub struct DerivationMetadata {
616 pub context: String,
618
619 pub derived_at: i64,
621
622 pub source_hash: String,
624
625 pub policy_hash: String,
627
628 pub cache_key: String,
630
631 pub filtered: Vec<FilteredOperation>,
633
634 pub stats: DerivationStats,
636}
637
638#[derive(Debug, Clone, Serialize, Deserialize)]
640pub struct FilteredOperation {
641 pub operation_id: String,
643
644 pub operation_name: String,
646
647 pub reason: FilterReason,
649
650 pub policy: String,
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize)]
656#[serde(tag = "type", rename_all = "snake_case")]
657pub enum FilterReason {
658 GlobalBlocklistOperation { operation_id: String },
660
661 GlobalBlocklistPattern { pattern: String },
663
664 GlobalBlocklistCategory { category: OperationCategory },
666
667 GlobalBlocklistRiskLevel { level: OperationRiskLevel },
669
670 ToolBlocklist,
672
673 ToolBlocklistPattern { pattern: String },
675
676 ToolNotInAllowlist,
678
679 ToolDenyAllMode,
681
682 CodeModeBlocklist,
684
685 CodeModeBlocklistPattern { pattern: String },
687
688 MethodBlocklist { method_type: String },
690
691 MethodBlocklistPattern {
693 method_type: String,
694 pattern: String,
695 },
696
697 MethodNotInAllowlist { method_type: String },
699
700 MethodDenyAllMode { method_type: String },
702}
703
704impl FilterReason {
705 pub fn description(&self) -> String {
707 match self {
708 FilterReason::GlobalBlocklistOperation { operation_id } => {
709 format!("Operation '{}' is in the global blocklist", operation_id)
710 }
711 FilterReason::GlobalBlocklistPattern { pattern } => {
712 format!("Matches global blocklist pattern '{}'", pattern)
713 }
714 FilterReason::GlobalBlocklistCategory { category } => {
715 format!("Category '{:?}' is blocked globally", category)
716 }
717 FilterReason::GlobalBlocklistRiskLevel { level } => {
718 format!("Risk level '{:?}' is blocked globally", level)
719 }
720 FilterReason::ToolBlocklist => "Operation is in the tool blocklist".to_string(),
721 FilterReason::ToolBlocklistPattern { pattern } => {
722 format!("Matches tool blocklist pattern '{}'", pattern)
723 }
724 FilterReason::ToolNotInAllowlist => {
725 "Operation is not in the tool allowlist".to_string()
726 }
727 FilterReason::ToolDenyAllMode => "Tool exposure is set to deny_all".to_string(),
728 FilterReason::CodeModeBlocklist => {
729 "Operation is in the Code Mode blocklist".to_string()
730 }
731 FilterReason::CodeModeBlocklistPattern { pattern } => {
732 format!("Matches Code Mode blocklist pattern '{}'", pattern)
733 }
734 FilterReason::MethodBlocklist { method_type } => {
735 format!("Operation is in the {} blocklist", method_type)
736 }
737 FilterReason::MethodBlocklistPattern {
738 method_type,
739 pattern,
740 } => {
741 format!("Matches {} blocklist pattern '{}'", method_type, pattern)
742 }
743 FilterReason::MethodNotInAllowlist { method_type } => {
744 format!("Operation is not in the {} allowlist", method_type)
745 }
746 FilterReason::MethodDenyAllMode { method_type } => {
747 format!("{} exposure is set to deny_all", method_type)
748 }
749 }
750 }
751}
752
753#[derive(Debug, Clone, Default, Serialize, Deserialize)]
755pub struct DerivationStats {
756 pub source_total: usize,
758
759 pub derived_total: usize,
761
762 pub filtered_total: usize,
764
765 pub filtered_by_reason: HashMap<String, usize>,
767}
768
769pub fn pattern_matches(pattern: &str, text: &str) -> bool {
776 let pattern = pattern.to_lowercase();
777 let text = text.to_lowercase();
778
779 let parts: Vec<&str> = pattern.split('*').collect();
781
782 if parts.len() == 1 {
783 return pattern == text;
785 }
786
787 let mut pos = 0;
788 for (i, part) in parts.iter().enumerate() {
789 if part.is_empty() {
790 continue;
791 }
792
793 if i == 0 {
794 if !text.starts_with(part) {
796 return false;
797 }
798 pos = part.len();
799 } else if i == parts.len() - 1 {
800 if !text[pos..].ends_with(part) {
802 return false;
803 }
804 } else {
805 match text[pos..].find(part) {
807 Some(found) => pos += found + part.len(),
808 None => return false,
809 }
810 }
811 }
812
813 true
814}
815
816pub struct SchemaDeriver {
822 operations: Vec<Operation>,
824
825 policy: McpExposurePolicy,
827
828 source_hash: String,
830
831 policy_hash: String,
833}
834
835impl SchemaDeriver {
836 pub fn new(operations: Vec<Operation>, policy: McpExposurePolicy, source_hash: String) -> Self {
838 let policy_hash = Self::compute_policy_hash(&policy);
839 Self {
840 operations,
841 policy,
842 source_hash,
843 policy_hash,
844 }
845 }
846
847 pub fn derive_tools_schema(&self) -> DerivedSchema {
849 let mut included = Vec::new();
850 let mut filtered = Vec::new();
851
852 for op in &self.operations {
853 if let Some(reason) = self.policy.global_blocklist.is_blocked(op) {
855 filtered.push(FilteredOperation {
856 operation_id: op.id.clone(),
857 operation_name: op.name.clone(),
858 reason,
859 policy: "global_blocklist".to_string(),
860 });
861 continue;
862 }
863
864 if let Some(reason) = self.policy.tools.is_allowed(op) {
866 filtered.push(FilteredOperation {
867 operation_id: op.id.clone(),
868 operation_name: op.name.clone(),
869 reason,
870 policy: "tools".to_string(),
871 });
872 continue;
873 }
874
875 let op = self.apply_tool_overrides(op);
877 included.push(op);
878 }
879
880 self.build_derived_schema(included, filtered, "tools")
881 }
882
883 pub fn derive_code_mode_schema(&self) -> DerivedSchema {
885 let mut included = Vec::new();
886 let mut filtered = Vec::new();
887
888 for op in &self.operations {
889 if let Some(reason) = self.policy.global_blocklist.is_blocked(op) {
891 filtered.push(FilteredOperation {
892 operation_id: op.id.clone(),
893 operation_name: op.name.clone(),
894 reason,
895 policy: "global_blocklist".to_string(),
896 });
897 continue;
898 }
899
900 if let Some(reason) = self.policy.code_mode.is_allowed(op) {
902 let policy_name = match op.category {
903 OperationCategory::Read => "code_mode.reads",
904 OperationCategory::Delete => "code_mode.deletes",
905 _ => "code_mode.writes",
906 };
907 filtered.push(FilteredOperation {
908 operation_id: op.id.clone(),
909 operation_name: op.name.clone(),
910 reason,
911 policy: policy_name.to_string(),
912 });
913 continue;
914 }
915
916 included.push(op.clone());
917 }
918
919 self.build_derived_schema(included, filtered, "code_mode")
920 }
921
922 pub fn is_tool_allowed(&self, operation_id: &str) -> bool {
924 self.operations
925 .iter()
926 .find(|op| op.id == operation_id)
927 .map(|op| {
928 self.policy.global_blocklist.is_blocked(op).is_none()
929 && self.policy.tools.is_allowed(op).is_none()
930 })
931 .unwrap_or(false)
932 }
933
934 pub fn is_code_mode_allowed(&self, operation_id: &str) -> bool {
936 self.operations
937 .iter()
938 .find(|op| op.id == operation_id)
939 .map(|op| {
940 self.policy.global_blocklist.is_blocked(op).is_none()
941 && self.policy.code_mode.is_allowed(op).is_none()
942 })
943 .unwrap_or(false)
944 }
945
946 pub fn get_tool_filter_reason(&self, operation_id: &str) -> Option<FilterReason> {
948 self.operations
949 .iter()
950 .find(|op| op.id == operation_id)
951 .and_then(|op| {
952 self.policy
953 .global_blocklist
954 .is_blocked(op)
955 .or_else(|| self.policy.tools.is_allowed(op))
956 })
957 }
958
959 pub fn get_code_mode_filter_reason(&self, operation_id: &str) -> Option<FilterReason> {
961 self.operations
962 .iter()
963 .find(|op| op.id == operation_id)
964 .and_then(|op| {
965 self.policy
966 .global_blocklist
967 .is_blocked(op)
968 .or_else(|| self.policy.code_mode.is_allowed(op))
969 })
970 }
971
972 pub fn find_operation_id(&self, method: &str, path_pattern: &str) -> Option<String> {
984 let method_upper = method.to_uppercase();
985 let normalized_pattern = Self::normalize_path_for_matching(path_pattern);
986
987 for op in &self.operations {
988 if let OperationDetails::OpenAPI {
989 method: op_method,
990 path: op_path,
991 ..
992 } = &op.details
993 {
994 if op_method.to_uppercase() == method_upper {
995 let normalized_op_path = Self::normalize_path_for_matching(op_path);
996 if Self::paths_match(&normalized_pattern, &normalized_op_path) {
997 return Some(op.id.clone());
998 }
999 }
1000 }
1001 }
1002 None
1003 }
1004
1005 pub fn get_operations_for_allowlist(&self) -> Vec<(String, String, String)> {
1009 self.operations
1010 .iter()
1011 .filter_map(|op| {
1012 if let OperationDetails::OpenAPI { method, path, .. } = &op.details {
1013 let method_path = format!("{}:{}", method.to_uppercase(), path);
1014 let description = op.description.clone().unwrap_or_else(|| op.name.clone());
1015 Some((op.id.clone(), method_path, description))
1016 } else {
1017 None
1018 }
1019 })
1020 .collect()
1021 }
1022
1023 fn normalize_path_for_matching(path: &str) -> String {
1025 path.split('/')
1026 .map(|segment| {
1027 if segment.starts_with('{') && segment.ends_with('}') {
1028 "*" } else if segment.starts_with(':') {
1030 "*" } else if segment == "*" {
1032 "*"
1033 } else {
1034 segment
1035 }
1036 })
1037 .collect::<Vec<_>>()
1038 .join("/")
1039 }
1040
1041 fn paths_match(pattern: &str, path: &str) -> bool {
1043 let pattern_parts: Vec<_> = pattern.split('/').collect();
1044 let path_parts: Vec<_> = path.split('/').collect();
1045
1046 if pattern_parts.len() != path_parts.len() {
1047 return false;
1048 }
1049
1050 for (p, s) in pattern_parts.iter().zip(path_parts.iter()) {
1051 if *p == "*" || *s == "*" {
1052 continue; }
1054 if p != s {
1055 return false;
1056 }
1057 }
1058 true
1059 }
1060
1061 fn apply_tool_overrides(&self, op: &Operation) -> Operation {
1063 let mut op = op.clone();
1064
1065 if let Some(override_config) = self.policy.tools.overrides.get(&op.id) {
1066 if let Some(name) = &override_config.name {
1067 op.name = name.clone();
1068 }
1069 if let Some(description) = &override_config.description {
1070 op.description = Some(description.clone());
1071 }
1072 if override_config.dangerous {
1073 op.risk_level = OperationRiskLevel::High;
1074 }
1075 }
1076
1077 op
1078 }
1079
1080 fn build_derived_schema(
1082 &self,
1083 operations: Vec<Operation>,
1084 filtered: Vec<FilteredOperation>,
1085 context: &str,
1086 ) -> DerivedSchema {
1087 let mut filtered_by_reason: HashMap<String, usize> = HashMap::new();
1089 for f in &filtered {
1090 let reason_type = match &f.reason {
1091 FilterReason::GlobalBlocklistOperation { .. } => "global_blocklist_operation",
1092 FilterReason::GlobalBlocklistPattern { .. } => "global_blocklist_pattern",
1093 FilterReason::GlobalBlocklistCategory { .. } => "global_blocklist_category",
1094 FilterReason::GlobalBlocklistRiskLevel { .. } => "global_blocklist_risk_level",
1095 FilterReason::ToolBlocklist => "tool_blocklist",
1096 FilterReason::ToolBlocklistPattern { .. } => "tool_blocklist_pattern",
1097 FilterReason::ToolNotInAllowlist => "tool_not_in_allowlist",
1098 FilterReason::ToolDenyAllMode => "tool_deny_all",
1099 FilterReason::CodeModeBlocklist => "code_mode_blocklist",
1100 FilterReason::CodeModeBlocklistPattern { .. } => "code_mode_blocklist_pattern",
1101 FilterReason::MethodBlocklist { .. } => "method_blocklist",
1102 FilterReason::MethodBlocklistPattern { .. } => "method_blocklist_pattern",
1103 FilterReason::MethodNotInAllowlist { .. } => "method_not_in_allowlist",
1104 FilterReason::MethodDenyAllMode { .. } => "method_deny_all",
1105 };
1106 *filtered_by_reason
1107 .entry(reason_type.to_string())
1108 .or_default() += 1;
1109 }
1110
1111 let stats = DerivationStats {
1112 source_total: self.operations.len(),
1113 derived_total: operations.len(),
1114 filtered_total: filtered.len(),
1115 filtered_by_reason,
1116 };
1117
1118 let documentation = self.generate_documentation(&operations, context);
1120
1121 let cache_key = format!("{}:{}:{}", context, self.source_hash, self.policy_hash);
1123
1124 let now = std::time::SystemTime::now()
1125 .duration_since(std::time::UNIX_EPOCH)
1126 .map(|d| d.as_secs() as i64)
1127 .unwrap_or(0);
1128
1129 DerivedSchema {
1130 operations,
1131 documentation,
1132 metadata: DerivationMetadata {
1133 context: context.to_string(),
1134 derived_at: now,
1135 source_hash: self.source_hash.clone(),
1136 policy_hash: self.policy_hash.clone(),
1137 cache_key,
1138 filtered,
1139 stats,
1140 },
1141 }
1142 }
1143
1144 fn generate_documentation(&self, operations: &[Operation], context: &str) -> String {
1146 let mut doc = String::new();
1147
1148 if context == "code_mode" {
1149 doc.push_str("# API Operations Available in Code Mode\n\n");
1150 } else {
1151 doc.push_str("# API Operations Available as MCP Tools\n\n");
1152 }
1153
1154 doc.push_str(&format!(
1155 "**{} of {} operations available**\n\n",
1156 operations.len(),
1157 self.operations.len()
1158 ));
1159
1160 let reads: Vec<_> = operations
1162 .iter()
1163 .filter(|o| o.category == OperationCategory::Read)
1164 .collect();
1165 let writes: Vec<_> = operations
1166 .iter()
1167 .filter(|o| {
1168 matches!(
1169 o.category,
1170 OperationCategory::Create | OperationCategory::Update
1171 )
1172 })
1173 .collect();
1174 let deletes: Vec<_> = operations
1175 .iter()
1176 .filter(|o| o.category == OperationCategory::Delete)
1177 .collect();
1178
1179 doc.push_str(&format!(
1181 "## Read Operations ({} available)\n\n",
1182 reads.len()
1183 ));
1184 if reads.is_empty() {
1185 doc.push_str("_No read operations available._\n\n");
1186 } else {
1187 for op in reads {
1188 self.document_operation(&mut doc, op, context);
1189 }
1190 }
1191
1192 doc.push_str(&format!(
1194 "\n## Write Operations ({} available)\n\n",
1195 writes.len()
1196 ));
1197 if writes.is_empty() {
1198 doc.push_str("_No write operations available._\n\n");
1199 } else {
1200 for op in writes {
1201 self.document_operation(&mut doc, op, context);
1202 }
1203 }
1204
1205 doc.push_str(&format!(
1207 "\n## Delete Operations ({} available)\n\n",
1208 deletes.len()
1209 ));
1210 if deletes.is_empty() {
1211 doc.push_str("_No delete operations available._\n\n");
1212 } else {
1213 for op in deletes {
1214 self.document_operation(&mut doc, op, context);
1215 }
1216 }
1217
1218 doc
1219 }
1220
1221 fn document_operation(&self, doc: &mut String, op: &Operation, context: &str) {
1223 match &op.details {
1224 OperationDetails::OpenAPI { method, path, .. } => {
1225 if context == "code_mode" {
1226 let method_lower = method.to_lowercase();
1227 doc.push_str(&format!(
1228 "- `api.{}(\"{}\")` - {}\n",
1229 method_lower, path, op.name
1230 ));
1231 } else {
1232 doc.push_str(&format!("- **{}**: `{} {}`\n", op.name, method, path));
1233 }
1234 }
1235 OperationDetails::GraphQL {
1236 operation_type,
1237 field_name,
1238 ..
1239 } => {
1240 doc.push_str(&format!(
1241 "- **{}**: `{:?}.{}`\n",
1242 op.name, operation_type, field_name
1243 ));
1244 }
1245 OperationDetails::Sql {
1246 statement_type,
1247 table,
1248 ..
1249 } => {
1250 doc.push_str(&format!(
1251 "- **{}**: `{:?} {}`\n",
1252 op.name, statement_type, table
1253 ));
1254 }
1255 OperationDetails::Unknown => {
1256 doc.push_str(&format!("- **{}** ({})\n", op.name, op.id));
1257 }
1258 }
1259
1260 if let Some(desc) = &op.description {
1261 doc.push_str(&format!(" {}\n", desc));
1262 }
1263 }
1264
1265 fn compute_policy_hash(policy: &McpExposurePolicy) -> String {
1267 use std::collections::hash_map::DefaultHasher;
1268 use std::hash::{Hash, Hasher};
1269
1270 let mut hasher = DefaultHasher::new();
1271
1272 let mut ops: Vec<_> = policy.global_blocklist.operations.iter().collect();
1274 ops.sort();
1275 for op in ops {
1276 op.hash(&mut hasher);
1277 }
1278
1279 let mut patterns: Vec<_> = policy.global_blocklist.patterns.iter().collect();
1280 patterns.sort();
1281 for p in patterns {
1282 p.hash(&mut hasher);
1283 }
1284
1285 format!("{:?}", policy.tools.mode).hash(&mut hasher);
1287 let mut allowlist: Vec<_> = policy.tools.allowlist.iter().collect();
1288 allowlist.sort();
1289 for a in allowlist {
1290 a.hash(&mut hasher);
1291 }
1292
1293 format!("{:?}", policy.code_mode.reads.mode).hash(&mut hasher);
1295 format!("{:?}", policy.code_mode.writes.mode).hash(&mut hasher);
1296 format!("{:?}", policy.code_mode.deletes.mode).hash(&mut hasher);
1297
1298 format!("{:016x}", hasher.finish())
1299 }
1300}
1301
1302#[cfg(test)]
1307mod tests {
1308 use super::*;
1309
1310 #[test]
1311 fn test_pattern_matching() {
1312 assert!(pattern_matches("GET /users", "GET /users"));
1314 assert!(!pattern_matches("GET /users", "POST /users"));
1315
1316 assert!(pattern_matches("GET /users/*", "GET /users/123"));
1318 assert!(pattern_matches("GET /users/*", "GET /users/123/posts"));
1319 assert!(!pattern_matches("GET /users/*", "GET /posts/123"));
1320
1321 assert!(pattern_matches("* /admin/*", "GET /admin/users"));
1323 assert!(pattern_matches("* /admin/*", "DELETE /admin/config"));
1324
1325 assert!(pattern_matches(
1327 "GET /users/*/posts",
1328 "GET /users/123/posts"
1329 ));
1330
1331 assert!(pattern_matches("*/admin/*", "DELETE /admin/all"));
1333
1334 assert!(pattern_matches("GET /USERS", "get /users"));
1336 }
1337
1338 #[test]
1339 fn test_global_blocklist() {
1340 let blocklist = GlobalBlocklist {
1341 operations: ["factoryReset".to_string()].into_iter().collect(),
1342 patterns: ["* /admin/*".to_string()].into_iter().collect(),
1343 categories: [OperationCategory::Internal].into_iter().collect(),
1344 risk_levels: [OperationRiskLevel::Critical].into_iter().collect(),
1345 };
1346
1347 let op = Operation {
1349 id: "factoryReset".to_string(),
1350 name: "Factory Reset".to_string(),
1351 description: None,
1352 category: OperationCategory::Admin,
1353 is_read_only: false,
1354 risk_level: OperationRiskLevel::Critical,
1355 tags: vec![],
1356 details: OperationDetails::Unknown,
1357 };
1358 assert!(blocklist.is_blocked(&op).is_some());
1359
1360 let op = Operation {
1362 id: "listAdminUsers".to_string(),
1363 name: "List Admin Users".to_string(),
1364 description: None,
1365 category: OperationCategory::Read,
1366 is_read_only: true,
1367 risk_level: OperationRiskLevel::Safe,
1368 tags: vec![],
1369 details: OperationDetails::OpenAPI {
1370 method: "GET".to_string(),
1371 path: "/admin/users".to_string(),
1372 parameters: vec![],
1373 has_request_body: false,
1374 },
1375 };
1376 assert!(blocklist.is_blocked(&op).is_some());
1377
1378 let op = Operation {
1380 id: "internalSync".to_string(),
1381 name: "Internal Sync".to_string(),
1382 description: None,
1383 category: OperationCategory::Internal,
1384 is_read_only: false,
1385 risk_level: OperationRiskLevel::Low,
1386 tags: vec![],
1387 details: OperationDetails::Unknown,
1388 };
1389 assert!(blocklist.is_blocked(&op).is_some());
1390
1391 let op = Operation {
1393 id: "listUsers".to_string(),
1394 name: "List Users".to_string(),
1395 description: None,
1396 category: OperationCategory::Read,
1397 is_read_only: true,
1398 risk_level: OperationRiskLevel::Safe,
1399 tags: vec![],
1400 details: OperationDetails::OpenAPI {
1401 method: "GET".to_string(),
1402 path: "/users".to_string(),
1403 parameters: vec![],
1404 has_request_body: false,
1405 },
1406 };
1407 assert!(blocklist.is_blocked(&op).is_none());
1408 }
1409
1410 #[test]
1411 fn test_exposure_modes() {
1412 let policy = ToolExposurePolicy {
1414 mode: ExposureMode::AllowAll,
1415 blocklist: ["blocked".to_string()].into_iter().collect(),
1416 ..Default::default()
1417 };
1418
1419 let allowed_op = Operation::new("allowed", "Allowed", OperationCategory::Read);
1420 let blocked_op = Operation::new("blocked", "Blocked", OperationCategory::Read);
1421
1422 assert!(policy.is_allowed(&allowed_op).is_none());
1423 assert!(policy.is_allowed(&blocked_op).is_some());
1424
1425 let policy = ToolExposurePolicy {
1427 mode: ExposureMode::Allowlist,
1428 allowlist: ["allowed".to_string()].into_iter().collect(),
1429 ..Default::default()
1430 };
1431
1432 assert!(policy.is_allowed(&allowed_op).is_none());
1433 assert!(policy.is_allowed(&blocked_op).is_some());
1434
1435 let policy = ToolExposurePolicy {
1437 mode: ExposureMode::DenyAll,
1438 ..Default::default()
1439 };
1440
1441 assert!(policy.is_allowed(&allowed_op).is_some());
1442 }
1443
1444 #[test]
1445 fn test_schema_deriver() {
1446 let operations = vec![
1447 Operation::new("listUsers", "List Users", OperationCategory::Read),
1448 Operation::new("createUser", "Create User", OperationCategory::Create),
1449 Operation::new("deleteUser", "Delete User", OperationCategory::Delete),
1450 Operation::new("factoryReset", "Factory Reset", OperationCategory::Admin),
1451 ];
1452
1453 let policy = McpExposurePolicy {
1454 global_blocklist: GlobalBlocklist {
1455 operations: ["factoryReset".to_string()].into_iter().collect(),
1456 ..Default::default()
1457 },
1458 tools: ToolExposurePolicy {
1459 mode: ExposureMode::AllowAll,
1460 ..Default::default()
1461 },
1462 code_mode: CodeModeExposurePolicy {
1463 reads: MethodExposurePolicy {
1464 mode: ExposureMode::AllowAll,
1465 ..Default::default()
1466 },
1467 writes: MethodExposurePolicy {
1468 mode: ExposureMode::Allowlist,
1469 allowlist: ["createUser".to_string()].into_iter().collect(),
1470 ..Default::default()
1471 },
1472 deletes: MethodExposurePolicy {
1473 mode: ExposureMode::DenyAll,
1474 ..Default::default()
1475 },
1476 ..Default::default()
1477 },
1478 };
1479
1480 let deriver = SchemaDeriver::new(operations, policy, "test-hash".to_string());
1481
1482 let tools = deriver.derive_tools_schema();
1484 assert_eq!(tools.operations.len(), 3);
1485 assert!(tools.contains("listUsers"));
1486 assert!(tools.contains("createUser"));
1487 assert!(tools.contains("deleteUser"));
1488 assert!(!tools.contains("factoryReset"));
1489
1490 let code_mode = deriver.derive_code_mode_schema();
1496 assert_eq!(code_mode.operations.len(), 2);
1497 assert!(code_mode.contains("listUsers"));
1498 assert!(code_mode.contains("createUser"));
1499 assert!(!code_mode.contains("deleteUser"));
1500 assert!(!code_mode.contains("factoryReset"));
1501 }
1502}