Files
windmill/backend/windmill-mcp/src/common/scope.rs
T
centdix 437bad4cb7 chore: move mcp logic to windmill-mcp (#7584)
* draft

* clean up mcp logic

* cleaning

* cleaning

* better code

* error logging

* cleaning
2026-01-16 20:55:14 +00:00

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"));
}
}