mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 00:02:23 +00:00
feat(mcp): granular token scopes for scripts, flows, and endpoints (#7130)
* feat(mcp): add granular scope parsing and filtering
- Add scope_matcher.rs with McpScopeConfig and parsing logic
- Support new scope format: mcp:scripts:{paths}, mcp:flows:{paths}, mcp:endpoints:{names}
- Update check_scopes() to accept any mcp:* scope
- Implement resource matching with wildcard support (f/folder/*)
- Full backward compatibility with legacy scopes (mcp:all, mcp:favorites, mcp:hub:{app})
- Add comprehensive unit tests for scope parsing and matching
Co-authored-by: centdix <centdix@users.noreply.github.com>
* feat(mcp): add badge-based UI for granular scope selection
- Add 'Custom' toggle option to MCP token creation UI
- Implement clickable badge interface for selecting scripts/flows/endpoints
- Selected badges show in blue, unselected in gray with opacity
- Automatically load all workspace scripts/flows/endpoints in custom mode
- Generate granular scopes: mcp:scripts:{paths}, mcp:flows:{paths}, mcp:endpoints:{names}
- Validate at least one resource is selected before token creation
- Display selection count for user feedback
Co-authored-by: centdix <centdix@users.noreply.github.com>
* docs(openapi): document granular MCP scope format
- Add comprehensive documentation for MCP scopes in NewToken schema
- Document new granular format: mcp:scripts:{paths}, mcp:flows:{paths}, mcp:endpoints:{names}
- Provide usage examples for common scenarios
- Mark legacy scopes (mcp:all, mcp:favorites) as deprecated but supported
- Include wildcard pattern examples (f/folder/*)
Co-authored-by: centdix <centdix@users.noreply.github.com>
* fix: add type annotation for None in scope_path tuple
Fix compilation error where type parameter T could not be inferred for Option.
Changed None to None::<&str> to explicitly specify the type.
Co-authored-by: centdix <centdix@users.noreply.github.com>
* better ui
* cleaning
* fix back comp
* fix
* fix
* cleaning
---------
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: centdix <centdix@users.noreply.github.com>
This commit is contained in:
@@ -15113,7 +15113,7 @@ components:
|
||||
schema:
|
||||
type: integer
|
||||
JobTriggerKind:
|
||||
name: trigger_kind
|
||||
name: trigger_kind
|
||||
description: trigger kind (schedule, http, websocket...)
|
||||
in: query
|
||||
schema:
|
||||
|
||||
@@ -39,6 +39,7 @@ use super::utils::{
|
||||
FlowInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, ToolableItem, WorkspaceId,
|
||||
},
|
||||
schema::transform_schema_for_resources,
|
||||
scope_matcher::{is_resource_allowed, parse_mcp_scopes},
|
||||
transform::{reverse_transform, reverse_transform_key},
|
||||
};
|
||||
|
||||
@@ -151,6 +152,10 @@ impl ServerHandler for Runner {
|
||||
|
||||
check_scopes(authed)?;
|
||||
|
||||
// Parse MCP scopes for authorization
|
||||
let scopes = authed.scopes.as_ref().map(|s| s.as_slice()).unwrap_or(&[]);
|
||||
let scope_config = parse_mcp_scopes(scopes)?;
|
||||
|
||||
if request.name.ends_with("_TRUNC") {
|
||||
return Ok(CallToolResult::error(
|
||||
vec![
|
||||
@@ -197,6 +202,19 @@ impl ServerHandler for Runner {
|
||||
let endpoint_tools = all_endpoint_tools();
|
||||
for endpoint_tool in endpoint_tools {
|
||||
if endpoint_tool.name.as_ref() == request.name {
|
||||
// Validate endpoint scope
|
||||
if scope_config.granular
|
||||
&& !is_resource_allowed(&endpoint_tool.name, &scope_config.endpoints)
|
||||
{
|
||||
return Err(ErrorData::internal_error(
|
||||
format!(
|
||||
"Access denied: endpoint '{}' not in token scope",
|
||||
endpoint_tool.name
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
// This is an endpoint tool, forward to the actual HTTP endpoint
|
||||
let result =
|
||||
call_endpoint_tool(&endpoint_tool, args.clone(), &workspace_id, &authed)
|
||||
@@ -212,6 +230,21 @@ impl ServerHandler for Runner {
|
||||
ErrorData::internal_error(format!("Failed to reverse transform path: {}", e), None)
|
||||
})?;
|
||||
|
||||
// Validate script/flow scope
|
||||
if !is_hub && scope_config.granular {
|
||||
if tool_type == "script" && !is_resource_allowed(&path, &scope_config.scripts) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("Access denied: script '{}' not in token scope", path),
|
||||
None,
|
||||
));
|
||||
} else if tool_type == "flow" && !is_resource_allowed(&path, &scope_config.flows) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("Access denied: flow '{}' not in token scope", path),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let item_schema = if is_hub {
|
||||
get_hub_script_schema(&format!("hub/{}", path), db).await?
|
||||
} else {
|
||||
@@ -337,53 +370,23 @@ impl ServerHandler for Runner {
|
||||
})
|
||||
.map(|w_id| w_id.0.clone())?;
|
||||
|
||||
let scopes = authed.scopes.as_ref();
|
||||
let owned_scope = scopes.and_then(|scopes| {
|
||||
scopes
|
||||
.iter()
|
||||
.find(|scope| scope.starts_with("mcp:") && !scope.contains("hub"))
|
||||
});
|
||||
let hub_scope =
|
||||
scopes.and_then(|scopes| scopes.iter().find(|scope| scope.starts_with("mcp:hub")));
|
||||
let (scope_type, scope_path) = owned_scope.map_or(("all", None), |scope| {
|
||||
let parts = scope.split(":").collect::<Vec<&str>>();
|
||||
(
|
||||
parts[1],
|
||||
if parts.len() == 3 {
|
||||
Some(parts[2])
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
});
|
||||
let scope_integrations = hub_scope.and_then(|scope| {
|
||||
let parts = scope.split(":").collect::<Vec<&str>>();
|
||||
if parts.len() == 3 {
|
||||
Some(parts[2])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
// Parse MCP scopes to determine what to expose
|
||||
let scopes = authed.scopes.as_ref().map(|s| s.as_slice()).unwrap_or(&[]);
|
||||
let scope_config = parse_mcp_scopes(scopes)?;
|
||||
|
||||
let scripts_fn = get_items::<ScriptInfo>(
|
||||
user_db,
|
||||
authed,
|
||||
&workspace_id,
|
||||
scope_type,
|
||||
"script",
|
||||
scope_path.as_deref(),
|
||||
);
|
||||
let flows_fn = get_items::<FlowInfo>(
|
||||
user_db,
|
||||
authed,
|
||||
&workspace_id,
|
||||
scope_type,
|
||||
"flow",
|
||||
scope_path.as_deref(),
|
||||
);
|
||||
let scope_type = if scope_config.favorites {
|
||||
"favorites"
|
||||
} else {
|
||||
// Fetch all items if either all or granular scope set (we filter later for granular scopes)
|
||||
"all"
|
||||
};
|
||||
|
||||
let scripts_fn =
|
||||
get_items::<ScriptInfo>(user_db, authed, &workspace_id, scope_type, "script");
|
||||
let flows_fn = get_items::<FlowInfo>(user_db, authed, &workspace_id, scope_type, "flow");
|
||||
let resources_types_fn = get_resources_types(user_db, authed, &workspace_id);
|
||||
let hub_scripts_fn = get_scripts_from_hub(db, scope_integrations.as_deref());
|
||||
let (scripts, flows, resources_types, hub_scripts) = if scope_integrations.is_some() {
|
||||
let hub_scripts_fn = get_scripts_from_hub(db, scope_config.hub_apps.as_deref());
|
||||
let (scripts, flows, resources_types, hub_scripts) = if scope_config.hub_apps.is_some() {
|
||||
let (scripts, flows, resources_types, hub_scripts) =
|
||||
try_join!(scripts_fn, flows_fn, resources_types_fn, hub_scripts_fn)?;
|
||||
(scripts, flows, resources_types, hub_scripts)
|
||||
@@ -396,7 +399,13 @@ impl ServerHandler for Runner {
|
||||
let mut resources_cache: HashMap<String, Vec<ResourceInfo>> = HashMap::new();
|
||||
let mut tools: Vec<Tool> = Vec::new();
|
||||
|
||||
// Filter and add scripts based on scope
|
||||
for script in scripts {
|
||||
// For granular scopes, filter by path
|
||||
if scope_config.granular && !is_resource_allowed(&script.path, &scope_config.scripts) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tools.push(
|
||||
Runner::create_tool_from_item(
|
||||
&script,
|
||||
@@ -410,7 +419,13 @@ impl ServerHandler for Runner {
|
||||
);
|
||||
}
|
||||
|
||||
// Filter and add flows based on scope
|
||||
for flow in flows {
|
||||
// For granular scopes, filter by path
|
||||
if scope_config.granular && !is_resource_allowed(&flow.path, &scope_config.flows) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tools.push(
|
||||
Runner::create_tool_from_item(
|
||||
&flow,
|
||||
@@ -438,10 +453,23 @@ impl ServerHandler for Runner {
|
||||
);
|
||||
}
|
||||
|
||||
// Add endpoint tools from the generated MCP tools
|
||||
// Add endpoint tools from the generated MCP tools, filtered by scope
|
||||
let endpoint_tools = all_endpoint_tools();
|
||||
let mcp_tools_converted = endpoint_tools_to_mcp_tools(endpoint_tools);
|
||||
tools.extend(mcp_tools_converted);
|
||||
for endpoint_tool in endpoint_tools {
|
||||
// For granular scopes, filter by endpoint name
|
||||
if scope_config.granular
|
||||
&& !is_resource_allowed(&endpoint_tool.name, &scope_config.endpoints)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tools.push(
|
||||
endpoint_tools_to_mcp_tools(vec![endpoint_tool])
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ListToolsResult { tools, next_cursor: None })
|
||||
}
|
||||
|
||||
@@ -18,11 +18,10 @@ use crate::HTTP_CLIENT;
|
||||
pub fn check_scopes(authed: &ApiAuthed) -> Result<(), ErrorData> {
|
||||
let scopes = authed.scopes.as_ref();
|
||||
if scopes.is_none()
|
||||
|| scopes.unwrap().iter().all(|scope| {
|
||||
!scope.starts_with("mcp:all")
|
||||
&& !scope.starts_with("mcp:favorites")
|
||||
&& !scope.starts_with("mcp:hub:")
|
||||
})
|
||||
|| scopes
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|scope| !scope.starts_with("mcp:"))
|
||||
{
|
||||
tracing::error!("Unauthorized: missing mcp scope");
|
||||
return Err(ErrorData::internal_error(
|
||||
@@ -141,7 +140,6 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen
|
||||
workspace_id: &str,
|
||||
scope_type: &str,
|
||||
item_type: &str,
|
||||
scope_path: Option<&str>,
|
||||
) -> Result<Vec<T>, ErrorData> {
|
||||
let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type));
|
||||
let fields = vec!["o.path", "o.summary", "o.description", "o.schema"];
|
||||
@@ -159,23 +157,6 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen
|
||||
sqlb.and_where("(o.no_main_func IS NOT TRUE OR o.no_main_func IS NULL)");
|
||||
}
|
||||
|
||||
// scope path is always a folder path, format is f/my_folder/*
|
||||
if let Some(scope_path) = scope_path {
|
||||
if scope_path.split("/").count() != 3
|
||||
|| !scope_path.starts_with("f/")
|
||||
|| !scope_path.ends_with("/*")
|
||||
{
|
||||
return Err(ErrorData::internal_error(
|
||||
format!(
|
||||
"Invalid folder format: {}, expected format is f/my_folder/*",
|
||||
scope_path
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
sqlb.and_where_like_left("o.path", &scope_path[..scope_path.len() - 2]);
|
||||
}
|
||||
|
||||
sqlb.order_by(
|
||||
if item_type == "flow" {
|
||||
"o.edited_at"
|
||||
|
||||
@@ -6,4 +6,5 @@
|
||||
pub mod models;
|
||||
pub mod database;
|
||||
pub mod schema;
|
||||
pub mod transform;
|
||||
pub mod transform;
|
||||
pub mod scope_matcher;
|
||||
@@ -0,0 +1,229 @@
|
||||
//! 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.
|
||||
|
||||
use rmcp::ErrorData;
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// Parse MCP scopes from token scope strings
|
||||
pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, ErrorData> {
|
||||
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) -> Result<Vec<String>, ErrorData> {
|
||||
if resources.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
Ok(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));
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,13 @@
|
||||
let includedRunnables = $state<string[]>([])
|
||||
let selectedFolder = $state<string>('')
|
||||
|
||||
// Granular scope selection
|
||||
let selectedScripts = $state<string[]>([])
|
||||
let selectedFlows = $state<string[]>([])
|
||||
let selectedEndpoints = $state<string[]>([])
|
||||
let allScripts = $state<string[]>([])
|
||||
let allFlows = $state<string[]>([])
|
||||
|
||||
let runnablesCache = new Map<string, string[]>()
|
||||
|
||||
let customScopes = $state<string[]>([])
|
||||
@@ -86,9 +93,21 @@
|
||||
|
||||
let tokenScopes = scopes
|
||||
if (mcpMode) {
|
||||
if (newMcpScope === 'folder') {
|
||||
if (newMcpScope === 'custom') {
|
||||
// Granular scope format
|
||||
tokenScopes = []
|
||||
if (selectedScripts.length > 0) {
|
||||
tokenScopes.push(`mcp:scripts:${selectedScripts.join(',')}`)
|
||||
}
|
||||
if (selectedFlows.length > 0) {
|
||||
tokenScopes.push(`mcp:flows:${selectedFlows.join(',')}`)
|
||||
}
|
||||
if (selectedEndpoints.length > 0) {
|
||||
tokenScopes.push(`mcp:endpoints:${selectedEndpoints.join(',')}`)
|
||||
}
|
||||
} else if (newMcpScope === 'folder') {
|
||||
const folderPath = `f/${selectedFolder}/*`
|
||||
tokenScopes = [`mcp:all:${folderPath}`]
|
||||
tokenScopes = [`mcp:scripts:${folderPath}`, `mcp:flows:${folderPath}`, `mcp:endpoints:*`]
|
||||
} else {
|
||||
tokenScopes = [`mcp:${newMcpScope}`]
|
||||
}
|
||||
@@ -247,6 +266,53 @@
|
||||
selectedFolder = ''
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (mcpCreationMode && newMcpScope === 'custom') {
|
||||
const workspace = newTokenWorkspace || $workspaceStore
|
||||
if (workspace) {
|
||||
loadAllScriptsAndFlows(workspace)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function loadAllScriptsAndFlows(workspace: string) {
|
||||
try {
|
||||
loadingRunnables = true
|
||||
const [scripts, flows] = await Promise.all([
|
||||
getScripts(false, workspace, undefined),
|
||||
getFlows(false, workspace, undefined)
|
||||
])
|
||||
allScripts = scripts
|
||||
allFlows = flows
|
||||
} finally {
|
||||
loadingRunnables = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllScripts() {
|
||||
selectedScripts = [...allScripts]
|
||||
}
|
||||
|
||||
function clearAllScripts() {
|
||||
selectedScripts = []
|
||||
}
|
||||
|
||||
function selectAllFlows() {
|
||||
selectedFlows = [...allFlows]
|
||||
}
|
||||
|
||||
function clearAllFlows() {
|
||||
selectedFlows = []
|
||||
}
|
||||
|
||||
function selectAllEndpoints() {
|
||||
selectedEndpoints = [...mcpEndpointTools.map((e) => e.name)]
|
||||
}
|
||||
|
||||
function clearAllEndpoints() {
|
||||
selectedEndpoints = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -317,7 +383,7 @@
|
||||
|
||||
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{#if mcpCreationMode}
|
||||
<div>
|
||||
<div class="col-span-2">
|
||||
<span class="block mb-1 text-emphasis text-xs font-semibold">Scope</span>
|
||||
<ToggleButtonGroup bind:selected={newMcpScope} allowEmpty={false}>
|
||||
{#snippet children({ item })}
|
||||
@@ -339,6 +405,12 @@
|
||||
label="Folder"
|
||||
tooltip="Make all scripts and flows in the selected folder available as tools"
|
||||
/>
|
||||
<ToggleButton
|
||||
{item}
|
||||
value="custom"
|
||||
label="Custom"
|
||||
tooltip="Select exactly which scripts, flows, and endpoints to expose"
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
@@ -404,7 +476,68 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if mcpCreationMode && (newMcpScope !== 'folder' || selectedFolder.length > 0)}
|
||||
{#if mcpCreationMode && newMcpScope === 'custom'}
|
||||
{#if loadingRunnables}
|
||||
<div class="flex flex-col gap-2 col-span-2 pr-4">
|
||||
<span class="block text-xs text-primary">Loading scripts and flows...</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Badge rounded small color="dark-gray" baseClass="animate-skeleton">Loading...</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#snippet sectionHeader(label: string, selectAll: () => void, clearAll: () => void)}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="block text-xs font-semibold">{label}</span>
|
||||
<div class="flex gap-2">
|
||||
<Button size="xs2" on:click={selectAll}>Select All</Button>
|
||||
<Button size="xs2" on:click={clearAll}>Clear All</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col gap-2 col-span-2 pr-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
{@render sectionHeader('Scripts', selectAllScripts, clearAllScripts)}
|
||||
{#if allScripts.length > 0}
|
||||
<MultiSelect
|
||||
items={safeSelectItems(allScripts)}
|
||||
placeholder="Select scripts"
|
||||
bind:value={selectedScripts}
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-xs text-primary">No scripts available</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
{@render sectionHeader('Flows', selectAllFlows, clearAllFlows)}
|
||||
{#if allFlows.length > 0}
|
||||
<MultiSelect
|
||||
items={safeSelectItems(allFlows)}
|
||||
placeholder="Select flows"
|
||||
bind:value={selectedFlows}
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-xs text-primary">No flows available</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
{@render sectionHeader('API Endpoints', selectAllEndpoints, clearAllEndpoints)}
|
||||
<MultiSelect
|
||||
items={safeSelectItems(mcpEndpointTools.map((e) => e.name))}
|
||||
placeholder="Select endpoints"
|
||||
bind:value={selectedEndpoints}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-primary mt-2">
|
||||
Selected: {selectedScripts.length} scripts, {selectedFlows.length} flows, {selectedEndpoints.length}
|
||||
endpoints
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if mcpCreationMode && (newMcpScope !== 'folder' || selectedFolder.length > 0)}
|
||||
{#if loadingRunnables}
|
||||
<div class="flex flex-col gap-2 col-span-2 pr-4">
|
||||
<span class="block text-xs text-primary"
|
||||
@@ -476,7 +609,12 @@
|
||||
<Button
|
||||
on:click={() => createToken(mcpCreationMode)}
|
||||
disabled={mcpCreationMode &&
|
||||
(newTokenWorkspace == undefined || (newMcpScope === 'folder' && !selectedFolder))}
|
||||
(newTokenWorkspace == undefined ||
|
||||
(newMcpScope === 'folder' && !selectedFolder) ||
|
||||
(newMcpScope === 'custom' &&
|
||||
selectedScripts.length === 0 &&
|
||||
selectedFlows.length === 0 &&
|
||||
selectedEndpoints.length === 0))}
|
||||
variant="accent"
|
||||
>
|
||||
New token
|
||||
|
||||
Reference in New Issue
Block a user