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, Default, 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    #[default]
118    Medium,
119    /// Deletes data, difficult to reverse
120    High,
121    /// System-wide impact, irreversible
122    Critical,
123}
124
125/// Normalized operation model that works across all schema formats.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct Operation {
128    /// Unique identifier for this operation.
129    /// - OpenAPI: operationId or "{method} {path}"
130    /// - GraphQL: "{Type}.{field}" (e.g., "Query.users", "Mutation.createUser")
131    /// - SQL: "{action}_{table}" (e.g., "select_users", "insert_orders")
132    pub id: String,
133
134    /// Human-readable name.
135    pub name: String,
136
137    /// Description of what the operation does.
138    #[serde(default)]
139    pub description: Option<String>,
140
141    /// Operation category.
142    pub category: OperationCategory,
143
144    /// Whether this is a read-only operation.
145    pub is_read_only: bool,
146
147    /// Risk level for UI hints.
148    #[serde(default)]
149    pub risk_level: OperationRiskLevel,
150
151    /// Tags/categories for grouping.
152    #[serde(default)]
153    pub tags: Vec<String>,
154
155    /// Format-specific details.
156    pub details: OperationDetails,
157}
158
159impl Operation {
160    /// Create a new operation with minimal required fields.
161    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    /// Check if this operation matches a pattern.
186    /// Patterns support glob-style wildcards: * (any characters)
187    pub fn matches_pattern(&self, pattern: &str) -> bool {
188        match &self.details {
189            OperationDetails::OpenAPI { method, path, .. } => {
190                // Pattern format: "METHOD /path/*" or "* /path/*"
191                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                // Pattern format: "Type.field*" or "*.field"
200                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                // Pattern format: "action table" or "* table"
209                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/// Format-specific operation details.
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(tag = "format", rename_all = "lowercase")]
221pub enum OperationDetails {
222    /// OpenAPI operation details.
223    #[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    /// GraphQL operation details.
234    #[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    /// SQL operation details.
245    #[serde(rename = "sql")]
246    Sql {
247        statement_type: SqlStatementType,
248        table: String,
249        #[serde(default)]
250        columns: Vec<String>,
251    },
252
253    /// Unknown/generic operation.
254    Unknown,
255}
256
257/// SQL statement type.
258#[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/// Operation parameter.
268#[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// ============================================================================
279// LAYER 2: EXPOSURE POLICIES
280// ============================================================================
281
282/// Complete exposure policy configuration.
283#[derive(Debug, Clone, Default, Serialize, Deserialize)]
284pub struct McpExposurePolicy {
285    /// Operations NEVER exposed via MCP (highest priority).
286    #[serde(default)]
287    pub global_blocklist: GlobalBlocklist,
288
289    /// Policy for MCP tool exposure.
290    #[serde(default)]
291    pub tools: ToolExposurePolicy,
292
293    /// Policy for Code Mode exposure.
294    #[serde(default)]
295    pub code_mode: CodeModeExposurePolicy,
296}
297
298/// Global blocklist - these operations are never exposed.
299#[derive(Debug, Clone, Default, Serialize, Deserialize)]
300pub struct GlobalBlocklist {
301    /// Blocked operation IDs (exact match).
302    #[serde(default)]
303    pub operations: HashSet<String>,
304
305    /// Blocked patterns (glob matching).
306    /// - OpenAPI: "METHOD /path/*" or "* /path/*"
307    /// - GraphQL: "Type.field*" or "*.field"
308    /// - SQL: "action table" or "* table"
309    #[serde(default)]
310    pub patterns: HashSet<String>,
311
312    /// Blocked categories.
313    #[serde(default)]
314    pub categories: HashSet<OperationCategory>,
315
316    /// Blocked risk levels.
317    #[serde(default)]
318    pub risk_levels: HashSet<OperationRiskLevel>,
319}
320
321impl GlobalBlocklist {
322    /// Check if an operation is blocked by this blocklist.
323    pub fn is_blocked(&self, operation: &Operation) -> Option<FilterReason> {
324        // Check exact operation ID match
325        if self.operations.contains(&operation.id) {
326            return Some(FilterReason::GlobalBlocklistOperation {
327                operation_id: operation.id.clone(),
328            });
329        }
330
331        // Check pattern matches
332        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        // Check category
341        if self.categories.contains(&operation.category) {
342            return Some(FilterReason::GlobalBlocklistCategory {
343                category: operation.category,
344            });
345        }
346
347        // Check risk level
348        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/// Tool exposure policy.
359#[derive(Debug, Clone, Default, Serialize, Deserialize)]
360pub struct ToolExposurePolicy {
361    /// Exposure mode.
362    #[serde(default)]
363    pub mode: ExposureMode,
364
365    /// Operations to include (for allowlist mode).
366    #[serde(default)]
367    pub allowlist: HashSet<String>,
368
369    /// Operations to exclude (for blocklist mode).
370    #[serde(default)]
371    pub blocklist: HashSet<String>,
372
373    /// Per-operation customization.
374    #[serde(default)]
375    pub overrides: HashMap<String, ToolOverride>,
376}
377
378impl ToolExposurePolicy {
379    /// Check if an operation is allowed by this policy.
380    pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
381        // Check blocklist first (always applied)
382        if self.blocklist.contains(&operation.id) {
383            return Some(FilterReason::ToolBlocklist);
384        }
385
386        // Check patterns in blocklist
387        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                // Check if in allowlist
400                if self.allowlist.contains(&operation.id) {
401                    return None;
402                }
403                // Check patterns in allowlist
404                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, // Already checked blocklist above
412        }
413    }
414}
415
416/// Code Mode exposure policy.
417#[derive(Debug, Clone, Default, Serialize, Deserialize)]
418pub struct CodeModeExposurePolicy {
419    /// Policy for read operations.
420    #[serde(default)]
421    pub reads: MethodExposurePolicy,
422
423    /// Policy for write operations (create/update).
424    #[serde(default)]
425    pub writes: MethodExposurePolicy,
426
427    /// Policy for delete operations.
428    #[serde(default)]
429    pub deletes: MethodExposurePolicy,
430
431    /// Additional blocklist (applies on top of method policies).
432    #[serde(default)]
433    pub blocklist: HashSet<String>,
434}
435
436impl CodeModeExposurePolicy {
437    /// Check if an operation is allowed by this policy.
438    pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
439        // Check additional blocklist first
440        if self.blocklist.contains(&operation.id) {
441            return Some(FilterReason::CodeModeBlocklist);
442        }
443
444        // Check patterns in blocklist
445        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        // Get the appropriate method policy
454        let method_policy = self.get_method_policy(operation);
455        method_policy.is_allowed(operation)
456    }
457
458    /// Get the method policy for an operation based on its category.
459    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/// Per-method-type exposure policy.
470#[derive(Debug, Clone, Default, Serialize, Deserialize)]
471pub struct MethodExposurePolicy {
472    /// Exposure mode.
473    #[serde(default)]
474    pub mode: ExposureMode,
475
476    /// Operations to include (for allowlist mode).
477    /// Can be operation IDs or patterns.
478    #[serde(default)]
479    pub allowlist: HashSet<String>,
480
481    /// Operations to exclude (for blocklist mode).
482    #[serde(default)]
483    pub blocklist: HashSet<String>,
484}
485
486impl MethodExposurePolicy {
487    /// Check if an operation is allowed by this policy.
488    pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
489        // Check blocklist first
490        if self.blocklist.contains(&operation.id) {
491            return Some(FilterReason::MethodBlocklist {
492                method_type: Self::method_type_name(operation),
493            });
494        }
495
496        // Check patterns in blocklist
497        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                // Check if in allowlist
513                if self.allowlist.contains(&operation.id) {
514                    return None;
515                }
516                // Check patterns in allowlist
517                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, // Already checked blocklist above
527        }
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/// Exposure mode determines how allowlist/blocklist are interpreted.
540#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(rename_all = "snake_case")]
542pub enum ExposureMode {
543    /// All operations exposed except those in blocklist.
544    #[default]
545    AllowAll,
546    /// No operations exposed (allowlist/blocklist ignored).
547    DenyAll,
548    /// Only operations in allowlist are exposed (blocklist still applies).
549    Allowlist,
550    /// All operations exposed except those in blocklist (same as AllowAll).
551    Blocklist,
552}
553
554/// Per-operation customization for tools.
555#[derive(Debug, Clone, Default, Serialize, Deserialize)]
556pub struct ToolOverride {
557    /// Custom tool name (instead of operation ID).
558    #[serde(default)]
559    pub name: Option<String>,
560
561    /// Custom description.
562    #[serde(default)]
563    pub description: Option<String>,
564
565    /// Mark as dangerous (requires confirmation in Claude).
566    #[serde(default)]
567    pub dangerous: bool,
568
569    /// Hide from tool list but still callable.
570    #[serde(default)]
571    pub hidden: bool,
572}
573
574// ============================================================================
575// LAYER 3: DERIVED SCHEMAS
576// ============================================================================
577
578/// Derived schema for a specific exposure context.
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct DerivedSchema {
581    /// Operations included in this derived view.
582    pub operations: Vec<Operation>,
583
584    /// Human-readable documentation (for MCP resources).
585    pub documentation: String,
586
587    /// Derivation metadata (for audit).
588    pub metadata: DerivationMetadata,
589}
590
591impl DerivedSchema {
592    /// Get an operation by ID.
593    pub fn get_operation(&self, id: &str) -> Option<&Operation> {
594        self.operations.iter().find(|op| op.id == id)
595    }
596
597    /// Check if an operation is included.
598    pub fn contains(&self, id: &str) -> bool {
599        self.operations.iter().any(|op| op.id == id)
600    }
601
602    /// Get all operation IDs.
603    pub fn operation_ids(&self) -> HashSet<String> {
604        self.operations.iter().map(|op| op.id.clone()).collect()
605    }
606}
607
608/// Metadata about schema derivation.
609#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct DerivationMetadata {
611    /// Context (tools or code_mode).
612    pub context: String,
613
614    /// When the schema was derived (Unix timestamp).
615    pub derived_at: i64,
616
617    /// Source schema hash (for cache invalidation).
618    pub source_hash: String,
619
620    /// Policy hash (for cache invalidation).
621    pub policy_hash: String,
622
623    /// Combined hash for caching.
624    pub cache_key: String,
625
626    /// What was filtered and why.
627    pub filtered: Vec<FilteredOperation>,
628
629    /// Statistics.
630    pub stats: DerivationStats,
631}
632
633/// An operation that was filtered during derivation.
634#[derive(Debug, Clone, Serialize, Deserialize)]
635pub struct FilteredOperation {
636    /// Operation that was filtered.
637    pub operation_id: String,
638
639    /// Operation name for display.
640    pub operation_name: String,
641
642    /// Why it was filtered.
643    pub reason: FilterReason,
644
645    /// Which policy caused the filter.
646    pub policy: String,
647}
648
649/// Why an operation was filtered.
650#[derive(Debug, Clone, Serialize, Deserialize)]
651#[serde(tag = "type", rename_all = "snake_case")]
652pub enum FilterReason {
653    /// Blocked by global blocklist (exact operation ID match).
654    GlobalBlocklistOperation { operation_id: String },
655
656    /// Blocked by global blocklist (pattern match).
657    GlobalBlocklistPattern { pattern: String },
658
659    /// Blocked by global blocklist (category).
660    GlobalBlocklistCategory { category: OperationCategory },
661
662    /// Blocked by global blocklist (risk level).
663    GlobalBlocklistRiskLevel { level: OperationRiskLevel },
664
665    /// Blocked by tool blocklist.
666    ToolBlocklist,
667
668    /// Blocked by tool blocklist pattern.
669    ToolBlocklistPattern { pattern: String },
670
671    /// Not in tool allowlist.
672    ToolNotInAllowlist,
673
674    /// Tool policy is deny_all.
675    ToolDenyAllMode,
676
677    /// Blocked by code mode blocklist.
678    CodeModeBlocklist,
679
680    /// Blocked by code mode blocklist pattern.
681    CodeModeBlocklistPattern { pattern: String },
682
683    /// Blocked by method blocklist.
684    MethodBlocklist { method_type: String },
685
686    /// Blocked by method blocklist pattern.
687    MethodBlocklistPattern {
688        method_type: String,
689        pattern: String,
690    },
691
692    /// Not in method allowlist.
693    MethodNotInAllowlist { method_type: String },
694
695    /// Method policy is deny_all.
696    MethodDenyAllMode { method_type: String },
697}
698
699impl FilterReason {
700    /// Get a human-readable description of the filter reason.
701    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/// Statistics about schema derivation.
749#[derive(Debug, Clone, Default, Serialize, Deserialize)]
750pub struct DerivationStats {
751    /// Total operations in source.
752    pub source_total: usize,
753
754    /// Operations in derived schema.
755    pub derived_total: usize,
756
757    /// Operations filtered.
758    pub filtered_total: usize,
759
760    /// Breakdown by filter reason type.
761    pub filtered_by_reason: HashMap<String, usize>,
762}
763
764// ============================================================================
765// PATTERN MATCHING
766// ============================================================================
767
768/// Check if a pattern matches a string using glob-style wildcards.
769/// Supports: * (match any characters)
770pub fn pattern_matches(pattern: &str, text: &str) -> bool {
771    let pattern = pattern.to_lowercase();
772    let text = text.to_lowercase();
773
774    // Simple glob matching with * wildcard
775    let parts: Vec<&str> = pattern.split('*').collect();
776
777    if parts.len() == 1 {
778        // No wildcards, exact match
779        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            // First part must match at the start
790            if !text.starts_with(part) {
791                return false;
792            }
793            pos = part.len();
794        } else if i == parts.len() - 1 {
795            // Last part must match at the end
796            if !text[pos..].ends_with(part) {
797                return false;
798            }
799        } else {
800            // Middle parts can match anywhere after current position
801            match text[pos..].find(part) {
802                Some(found) => pos += found + part.len(),
803                None => return false,
804            }
805        }
806    }
807
808    true
809}
810
811// ============================================================================
812// SCHEMA DERIVER
813// ============================================================================
814
815/// Derives schemas from source + policy.
816pub struct SchemaDeriver {
817    /// Source operations.
818    operations: Vec<Operation>,
819
820    /// Exposure policy.
821    policy: McpExposurePolicy,
822
823    /// Source schema hash.
824    source_hash: String,
825
826    /// Policy hash.
827    policy_hash: String,
828}
829
830impl SchemaDeriver {
831    /// Create a new schema deriver.
832    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    /// Derive the MCP Tools schema.
843    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            // Step 1: Check global blocklist (highest priority)
849            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            // Step 2: Check tool exposure policy
860            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            // Step 3: Apply overrides and include
871            let op = self.apply_tool_overrides(op);
872            included.push(op);
873        }
874
875        self.build_derived_schema(included, filtered, "tools")
876    }
877
878    /// Derive the Code Mode schema.
879    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            // Step 1: Check global blocklist
885            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            // Step 2: Check code mode policy
896            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    /// Check if an operation is allowed in tools.
918    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    /// Check if an operation is allowed in code mode.
930    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    /// Get the filter reason for an operation in tools context.
942    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    /// Get the filter reason for an operation in code mode context.
955    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    /// Find operation ID by HTTP method and path pattern.
968    ///
969    /// This enables looking up human-readable operationIds (like "updateProduct")
970    /// from METHOD:/path patterns (like "PUT:/products/*").
971    ///
972    /// # Arguments
973    /// * `method` - HTTP method (e.g., "PUT", "POST")
974    /// * `path_pattern` - Path pattern with wildcards (e.g., "/products/*")
975    ///
976    /// # Returns
977    /// The operationId if a matching operation is found.
978    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    /// Get all operations in a format suitable for display to administrators.
1001    ///
1002    /// Returns tuples of (operationId, METHOD:/path, description).
1003    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    /// Normalize a path for matching by replacing parameter placeholders with *.
1019    fn normalize_path_for_matching(path: &str) -> String {
1020        path.split('/')
1021            .map(|segment| {
1022                if segment.starts_with('{') && segment.ends_with('}') {
1023                    "*" // {id} -> *
1024                } else if segment.starts_with(':') {
1025                    "*" // :id -> *
1026                } else if segment == "*" {
1027                    "*"
1028                } else {
1029                    segment
1030                }
1031            })
1032            .collect::<Vec<_>>()
1033            .join("/")
1034    }
1035
1036    /// Check if two normalized paths match.
1037    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; // Wildcard matches anything
1048            }
1049            if p != s {
1050                return false;
1051            }
1052        }
1053        true
1054    }
1055
1056    /// Apply tool overrides to an operation.
1057    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    /// Build a derived schema from included and filtered operations.
1076    fn build_derived_schema(
1077        &self,
1078        operations: Vec<Operation>,
1079        filtered: Vec<FilteredOperation>,
1080        context: &str,
1081    ) -> DerivedSchema {
1082        // Build statistics
1083        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        // Generate documentation
1114        let documentation = self.generate_documentation(&operations, context);
1115
1116        // Compute cache key
1117        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    /// Generate human-readable documentation for a derived schema.
1140    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        // Group by category
1156        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        // Read operations
1175        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        // Write operations
1188        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        // Delete operations
1201        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    /// Document a single operation.
1217    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    /// Compute a hash of the policy for caching.
1261    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        // Hash global blocklist
1268        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        // Hash tool policy
1281        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        // Hash code mode policy
1289        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// ============================================================================
1298// TESTS
1299// ============================================================================
1300
1301#[cfg(test)]
1302mod tests {
1303    use super::*;
1304
1305    #[test]
1306    fn test_pattern_matching() {
1307        // Exact match
1308        assert!(pattern_matches("GET /users", "GET /users"));
1309        assert!(!pattern_matches("GET /users", "POST /users"));
1310
1311        // Wildcard at end
1312        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        // Wildcard at start
1317        assert!(pattern_matches("* /admin/*", "GET /admin/users"));
1318        assert!(pattern_matches("* /admin/*", "DELETE /admin/config"));
1319
1320        // Wildcard in middle
1321        assert!(pattern_matches(
1322            "GET /users/*/posts",
1323            "GET /users/123/posts"
1324        ));
1325
1326        // Multiple wildcards
1327        assert!(pattern_matches("*/admin/*", "DELETE /admin/all"));
1328
1329        // Case insensitive
1330        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        // Blocked by operation ID
1343        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        // Blocked by pattern
1356        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        // Blocked by category
1374        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        // Not blocked
1387        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        // Test AllowAll mode
1408        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        // Test Allowlist mode
1421        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        // Test DenyAll mode
1431        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        // Tools schema should have 3 operations (factoryReset blocked globally)
1478        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        // Code mode schema should have 2 operations
1486        // - listUsers (read, allowed)
1487        // - createUser (write, in allowlist)
1488        // - deleteUser (delete, deny_all)
1489        // - factoryReset (blocked globally)
1490        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}