mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 00:16:53 +00:00
* draft * clean up mcp logic * cleaning * cleaning * better code * error logging * cleaning
258 lines
8.0 KiB
Rust
258 lines
8.0 KiB
Rust
//! MCP Scope matching utilities
|
|
//!
|
|
//! Contains utilities for parsing and matching MCP token scopes to determine
|
|
//! which scripts, flows, and endpoints a token has access to.
|
|
|
|
/// Configuration for MCP scopes parsed from token scopes
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct McpScopeConfig {
|
|
/// Script paths/patterns allowed by this token
|
|
pub scripts: Vec<String>,
|
|
/// Flow paths/patterns allowed by this token
|
|
pub flows: Vec<String>,
|
|
/// Endpoint names/patterns allowed by this token
|
|
pub endpoints: Vec<String>,
|
|
/// Whether this is a legacy "all" scope
|
|
pub all: bool,
|
|
/// Whether this is a "favorites" scope
|
|
pub favorites: bool,
|
|
/// Whether a granular scope is detected
|
|
pub granular: bool,
|
|
/// Hub app filter (if any)
|
|
pub hub_apps: Option<String>,
|
|
}
|
|
|
|
impl McpScopeConfig {
|
|
/// Check if a resource is allowed based on its type and path
|
|
pub fn is_allowed(&self, resource_type: &str, path: &str) -> bool {
|
|
if self.all {
|
|
return true;
|
|
}
|
|
|
|
let patterns = match resource_type {
|
|
"script" => &self.scripts,
|
|
"flow" => &self.flows,
|
|
"endpoint" => &self.endpoints,
|
|
_ => return false,
|
|
};
|
|
|
|
is_resource_allowed(path, patterns)
|
|
}
|
|
}
|
|
|
|
/// Parse MCP scopes from token scope strings
|
|
pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> {
|
|
let mut config = McpScopeConfig::default();
|
|
|
|
for scope in scopes {
|
|
if !scope.starts_with("mcp:") {
|
|
continue;
|
|
}
|
|
|
|
if scope == "mcp:all" {
|
|
// Legacy scope: grant access to everything
|
|
config.all = true;
|
|
config.scripts.push("*".to_string());
|
|
config.flows.push("*".to_string());
|
|
config.endpoints.push("*".to_string());
|
|
continue;
|
|
}
|
|
|
|
if scope == "mcp:favorites" {
|
|
// Legacy favorites scope
|
|
config.favorites = true;
|
|
continue;
|
|
}
|
|
|
|
// Legacy folder scope: mcp:all:f/folder/*
|
|
if scope.starts_with("mcp:all:") {
|
|
if let Some(folder_pattern) = scope.strip_prefix("mcp:all:") {
|
|
// Parse as folder pattern - add to both scripts and flows. Also add all endpoints.
|
|
config.scripts.push(folder_pattern.to_string());
|
|
config.flows.push(folder_pattern.to_string());
|
|
config.endpoints.push("*".to_string());
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if scope.starts_with("mcp:hub:") {
|
|
// Legacy hub scope
|
|
if let Some(apps) = scope.strip_prefix("mcp:hub:") {
|
|
config.hub_apps = Some(apps.to_string());
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if let Some(resources) = scope.strip_prefix("mcp:scripts:") {
|
|
// New granular script scope: mcp:scripts:path1,path2,f/folder/*
|
|
config.scripts.extend(parse_resource_list(resources));
|
|
continue;
|
|
}
|
|
|
|
if let Some(resources) = scope.strip_prefix("mcp:flows:") {
|
|
// New granular flow scope: mcp:flows:path1,path2,f/folder/*
|
|
config.flows.extend(parse_resource_list(resources));
|
|
continue;
|
|
}
|
|
|
|
if let Some(resources) = scope.strip_prefix("mcp:endpoints:") {
|
|
// New granular endpoint scope: mcp:endpoints:name1,name2
|
|
config.endpoints.extend(parse_resource_list(resources));
|
|
continue;
|
|
}
|
|
|
|
tracing::warn!("Unrecognized MCP scope format: {}", scope);
|
|
}
|
|
|
|
config.granular = !config.all && !config.favorites;
|
|
|
|
Ok(config)
|
|
}
|
|
|
|
/// Parse comma-separated resource list
|
|
fn parse_resource_list(resources: &str) -> Vec<String> {
|
|
if resources.is_empty() {
|
|
return vec![];
|
|
}
|
|
|
|
resources
|
|
.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
/// Check if a resource path matches any pattern in the allowed list
|
|
pub fn is_resource_allowed(resource_path: &str, allowed_patterns: &[String]) -> bool {
|
|
if allowed_patterns.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
// Wildcard grants all access
|
|
if allowed_patterns.contains(&"*".to_string()) {
|
|
return true;
|
|
}
|
|
|
|
// Check against each pattern
|
|
for pattern in allowed_patterns {
|
|
if resource_matches_pattern(resource_path, pattern) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
/// Check if a resource path matches a pattern (supports wildcards like f/folder/*)
|
|
fn resource_matches_pattern(resource_path: &str, pattern: &str) -> bool {
|
|
// Exact match
|
|
if pattern == resource_path {
|
|
return true;
|
|
}
|
|
|
|
// Wildcard pattern matching
|
|
if pattern.ends_with("/*") {
|
|
let prefix = &pattern[..pattern.len() - 2];
|
|
|
|
if !resource_path.starts_with(prefix) {
|
|
return false;
|
|
}
|
|
|
|
// If the resource is exactly the prefix, it matches
|
|
if resource_path.len() == prefix.len() {
|
|
return true;
|
|
}
|
|
|
|
// If the resource is longer, the next character must be '/' for a valid match
|
|
// This prevents "u/user" from matching "u/use/*"
|
|
return resource_path.chars().nth(prefix.len()) == Some('/');
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_legacy_scopes() {
|
|
let scopes = vec!["mcp:all".to_string()];
|
|
let config = parse_mcp_scopes(&scopes).unwrap();
|
|
assert!(config.all);
|
|
assert_eq!(config.scripts, vec!["*"]);
|
|
assert_eq!(config.flows, vec!["*"]);
|
|
assert_eq!(config.endpoints, vec!["*"]);
|
|
|
|
let scopes = vec!["mcp:favorites".to_string()];
|
|
let config = parse_mcp_scopes(&scopes).unwrap();
|
|
assert!(config.favorites);
|
|
|
|
let scopes = vec!["mcp:hub:slack".to_string()];
|
|
let config = parse_mcp_scopes(&scopes).unwrap();
|
|
assert_eq!(config.hub_apps, Some("slack".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_granular_scopes() {
|
|
let scopes = vec![
|
|
"mcp:scripts:u/admin/script1,u/admin/script2".to_string(),
|
|
"mcp:flows:f/automation/*".to_string(),
|
|
"mcp:endpoints:list_jobs,get_job".to_string(),
|
|
];
|
|
let config = parse_mcp_scopes(&scopes).unwrap();
|
|
|
|
assert_eq!(config.scripts, vec!["u/admin/script1", "u/admin/script2"]);
|
|
assert_eq!(config.flows, vec!["f/automation/*"]);
|
|
assert_eq!(config.endpoints, vec!["list_jobs", "get_job"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resource_matching() {
|
|
// Exact match
|
|
assert!(resource_matches_pattern("u/admin/script", "u/admin/script"));
|
|
|
|
// Wildcard folder match
|
|
assert!(resource_matches_pattern("f/folder/script", "f/folder/*"));
|
|
assert!(resource_matches_pattern(
|
|
"f/folder/sub/script",
|
|
"f/folder/*"
|
|
));
|
|
|
|
// Should NOT match - prefix is not complete
|
|
assert!(!resource_matches_pattern("u/username", "u/user/*"));
|
|
|
|
// Should match - exact prefix
|
|
assert!(resource_matches_pattern("u/user/script", "u/user/*"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_resource_allowed() {
|
|
let patterns = vec!["u/admin/script1".to_string(), "f/folder/*".to_string()];
|
|
|
|
assert!(is_resource_allowed("u/admin/script1", &patterns));
|
|
assert!(is_resource_allowed("f/folder/anything", &patterns));
|
|
assert!(!is_resource_allowed("u/other/script", &patterns));
|
|
|
|
// Test wildcard
|
|
let wildcard = vec!["*".to_string()];
|
|
assert!(is_resource_allowed("any/path", &wildcard));
|
|
|
|
// Test empty patterns
|
|
let empty: Vec<String> = vec![];
|
|
assert!(!is_resource_allowed("any/path", &empty));
|
|
}
|
|
|
|
#[test]
|
|
fn test_scope_config_is_allowed() {
|
|
let mut config = McpScopeConfig::default();
|
|
config.scripts.push("u/admin/*".to_string());
|
|
config.flows.push("f/automation/*".to_string());
|
|
|
|
assert!(config.is_allowed("script", "u/admin/test"));
|
|
assert!(!config.is_allowed("script", "u/other/test"));
|
|
assert!(config.is_allowed("flow", "f/automation/test"));
|
|
assert!(!config.is_allowed("flow", "f/other/test"));
|
|
}
|
|
}
|