use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
pub use crate::graphql::GraphQLOperationType;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SchemaFormat {
OpenAPI3,
GraphQL,
Sql,
AsyncAPI,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum SchemaSource {
Embedded { path: String },
Remote {
url: String,
#[serde(default)]
refresh_interval_seconds: Option<u64>,
},
Introspection { endpoint: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaMetadata {
pub source: SchemaSource,
#[serde(default)]
pub last_updated: Option<i64>,
#[serde(default)]
pub content_hash: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub title: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OperationCategory {
Read,
Create,
Update,
Delete,
Admin,
Internal,
}
impl OperationCategory {
pub fn is_read_only(&self) -> bool {
matches!(self, OperationCategory::Read)
}
pub fn is_write(&self) -> bool {
matches!(self, OperationCategory::Create | OperationCategory::Update)
}
pub fn is_delete(&self) -> bool {
matches!(self, OperationCategory::Delete)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OperationRiskLevel {
Safe,
Low,
#[default]
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Operation {
pub id: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub category: OperationCategory,
pub is_read_only: bool,
#[serde(default)]
pub risk_level: OperationRiskLevel,
#[serde(default)]
pub tags: Vec<String>,
pub details: OperationDetails,
}
impl Operation {
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
category: OperationCategory,
) -> Self {
let is_read_only = category.is_read_only();
Self {
id: id.into(),
name: name.into(),
description: None,
category,
is_read_only,
risk_level: if is_read_only {
OperationRiskLevel::Safe
} else if category.is_delete() {
OperationRiskLevel::High
} else {
OperationRiskLevel::Low
},
tags: Vec::new(),
details: OperationDetails::Unknown,
}
}
pub fn matches_pattern(&self, pattern: &str) -> bool {
match &self.details {
OperationDetails::OpenAPI { method, path, .. } => {
let endpoint = format!("{} {}", method.to_uppercase(), path);
pattern_matches(pattern, &endpoint) || pattern_matches(pattern, &self.id)
},
OperationDetails::GraphQL {
operation_type,
field_name,
..
} => {
let full_name = format!("{:?}.{}", operation_type, field_name);
pattern_matches(pattern, &full_name) || pattern_matches(pattern, &self.id)
},
OperationDetails::Sql {
statement_type,
table,
..
} => {
let full_name = format!("{:?} {}", statement_type, table);
pattern_matches(pattern, &full_name.to_lowercase())
|| pattern_matches(pattern, &self.id)
},
OperationDetails::Unknown => pattern_matches(pattern, &self.id),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "format", rename_all = "lowercase")]
pub enum OperationDetails {
#[serde(rename = "openapi")]
OpenAPI {
method: String,
path: String,
#[serde(default)]
parameters: Vec<OperationParameter>,
#[serde(default)]
has_request_body: bool,
},
#[serde(rename = "graphql")]
GraphQL {
operation_type: GraphQLOperationType,
field_name: String,
#[serde(default)]
arguments: Vec<OperationParameter>,
#[serde(default)]
return_type: Option<String>,
},
#[serde(rename = "sql")]
Sql {
statement_type: SqlStatementType,
table: String,
#[serde(default)]
columns: Vec<String>,
},
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum SqlStatementType {
Select,
Insert,
Update,
Delete,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperationParameter {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub required: bool,
#[serde(default)]
pub param_type: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct McpExposurePolicy {
#[serde(default)]
pub global_blocklist: GlobalBlocklist,
#[serde(default)]
pub tools: ToolExposurePolicy,
#[serde(default)]
pub code_mode: CodeModeExposurePolicy,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GlobalBlocklist {
#[serde(default)]
pub operations: HashSet<String>,
#[serde(default)]
pub patterns: HashSet<String>,
#[serde(default)]
pub categories: HashSet<OperationCategory>,
#[serde(default)]
pub risk_levels: HashSet<OperationRiskLevel>,
}
impl GlobalBlocklist {
pub fn is_blocked(&self, operation: &Operation) -> Option<FilterReason> {
if self.operations.contains(&operation.id) {
return Some(FilterReason::GlobalBlocklistOperation {
operation_id: operation.id.clone(),
});
}
for pattern in &self.patterns {
if operation.matches_pattern(pattern) {
return Some(FilterReason::GlobalBlocklistPattern {
pattern: pattern.clone(),
});
}
}
if self.categories.contains(&operation.category) {
return Some(FilterReason::GlobalBlocklistCategory {
category: operation.category,
});
}
if self.risk_levels.contains(&operation.risk_level) {
return Some(FilterReason::GlobalBlocklistRiskLevel {
level: operation.risk_level,
});
}
None
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolExposurePolicy {
#[serde(default)]
pub mode: ExposureMode,
#[serde(default)]
pub allowlist: HashSet<String>,
#[serde(default)]
pub blocklist: HashSet<String>,
#[serde(default)]
pub overrides: HashMap<String, ToolOverride>,
}
impl ToolExposurePolicy {
pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
if self.blocklist.contains(&operation.id) {
return Some(FilterReason::ToolBlocklist);
}
for pattern in &self.blocklist {
if pattern.contains('*') && operation.matches_pattern(pattern) {
return Some(FilterReason::ToolBlocklistPattern {
pattern: pattern.clone(),
});
}
}
match self.mode {
ExposureMode::AllowAll => None,
ExposureMode::DenyAll => Some(FilterReason::ToolDenyAllMode),
ExposureMode::Allowlist => {
if self.allowlist.contains(&operation.id) {
return None;
}
for pattern in &self.allowlist {
if pattern.contains('*') && operation.matches_pattern(pattern) {
return None;
}
}
Some(FilterReason::ToolNotInAllowlist)
},
ExposureMode::Blocklist => None, }
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CodeModeExposurePolicy {
#[serde(default)]
pub reads: MethodExposurePolicy,
#[serde(default)]
pub writes: MethodExposurePolicy,
#[serde(default)]
pub deletes: MethodExposurePolicy,
#[serde(default)]
pub blocklist: HashSet<String>,
}
impl CodeModeExposurePolicy {
pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
if self.blocklist.contains(&operation.id) {
return Some(FilterReason::CodeModeBlocklist);
}
for pattern in &self.blocklist {
if pattern.contains('*') && operation.matches_pattern(pattern) {
return Some(FilterReason::CodeModeBlocklistPattern {
pattern: pattern.clone(),
});
}
}
let method_policy = self.get_method_policy(operation);
method_policy.is_allowed(operation)
}
fn get_method_policy(&self, operation: &Operation) -> &MethodExposurePolicy {
match operation.category {
OperationCategory::Read => &self.reads,
OperationCategory::Delete => &self.deletes,
OperationCategory::Create | OperationCategory::Update => &self.writes,
OperationCategory::Admin | OperationCategory::Internal => &self.writes,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MethodExposurePolicy {
#[serde(default)]
pub mode: ExposureMode,
#[serde(default)]
pub allowlist: HashSet<String>,
#[serde(default)]
pub blocklist: HashSet<String>,
}
impl MethodExposurePolicy {
pub fn is_allowed(&self, operation: &Operation) -> Option<FilterReason> {
if self.blocklist.contains(&operation.id) {
return Some(FilterReason::MethodBlocklist {
method_type: Self::method_type_name(operation),
});
}
for pattern in &self.blocklist {
if pattern.contains('*') && operation.matches_pattern(pattern) {
return Some(FilterReason::MethodBlocklistPattern {
method_type: Self::method_type_name(operation),
pattern: pattern.clone(),
});
}
}
match self.mode {
ExposureMode::AllowAll => None,
ExposureMode::DenyAll => Some(FilterReason::MethodDenyAllMode {
method_type: Self::method_type_name(operation),
}),
ExposureMode::Allowlist => {
if self.allowlist.contains(&operation.id) {
return None;
}
for pattern in &self.allowlist {
if pattern.contains('*') && operation.matches_pattern(pattern) {
return None;
}
}
Some(FilterReason::MethodNotInAllowlist {
method_type: Self::method_type_name(operation),
})
},
ExposureMode::Blocklist => None, }
}
fn method_type_name(operation: &Operation) -> String {
match operation.category {
OperationCategory::Read => "reads".to_string(),
OperationCategory::Delete => "deletes".to_string(),
_ => "writes".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExposureMode {
#[default]
AllowAll,
DenyAll,
Allowlist,
Blocklist,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolOverride {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub dangerous: bool,
#[serde(default)]
pub hidden: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DerivedSchema {
pub operations: Vec<Operation>,
pub documentation: String,
pub metadata: DerivationMetadata,
}
impl DerivedSchema {
pub fn get_operation(&self, id: &str) -> Option<&Operation> {
self.operations.iter().find(|op| op.id == id)
}
pub fn contains(&self, id: &str) -> bool {
self.operations.iter().any(|op| op.id == id)
}
pub fn operation_ids(&self) -> HashSet<String> {
self.operations.iter().map(|op| op.id.clone()).collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DerivationMetadata {
pub context: String,
pub derived_at: i64,
pub source_hash: String,
pub policy_hash: String,
pub cache_key: String,
pub filtered: Vec<FilteredOperation>,
pub stats: DerivationStats,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilteredOperation {
pub operation_id: String,
pub operation_name: String,
pub reason: FilterReason,
pub policy: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FilterReason {
GlobalBlocklistOperation { operation_id: String },
GlobalBlocklistPattern { pattern: String },
GlobalBlocklistCategory { category: OperationCategory },
GlobalBlocklistRiskLevel { level: OperationRiskLevel },
ToolBlocklist,
ToolBlocklistPattern { pattern: String },
ToolNotInAllowlist,
ToolDenyAllMode,
CodeModeBlocklist,
CodeModeBlocklistPattern { pattern: String },
MethodBlocklist { method_type: String },
MethodBlocklistPattern {
method_type: String,
pattern: String,
},
MethodNotInAllowlist { method_type: String },
MethodDenyAllMode { method_type: String },
}
impl FilterReason {
pub fn description(&self) -> String {
match self {
FilterReason::GlobalBlocklistOperation { operation_id } => {
format!("Operation '{}' is in the global blocklist", operation_id)
},
FilterReason::GlobalBlocklistPattern { pattern } => {
format!("Matches global blocklist pattern '{}'", pattern)
},
FilterReason::GlobalBlocklistCategory { category } => {
format!("Category '{:?}' is blocked globally", category)
},
FilterReason::GlobalBlocklistRiskLevel { level } => {
format!("Risk level '{:?}' is blocked globally", level)
},
FilterReason::ToolBlocklist => "Operation is in the tool blocklist".to_string(),
FilterReason::ToolBlocklistPattern { pattern } => {
format!("Matches tool blocklist pattern '{}'", pattern)
},
FilterReason::ToolNotInAllowlist => {
"Operation is not in the tool allowlist".to_string()
},
FilterReason::ToolDenyAllMode => "Tool exposure is set to deny_all".to_string(),
FilterReason::CodeModeBlocklist => {
"Operation is in the Code Mode blocklist".to_string()
},
FilterReason::CodeModeBlocklistPattern { pattern } => {
format!("Matches Code Mode blocklist pattern '{}'", pattern)
},
FilterReason::MethodBlocklist { method_type } => {
format!("Operation is in the {} blocklist", method_type)
},
FilterReason::MethodBlocklistPattern {
method_type,
pattern,
} => {
format!("Matches {} blocklist pattern '{}'", method_type, pattern)
},
FilterReason::MethodNotInAllowlist { method_type } => {
format!("Operation is not in the {} allowlist", method_type)
},
FilterReason::MethodDenyAllMode { method_type } => {
format!("{} exposure is set to deny_all", method_type)
},
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DerivationStats {
pub source_total: usize,
pub derived_total: usize,
pub filtered_total: usize,
pub filtered_by_reason: HashMap<String, usize>,
}
pub fn pattern_matches(pattern: &str, text: &str) -> bool {
let pattern = pattern.to_lowercase();
let text = text.to_lowercase();
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == text;
}
let mut pos = 0;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !text.starts_with(part) {
return false;
}
pos = part.len();
} else if i == parts.len() - 1 {
if !text[pos..].ends_with(part) {
return false;
}
} else {
match text[pos..].find(part) {
Some(found) => pos += found + part.len(),
None => return false,
}
}
}
true
}
pub struct SchemaDeriver {
operations: Vec<Operation>,
policy: McpExposurePolicy,
source_hash: String,
policy_hash: String,
}
impl SchemaDeriver {
pub fn new(operations: Vec<Operation>, policy: McpExposurePolicy, source_hash: String) -> Self {
let policy_hash = Self::compute_policy_hash(&policy);
Self {
operations,
policy,
source_hash,
policy_hash,
}
}
pub fn derive_tools_schema(&self) -> DerivedSchema {
let mut included = Vec::new();
let mut filtered = Vec::new();
for op in &self.operations {
if let Some(reason) = self.policy.global_blocklist.is_blocked(op) {
filtered.push(FilteredOperation {
operation_id: op.id.clone(),
operation_name: op.name.clone(),
reason,
policy: "global_blocklist".to_string(),
});
continue;
}
if let Some(reason) = self.policy.tools.is_allowed(op) {
filtered.push(FilteredOperation {
operation_id: op.id.clone(),
operation_name: op.name.clone(),
reason,
policy: "tools".to_string(),
});
continue;
}
let op = self.apply_tool_overrides(op);
included.push(op);
}
self.build_derived_schema(included, filtered, "tools")
}
pub fn derive_code_mode_schema(&self) -> DerivedSchema {
let mut included = Vec::new();
let mut filtered = Vec::new();
for op in &self.operations {
if let Some(reason) = self.policy.global_blocklist.is_blocked(op) {
filtered.push(FilteredOperation {
operation_id: op.id.clone(),
operation_name: op.name.clone(),
reason,
policy: "global_blocklist".to_string(),
});
continue;
}
if let Some(reason) = self.policy.code_mode.is_allowed(op) {
let policy_name = match op.category {
OperationCategory::Read => "code_mode.reads",
OperationCategory::Delete => "code_mode.deletes",
_ => "code_mode.writes",
};
filtered.push(FilteredOperation {
operation_id: op.id.clone(),
operation_name: op.name.clone(),
reason,
policy: policy_name.to_string(),
});
continue;
}
included.push(op.clone());
}
self.build_derived_schema(included, filtered, "code_mode")
}
pub fn is_tool_allowed(&self, operation_id: &str) -> bool {
self.operations
.iter()
.find(|op| op.id == operation_id)
.map(|op| {
self.policy.global_blocklist.is_blocked(op).is_none()
&& self.policy.tools.is_allowed(op).is_none()
})
.unwrap_or(false)
}
pub fn is_code_mode_allowed(&self, operation_id: &str) -> bool {
self.operations
.iter()
.find(|op| op.id == operation_id)
.map(|op| {
self.policy.global_blocklist.is_blocked(op).is_none()
&& self.policy.code_mode.is_allowed(op).is_none()
})
.unwrap_or(false)
}
pub fn get_tool_filter_reason(&self, operation_id: &str) -> Option<FilterReason> {
self.operations
.iter()
.find(|op| op.id == operation_id)
.and_then(|op| {
self.policy
.global_blocklist
.is_blocked(op)
.or_else(|| self.policy.tools.is_allowed(op))
})
}
pub fn get_code_mode_filter_reason(&self, operation_id: &str) -> Option<FilterReason> {
self.operations
.iter()
.find(|op| op.id == operation_id)
.and_then(|op| {
self.policy
.global_blocklist
.is_blocked(op)
.or_else(|| self.policy.code_mode.is_allowed(op))
})
}
pub fn find_operation_id(&self, method: &str, path_pattern: &str) -> Option<String> {
let method_upper = method.to_uppercase();
let normalized_pattern = Self::normalize_path_for_matching(path_pattern);
for op in &self.operations {
if let OperationDetails::OpenAPI {
method: op_method,
path: op_path,
..
} = &op.details
{
if op_method.to_uppercase() == method_upper {
let normalized_op_path = Self::normalize_path_for_matching(op_path);
if Self::paths_match(&normalized_pattern, &normalized_op_path) {
return Some(op.id.clone());
}
}
}
}
None
}
pub fn get_operations_for_allowlist(&self) -> Vec<(String, String, String)> {
self.operations
.iter()
.filter_map(|op| {
if let OperationDetails::OpenAPI { method, path, .. } = &op.details {
let method_path = format!("{}:{}", method.to_uppercase(), path);
let description = op.description.clone().unwrap_or_else(|| op.name.clone());
Some((op.id.clone(), method_path, description))
} else {
None
}
})
.collect()
}
fn normalize_path_for_matching(path: &str) -> String {
path.split('/')
.map(|segment| {
if segment.starts_with('{') && segment.ends_with('}') {
"*" } else if segment.starts_with(':') {
"*" } else if segment == "*" {
"*"
} else {
segment
}
})
.collect::<Vec<_>>()
.join("/")
}
fn paths_match(pattern: &str, path: &str) -> bool {
let pattern_parts: Vec<_> = pattern.split('/').collect();
let path_parts: Vec<_> = path.split('/').collect();
if pattern_parts.len() != path_parts.len() {
return false;
}
for (p, s) in pattern_parts.iter().zip(path_parts.iter()) {
if *p == "*" || *s == "*" {
continue; }
if p != s {
return false;
}
}
true
}
fn apply_tool_overrides(&self, op: &Operation) -> Operation {
let mut op = op.clone();
if let Some(override_config) = self.policy.tools.overrides.get(&op.id) {
if let Some(name) = &override_config.name {
op.name = name.clone();
}
if let Some(description) = &override_config.description {
op.description = Some(description.clone());
}
if override_config.dangerous {
op.risk_level = OperationRiskLevel::High;
}
}
op
}
fn build_derived_schema(
&self,
operations: Vec<Operation>,
filtered: Vec<FilteredOperation>,
context: &str,
) -> DerivedSchema {
let mut filtered_by_reason: HashMap<String, usize> = HashMap::new();
for f in &filtered {
let reason_type = match &f.reason {
FilterReason::GlobalBlocklistOperation { .. } => "global_blocklist_operation",
FilterReason::GlobalBlocklistPattern { .. } => "global_blocklist_pattern",
FilterReason::GlobalBlocklistCategory { .. } => "global_blocklist_category",
FilterReason::GlobalBlocklistRiskLevel { .. } => "global_blocklist_risk_level",
FilterReason::ToolBlocklist => "tool_blocklist",
FilterReason::ToolBlocklistPattern { .. } => "tool_blocklist_pattern",
FilterReason::ToolNotInAllowlist => "tool_not_in_allowlist",
FilterReason::ToolDenyAllMode => "tool_deny_all",
FilterReason::CodeModeBlocklist => "code_mode_blocklist",
FilterReason::CodeModeBlocklistPattern { .. } => "code_mode_blocklist_pattern",
FilterReason::MethodBlocklist { .. } => "method_blocklist",
FilterReason::MethodBlocklistPattern { .. } => "method_blocklist_pattern",
FilterReason::MethodNotInAllowlist { .. } => "method_not_in_allowlist",
FilterReason::MethodDenyAllMode { .. } => "method_deny_all",
};
*filtered_by_reason
.entry(reason_type.to_string())
.or_default() += 1;
}
let stats = DerivationStats {
source_total: self.operations.len(),
derived_total: operations.len(),
filtered_total: filtered.len(),
filtered_by_reason,
};
let documentation = self.generate_documentation(&operations, context);
let cache_key = format!("{}:{}:{}", context, self.source_hash, self.policy_hash);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
DerivedSchema {
operations,
documentation,
metadata: DerivationMetadata {
context: context.to_string(),
derived_at: now,
source_hash: self.source_hash.clone(),
policy_hash: self.policy_hash.clone(),
cache_key,
filtered,
stats,
},
}
}
fn generate_documentation(&self, operations: &[Operation], context: &str) -> String {
let mut doc = String::new();
if context == "code_mode" {
doc.push_str("# API Operations Available in Code Mode\n\n");
} else {
doc.push_str("# API Operations Available as MCP Tools\n\n");
}
doc.push_str(&format!(
"**{} of {} operations available**\n\n",
operations.len(),
self.operations.len()
));
let reads: Vec<_> = operations
.iter()
.filter(|o| o.category == OperationCategory::Read)
.collect();
let writes: Vec<_> = operations
.iter()
.filter(|o| {
matches!(
o.category,
OperationCategory::Create | OperationCategory::Update
)
})
.collect();
let deletes: Vec<_> = operations
.iter()
.filter(|o| o.category == OperationCategory::Delete)
.collect();
doc.push_str(&format!(
"## Read Operations ({} available)\n\n",
reads.len()
));
if reads.is_empty() {
doc.push_str("_No read operations available._\n\n");
} else {
for op in reads {
self.document_operation(&mut doc, op, context);
}
}
doc.push_str(&format!(
"\n## Write Operations ({} available)\n\n",
writes.len()
));
if writes.is_empty() {
doc.push_str("_No write operations available._\n\n");
} else {
for op in writes {
self.document_operation(&mut doc, op, context);
}
}
doc.push_str(&format!(
"\n## Delete Operations ({} available)\n\n",
deletes.len()
));
if deletes.is_empty() {
doc.push_str("_No delete operations available._\n\n");
} else {
for op in deletes {
self.document_operation(&mut doc, op, context);
}
}
doc
}
fn document_operation(&self, doc: &mut String, op: &Operation, context: &str) {
match &op.details {
OperationDetails::OpenAPI { method, path, .. } => {
if context == "code_mode" {
let method_lower = method.to_lowercase();
doc.push_str(&format!(
"- `api.{}(\"{}\")` - {}\n",
method_lower, path, op.name
));
} else {
doc.push_str(&format!("- **{}**: `{} {}`\n", op.name, method, path));
}
},
OperationDetails::GraphQL {
operation_type,
field_name,
..
} => {
doc.push_str(&format!(
"- **{}**: `{:?}.{}`\n",
op.name, operation_type, field_name
));
},
OperationDetails::Sql {
statement_type,
table,
..
} => {
doc.push_str(&format!(
"- **{}**: `{:?} {}`\n",
op.name, statement_type, table
));
},
OperationDetails::Unknown => {
doc.push_str(&format!("- **{}** ({})\n", op.name, op.id));
},
}
if let Some(desc) = &op.description {
doc.push_str(&format!(" {}\n", desc));
}
}
fn compute_policy_hash(policy: &McpExposurePolicy) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
let mut ops: Vec<_> = policy.global_blocklist.operations.iter().collect();
ops.sort();
for op in ops {
op.hash(&mut hasher);
}
let mut patterns: Vec<_> = policy.global_blocklist.patterns.iter().collect();
patterns.sort();
for p in patterns {
p.hash(&mut hasher);
}
format!("{:?}", policy.tools.mode).hash(&mut hasher);
let mut allowlist: Vec<_> = policy.tools.allowlist.iter().collect();
allowlist.sort();
for a in allowlist {
a.hash(&mut hasher);
}
format!("{:?}", policy.code_mode.reads.mode).hash(&mut hasher);
format!("{:?}", policy.code_mode.writes.mode).hash(&mut hasher);
format!("{:?}", policy.code_mode.deletes.mode).hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pattern_matching() {
assert!(pattern_matches("GET /users", "GET /users"));
assert!(!pattern_matches("GET /users", "POST /users"));
assert!(pattern_matches("GET /users/*", "GET /users/123"));
assert!(pattern_matches("GET /users/*", "GET /users/123/posts"));
assert!(!pattern_matches("GET /users/*", "GET /posts/123"));
assert!(pattern_matches("* /admin/*", "GET /admin/users"));
assert!(pattern_matches("* /admin/*", "DELETE /admin/config"));
assert!(pattern_matches(
"GET /users/*/posts",
"GET /users/123/posts"
));
assert!(pattern_matches("*/admin/*", "DELETE /admin/all"));
assert!(pattern_matches("GET /USERS", "get /users"));
}
#[test]
fn test_global_blocklist() {
let blocklist = GlobalBlocklist {
operations: ["factoryReset".to_string()].into_iter().collect(),
patterns: ["* /admin/*".to_string()].into_iter().collect(),
categories: [OperationCategory::Internal].into_iter().collect(),
risk_levels: [OperationRiskLevel::Critical].into_iter().collect(),
};
let op = Operation {
id: "factoryReset".to_string(),
name: "Factory Reset".to_string(),
description: None,
category: OperationCategory::Admin,
is_read_only: false,
risk_level: OperationRiskLevel::Critical,
tags: vec![],
details: OperationDetails::Unknown,
};
assert!(blocklist.is_blocked(&op).is_some());
let op = Operation {
id: "listAdminUsers".to_string(),
name: "List Admin Users".to_string(),
description: None,
category: OperationCategory::Read,
is_read_only: true,
risk_level: OperationRiskLevel::Safe,
tags: vec![],
details: OperationDetails::OpenAPI {
method: "GET".to_string(),
path: "/admin/users".to_string(),
parameters: vec![],
has_request_body: false,
},
};
assert!(blocklist.is_blocked(&op).is_some());
let op = Operation {
id: "internalSync".to_string(),
name: "Internal Sync".to_string(),
description: None,
category: OperationCategory::Internal,
is_read_only: false,
risk_level: OperationRiskLevel::Low,
tags: vec![],
details: OperationDetails::Unknown,
};
assert!(blocklist.is_blocked(&op).is_some());
let op = Operation {
id: "listUsers".to_string(),
name: "List Users".to_string(),
description: None,
category: OperationCategory::Read,
is_read_only: true,
risk_level: OperationRiskLevel::Safe,
tags: vec![],
details: OperationDetails::OpenAPI {
method: "GET".to_string(),
path: "/users".to_string(),
parameters: vec![],
has_request_body: false,
},
};
assert!(blocklist.is_blocked(&op).is_none());
}
#[test]
fn test_exposure_modes() {
let policy = ToolExposurePolicy {
mode: ExposureMode::AllowAll,
blocklist: ["blocked".to_string()].into_iter().collect(),
..Default::default()
};
let allowed_op = Operation::new("allowed", "Allowed", OperationCategory::Read);
let blocked_op = Operation::new("blocked", "Blocked", OperationCategory::Read);
assert!(policy.is_allowed(&allowed_op).is_none());
assert!(policy.is_allowed(&blocked_op).is_some());
let policy = ToolExposurePolicy {
mode: ExposureMode::Allowlist,
allowlist: ["allowed".to_string()].into_iter().collect(),
..Default::default()
};
assert!(policy.is_allowed(&allowed_op).is_none());
assert!(policy.is_allowed(&blocked_op).is_some());
let policy = ToolExposurePolicy {
mode: ExposureMode::DenyAll,
..Default::default()
};
assert!(policy.is_allowed(&allowed_op).is_some());
}
#[test]
fn test_schema_deriver() {
let operations = vec![
Operation::new("listUsers", "List Users", OperationCategory::Read),
Operation::new("createUser", "Create User", OperationCategory::Create),
Operation::new("deleteUser", "Delete User", OperationCategory::Delete),
Operation::new("factoryReset", "Factory Reset", OperationCategory::Admin),
];
let policy = McpExposurePolicy {
global_blocklist: GlobalBlocklist {
operations: ["factoryReset".to_string()].into_iter().collect(),
..Default::default()
},
tools: ToolExposurePolicy {
mode: ExposureMode::AllowAll,
..Default::default()
},
code_mode: CodeModeExposurePolicy {
reads: MethodExposurePolicy {
mode: ExposureMode::AllowAll,
..Default::default()
},
writes: MethodExposurePolicy {
mode: ExposureMode::Allowlist,
allowlist: ["createUser".to_string()].into_iter().collect(),
..Default::default()
},
deletes: MethodExposurePolicy {
mode: ExposureMode::DenyAll,
..Default::default()
},
..Default::default()
},
};
let deriver = SchemaDeriver::new(operations, policy, "test-hash".to_string());
let tools = deriver.derive_tools_schema();
assert_eq!(tools.operations.len(), 3);
assert!(tools.contains("listUsers"));
assert!(tools.contains("createUser"));
assert!(tools.contains("deleteUser"));
assert!(!tools.contains("factoryReset"));
let code_mode = deriver.derive_code_mode_schema();
assert_eq!(code_mode.operations.len(), 2);
assert!(code_mode.contains("listUsers"));
assert!(code_mode.contains("createUser"));
assert!(!code_mode.contains("deleteUser"));
assert!(!code_mode.contains("factoryReset"));
}
}