Skip to main content

pmcp_code_mode/
schema_exposure.rs

1//! Schema Exposure Architecture for MCP Built-in Servers.
2//!
3//! This module implements the Three-Layer Schema Model:
4//! - Layer 1: Source Schema (original API specification)
5//! - Layer 2: Exposure Policies (what gets exposed)
6//! - Layer 3: Derived Schemas (computed views)
7//!
8//! See SCHEMA_EXPOSURE_ARCHITECTURE.md for full design documentation.
9
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13// Re-export GraphQLOperationType from the existing graphql module
14pub use crate::graphql::GraphQLOperationType;
15
16// ============================================================================
17// LAYER 1: SOURCE SCHEMA
18// ============================================================================
19
20/// Schema format identifier.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum SchemaFormat {
24    /// OpenAPI 3.x REST APIs
25    OpenAPI3,
26    /// GraphQL APIs
27    GraphQL,
28    /// SQL databases (future)
29    Sql,
30    /// AsyncAPI event-driven APIs (future)
31    AsyncAPI,
32}
33
34/// Where the schema came from.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type", rename_all = "lowercase")]
37pub enum SchemaSource {
38    /// Schema embedded in config file
39    Embedded { path: String },
40    /// Schema fetched from remote URL
41    Remote {
42        url: String,
43        #[serde(default)]
44        refresh_interval_seconds: Option<u64>,
45    },
46    /// Schema discovered via introspection
47    Introspection { endpoint: String },
48}
49
50/// Metadata about the source schema.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SchemaMetadata {
53    /// Where the schema came from
54    pub source: SchemaSource,
55
56    /// When it was last fetched/updated (Unix timestamp)
57    #[serde(default)]
58    pub last_updated: Option<i64>,
59
60    /// Content hash for change detection (SHA-256)
61    #[serde(default)]
62    pub content_hash: Option<String>,
63
64    /// Schema version (if available from spec)
65    #[serde(default)]
66    pub version: Option<String>,
67
68    /// Title from the spec
69    #[serde(default)]
70    pub title: Option<String>,
71}
72
73/// Operation category for filtering.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
75#[serde(rename_all = "lowercase")]
76pub enum OperationCategory {
77    /// Read operations (GET, Query, SELECT)
78    Read,
79    /// Create operations (POST create, Mutation create, INSERT)
80    Create,
81    /// Update operations (PUT/PATCH, Mutation update, UPDATE)
82    Update,
83    /// Delete operations (DELETE, Mutation delete, DELETE)
84    Delete,
85    /// Administrative operations
86    Admin,
87    /// Internal/debug operations
88    Internal,
89}
90
91impl OperationCategory {
92    /// Returns true if this is a read-only category.
93    pub fn is_read_only(&self) -> bool {
94        matches!(self, OperationCategory::Read)
95    }
96
97    /// Returns true if this is a write category (create or update).
98    pub fn is_write(&self) -> bool {
99        matches!(self, OperationCategory::Create | OperationCategory::Update)
100    }
101
102    /// Returns true if this is a delete category.
103    pub fn is_delete(&self) -> bool {
104        matches!(self, OperationCategory::Delete)
105    }
106}
107
108/// Risk level for an operation.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
110#[serde(rename_all = "lowercase")]
111pub enum OperationRiskLevel {
112    /// Read-only, no side effects
113    Safe,
114    /// Creates data, generally reversible
115    Low,
116    /// Modifies data, potentially reversible
117    Medium,
118    /// Deletes data, difficult to reverse
119    High,
120    /// System-wide impact, irreversible
121    Critical,
122}
123
124impl Default for OperationRiskLevel {
125    fn default() -> Self {
126        Self::Medium
127    }
128}
129
130/// Normalized operation model that works across all schema formats.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct Operation {
133    /// Unique identifier for this operation.
134    /// - OpenAPI: operationId or "{method} {path}"
135    /// - GraphQL: "{Type}.{field}" (e.g., "Query.users", "Mutation.createUser")
136    /// - SQL: "{action}_{table}" (e.g., "select_users", "insert_orders")
137    pub id: String,
138
139    /// Human-readable name.
140    pub name: String,
141
142    /// Description of what the operation does.
143    #[serde(default)]
144    pub description: Option<String>,
145
146    /// Operation category.
147    pub category: OperationCategory,
148
149    /// Whether this is a read-only operation.
150    pub is_read_only: bool,
151
152    /// Risk level for UI hints.
153    #[serde(default)]
154    pub risk_level: OperationRiskLevel,
155
156    /// Tags/categories for grouping.
157    #[serde(default)]
158    pub tags: Vec<String>,
159
160    /// Format-specific details.
161    pub details: OperationDetails,
162}
163
164impl Operation {
165    /// Create a new operation with minimal required fields.
166    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    /// Check if this operation matches a pattern.
191    /// Patterns support glob-style wildcards: * (any characters)
192    pub fn matches_pattern(&self, pattern: &str) -> bool {
193        match &self.details {
194            OperationDetails::OpenAPI { method, path, .. } => {
195                // Pattern format: "METHOD /path/*" or "* /path/*"
196                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                // Pattern format: "Type.field*" or "*.field"
205                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                // Pattern format: "action table" or "* table"
214                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/// Format-specific operation details.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(tag = "format", rename_all = "lowercase")]
226pub enum OperationDetails {
227    /// OpenAPI operation details.
228    #[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    /// GraphQL operation details.
239    #[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    /// SQL operation details.
250    #[serde(rename = "sql")]
251    Sql {
252        statement_type: SqlStatementType,
253        table: String,
254        #[serde(default)]
255        columns: Vec<String>,
256    },
257
258    /// Unknown/generic operation.
259    Unknown,
260}
261
262/// SQL statement type.
263#[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/// Operation parameter.
273#[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// ============================================================================
284// LAYER 2: EXPOSURE POLICIES
285// ============================================================================
286
287/// Complete exposure policy configuration.
288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
289pub struct McpExposurePolicy {
290    /// Operations NEVER exposed via MCP (highest priority).
291    #[serde(default)]
292    pub global_blocklist: GlobalBlocklist,
293
294    /// Policy for MCP tool exposure.
295    #[serde(default)]
296    pub tools: ToolExposurePolicy,
297
298    /// Policy for Code Mode exposure.
299    #[serde(default)]
300    pub code_mode: CodeModeExposurePolicy,
301}
302
303/// Global blocklist - these operations are never exposed.
304#[derive(Debug, Clone, Default, Serialize, Deserialize)]
305pub struct GlobalBlocklist {
306    /// Blocked operation IDs (exact match).
307    #[serde(default)]
308    pub operations: HashSet<String>,
309
310    /// Blocked patterns (glob matching).
311    /// - OpenAPI: "METHOD /path/*" or "* /path/*"
312    /// - GraphQL: "Type.field*" or "*.field"
313    /// - SQL: "action table" or "* table"
314    #[serde(default)]
315    pub patterns: HashSet<String>,
316
317    /// Blocked categories.
318    #[serde(default)]
319    pub categories: HashSet<OperationCategory>,
320
321    /// Blocked risk levels.
322    #[serde(default)]
323    pub risk_levels: HashSet<OperationRiskLevel>,
324}
325
326impl GlobalBlocklist {
327    /// Check if an operation is blocked by this blocklist.
328    pub fn is_blocked(&self, operation: &Operation) -> Option<FilterReason> {
329        // Check exact operation ID match
330        if self.operations.contains(&operation.id) {
331            return Some(FilterReason::GlobalBlocklistOperation {
332                operation_id: operation.id.clone(),
333            });
334        }
335
336        // Check pattern matches
337        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        // Check category
346        if self.categories.contains(&operation.category) {
347            return Some(FilterReason::GlobalBlocklistCategory {
348                category: operation.category,
349            });
350        }
351
352        // Check risk level
353        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/// Tool exposure policy.
364#[derive(Debug, Clone, Default, Serialize, Deserialize)]
365pub struct ToolExposurePolicy {
366    /// Exposure mode.
367    #[serde(default)]
368    pub mode: ExposureMode,
369
370    /// Operations to include (for allowlist mode).
371    #[serde(default)]
372    pub allowlist: HashSet<String>,
373
374    /// Operations to exclude (for blocklist mode).
375    #[serde(default)]
376    pub blocklist: HashSet<String>,
377
378    /// Per-operation customization.
379    #[serde(default)]
380    pub overrides: HashMap<String, ToolOverride>,
381}
382
383impl ToolExposurePolicy {
384    /// Check if an operation is allowed by this policy.
385    pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
386        // Check blocklist first (always applied)
387        if self.blocklist.contains(&operation.id) {
388            return Some(FilterReason::ToolBlocklist);
389        }
390
391        // Check patterns in blocklist
392        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                // Check if in allowlist
405                if self.allowlist.contains(&operation.id) {
406                    return None;
407                }
408                // Check patterns in allowlist
409                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, // Already checked blocklist above
417        }
418    }
419}
420
421/// Code Mode exposure policy.
422#[derive(Debug, Clone, Default, Serialize, Deserialize)]
423pub struct CodeModeExposurePolicy {
424    /// Policy for read operations.
425    #[serde(default)]
426    pub reads: MethodExposurePolicy,
427
428    /// Policy for write operations (create/update).
429    #[serde(default)]
430    pub writes: MethodExposurePolicy,
431
432    /// Policy for delete operations.
433    #[serde(default)]
434    pub deletes: MethodExposurePolicy,
435
436    /// Additional blocklist (applies on top of method policies).
437    #[serde(default)]
438    pub blocklist: HashSet<String>,
439}
440
441impl CodeModeExposurePolicy {
442    /// Check if an operation is allowed by this policy.
443    pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
444        // Check additional blocklist first
445        if self.blocklist.contains(&operation.id) {
446            return Some(FilterReason::CodeModeBlocklist);
447        }
448
449        // Check patterns in blocklist
450        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        // Get the appropriate method policy
459        let method_policy = self.get_method_policy(operation);
460        method_policy.is_allowed(operation)
461    }
462
463    /// Get the method policy for an operation based on its category.
464    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/// Per-method-type exposure policy.
475#[derive(Debug, Clone, Default, Serialize, Deserialize)]
476pub struct MethodExposurePolicy {
477    /// Exposure mode.
478    #[serde(default)]
479    pub mode: ExposureMode,
480
481    /// Operations to include (for allowlist mode).
482    /// Can be operation IDs or patterns.
483    #[serde(default)]
484    pub allowlist: HashSet<String>,
485
486    /// Operations to exclude (for blocklist mode).
487    #[serde(default)]
488    pub blocklist: HashSet<String>,
489}
490
491impl MethodExposurePolicy {
492    /// Check if an operation is allowed by this policy.
493    pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
494        // Check blocklist first
495        if self.blocklist.contains(&operation.id) {
496            return Some(FilterReason::MethodBlocklist {
497                method_type: Self::method_type_name(operation),
498            });
499        }
500
501        // Check patterns in blocklist
502        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                // Check if in allowlist
518                if self.allowlist.contains(&operation.id) {
519                    return None;
520                }
521                // Check patterns in allowlist
522                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, // Already checked blocklist above
532        }
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/// Exposure mode determines how allowlist/blocklist are interpreted.
545#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(rename_all = "snake_case")]
547pub enum ExposureMode {
548    /// All operations exposed except those in blocklist.
549    #[default]
550    AllowAll,
551    /// No operations exposed (allowlist/blocklist ignored).
552    DenyAll,
553    /// Only operations in allowlist are exposed (blocklist still applies).
554    Allowlist,
555    /// All operations exposed except those in blocklist (same as AllowAll).
556    Blocklist,
557}
558
559/// Per-operation customization for tools.
560#[derive(Debug, Clone, Default, Serialize, Deserialize)]
561pub struct ToolOverride {
562    /// Custom tool name (instead of operation ID).
563    #[serde(default)]
564    pub name: Option<String>,
565
566    /// Custom description.
567    #[serde(default)]
568    pub description: Option<String>,
569
570    /// Mark as dangerous (requires confirmation in Claude).
571    #[serde(default)]
572    pub dangerous: bool,
573
574    /// Hide from tool list but still callable.
575    #[serde(default)]
576    pub hidden: bool,
577}
578
579// ============================================================================
580// LAYER 3: DERIVED SCHEMAS
581// ============================================================================
582
583/// Derived schema for a specific exposure context.
584#[derive(Debug, Clone, Serialize, Deserialize)]
585pub struct DerivedSchema {
586    /// Operations included in this derived view.
587    pub operations: Vec<Operation>,
588
589    /// Human-readable documentation (for MCP resources).
590    pub documentation: String,
591
592    /// Derivation metadata (for audit).
593    pub metadata: DerivationMetadata,
594}
595
596impl DerivedSchema {
597    /// Get an operation by ID.
598    pub fn get_operation(&self, id: &str) -> Option<&Operation> {
599        self.operations.iter().find(|op| op.id == id)
600    }
601
602    /// Check if an operation is included.
603    pub fn contains(&self, id: &str) -> bool {
604        self.operations.iter().any(|op| op.id == id)
605    }
606
607    /// Get all operation IDs.
608    pub fn operation_ids(&self) -> HashSet<String> {
609        self.operations.iter().map(|op| op.id.clone()).collect()
610    }
611}
612
613/// Metadata about schema derivation.
614#[derive(Debug, Clone, Serialize, Deserialize)]
615pub struct DerivationMetadata {
616    /// Context (tools or code_mode).
617    pub context: String,
618
619    /// When the schema was derived (Unix timestamp).
620    pub derived_at: i64,
621
622    /// Source schema hash (for cache invalidation).
623    pub source_hash: String,
624
625    /// Policy hash (for cache invalidation).
626    pub policy_hash: String,
627
628    /// Combined hash for caching.
629    pub cache_key: String,
630
631    /// What was filtered and why.
632    pub filtered: Vec<FilteredOperation>,
633
634    /// Statistics.
635    pub stats: DerivationStats,
636}
637
638/// An operation that was filtered during derivation.
639#[derive(Debug, Clone, Serialize, Deserialize)]
640pub struct FilteredOperation {
641    /// Operation that was filtered.
642    pub operation_id: String,
643
644    /// Operation name for display.
645    pub operation_name: String,
646
647    /// Why it was filtered.
648    pub reason: FilterReason,
649
650    /// Which policy caused the filter.
651    pub policy: String,
652}
653
654/// Why an operation was filtered.
655#[derive(Debug, Clone, Serialize, Deserialize)]
656#[serde(tag = "type", rename_all = "snake_case")]
657pub enum FilterReason {
658    /// Blocked by global blocklist (exact operation ID match).
659    GlobalBlocklistOperation { operation_id: String },
660
661    /// Blocked by global blocklist (pattern match).
662    GlobalBlocklistPattern { pattern: String },
663
664    /// Blocked by global blocklist (category).
665    GlobalBlocklistCategory { category: OperationCategory },
666
667    /// Blocked by global blocklist (risk level).
668    GlobalBlocklistRiskLevel { level: OperationRiskLevel },
669
670    /// Blocked by tool blocklist.
671    ToolBlocklist,
672
673    /// Blocked by tool blocklist pattern.
674    ToolBlocklistPattern { pattern: String },
675
676    /// Not in tool allowlist.
677    ToolNotInAllowlist,
678
679    /// Tool policy is deny_all.
680    ToolDenyAllMode,
681
682    /// Blocked by code mode blocklist.
683    CodeModeBlocklist,
684
685    /// Blocked by code mode blocklist pattern.
686    CodeModeBlocklistPattern { pattern: String },
687
688    /// Blocked by method blocklist.
689    MethodBlocklist { method_type: String },
690
691    /// Blocked by method blocklist pattern.
692    MethodBlocklistPattern {
693        method_type: String,
694        pattern: String,
695    },
696
697    /// Not in method allowlist.
698    MethodNotInAllowlist { method_type: String },
699
700    /// Method policy is deny_all.
701    MethodDenyAllMode { method_type: String },
702}
703
704impl FilterReason {
705    /// Get a human-readable description of the filter reason.
706    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/// Statistics about schema derivation.
754#[derive(Debug, Clone, Default, Serialize, Deserialize)]
755pub struct DerivationStats {
756    /// Total operations in source.
757    pub source_total: usize,
758
759    /// Operations in derived schema.
760    pub derived_total: usize,
761
762    /// Operations filtered.
763    pub filtered_total: usize,
764
765    /// Breakdown by filter reason type.
766    pub filtered_by_reason: HashMap<String, usize>,
767}
768
769// ============================================================================
770// PATTERN MATCHING
771// ============================================================================
772
773/// Check if a pattern matches a string using glob-style wildcards.
774/// Supports: * (match any characters)
775pub fn pattern_matches(pattern: &str, text: &str) -> bool {
776    let pattern = pattern.to_lowercase();
777    let text = text.to_lowercase();
778
779    // Simple glob matching with * wildcard
780    let parts: Vec<&str> = pattern.split('*').collect();
781
782    if parts.len() == 1 {
783        // No wildcards, exact match
784        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            // First part must match at the start
795            if !text.starts_with(part) {
796                return false;
797            }
798            pos = part.len();
799        } else if i == parts.len() - 1 {
800            // Last part must match at the end
801            if !text[pos..].ends_with(part) {
802                return false;
803            }
804        } else {
805            // Middle parts can match anywhere after current position
806            match text[pos..].find(part) {
807                Some(found) => pos += found + part.len(),
808                None => return false,
809            }
810        }
811    }
812
813    true
814}
815
816// ============================================================================
817// SCHEMA DERIVER
818// ============================================================================
819
820/// Derives schemas from source + policy.
821pub struct SchemaDeriver {
822    /// Source operations.
823    operations: Vec<Operation>,
824
825    /// Exposure policy.
826    policy: McpExposurePolicy,
827
828    /// Source schema hash.
829    source_hash: String,
830
831    /// Policy hash.
832    policy_hash: String,
833}
834
835impl SchemaDeriver {
836    /// Create a new schema deriver.
837    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    /// Derive the MCP Tools schema.
848    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            // Step 1: Check global blocklist (highest priority)
854            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            // Step 2: Check tool exposure policy
865            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            // Step 3: Apply overrides and include
876            let op = self.apply_tool_overrides(op);
877            included.push(op);
878        }
879
880        self.build_derived_schema(included, filtered, "tools")
881    }
882
883    /// Derive the Code Mode schema.
884    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            // Step 1: Check global blocklist
890            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            // Step 2: Check code mode policy
901            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    /// Check if an operation is allowed in tools.
923    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    /// Check if an operation is allowed in code mode.
935    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    /// Get the filter reason for an operation in tools context.
947    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    /// Get the filter reason for an operation in code mode context.
960    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    /// Find operation ID by HTTP method and path pattern.
973    ///
974    /// This enables looking up human-readable operationIds (like "updateProduct")
975    /// from METHOD:/path patterns (like "PUT:/products/*").
976    ///
977    /// # Arguments
978    /// * `method` - HTTP method (e.g., "PUT", "POST")
979    /// * `path_pattern` - Path pattern with wildcards (e.g., "/products/*")
980    ///
981    /// # Returns
982    /// The operationId if a matching operation is found.
983    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    /// Get all operations in a format suitable for display to administrators.
1006    ///
1007    /// Returns tuples of (operationId, METHOD:/path, description).
1008    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    /// Normalize a path for matching by replacing parameter placeholders with *.
1024    fn normalize_path_for_matching(path: &str) -> String {
1025        path.split('/')
1026            .map(|segment| {
1027                if segment.starts_with('{') && segment.ends_with('}') {
1028                    "*" // {id} -> *
1029                } else if segment.starts_with(':') {
1030                    "*" // :id -> *
1031                } else if segment == "*" {
1032                    "*"
1033                } else {
1034                    segment
1035                }
1036            })
1037            .collect::<Vec<_>>()
1038            .join("/")
1039    }
1040
1041    /// Check if two normalized paths match.
1042    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; // Wildcard matches anything
1053            }
1054            if p != s {
1055                return false;
1056            }
1057        }
1058        true
1059    }
1060
1061    /// Apply tool overrides to an operation.
1062    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    /// Build a derived schema from included and filtered operations.
1081    fn build_derived_schema(
1082        &self,
1083        operations: Vec<Operation>,
1084        filtered: Vec<FilteredOperation>,
1085        context: &str,
1086    ) -> DerivedSchema {
1087        // Build statistics
1088        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        // Generate documentation
1119        let documentation = self.generate_documentation(&operations, context);
1120
1121        // Compute cache key
1122        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    /// Generate human-readable documentation for a derived schema.
1145    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        // Group by category
1161        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        // Read operations
1180        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        // Write operations
1193        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        // Delete operations
1206        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    /// Document a single operation.
1222    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    /// Compute a hash of the policy for caching.
1266    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        // Hash global blocklist
1273        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        // Hash tool policy
1286        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        // Hash code mode policy
1294        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// ============================================================================
1303// TESTS
1304// ============================================================================
1305
1306#[cfg(test)]
1307mod tests {
1308    use super::*;
1309
1310    #[test]
1311    fn test_pattern_matching() {
1312        // Exact match
1313        assert!(pattern_matches("GET /users", "GET /users"));
1314        assert!(!pattern_matches("GET /users", "POST /users"));
1315
1316        // Wildcard at end
1317        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        // Wildcard at start
1322        assert!(pattern_matches("* /admin/*", "GET /admin/users"));
1323        assert!(pattern_matches("* /admin/*", "DELETE /admin/config"));
1324
1325        // Wildcard in middle
1326        assert!(pattern_matches(
1327            "GET /users/*/posts",
1328            "GET /users/123/posts"
1329        ));
1330
1331        // Multiple wildcards
1332        assert!(pattern_matches("*/admin/*", "DELETE /admin/all"));
1333
1334        // Case insensitive
1335        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        // Blocked by operation ID
1348        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        // Blocked by pattern
1361        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        // Blocked by category
1379        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        // Not blocked
1392        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        // Test AllowAll mode
1413        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        // Test Allowlist mode
1426        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        // Test DenyAll mode
1436        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        // Tools schema should have 3 operations (factoryReset blocked globally)
1483        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        // Code mode schema should have 2 operations
1491        // - listUsers (read, allowed)
1492        // - createUser (write, in allowlist)
1493        // - deleteUser (delete, deny_all)
1494        // - factoryReset (blocked globally)
1495        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}