diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 604db2dc3e..9a323deef6 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -64,9 +64,10 @@ impl McpBackend for WindmillBackend { auth: &ApiAuthed, workspace_id: &str, favorites_only: bool, + path_prefix: Option<&str>, ) -> BackendResult> { let scope_type = if favorites_only { "favorites" } else { "all" }; - get_items::(&self.user_db, auth, workspace_id, scope_type, "script") + get_items::(&self.user_db, auth, workspace_id, scope_type, "script", path_prefix) .await .map_err(|e| ErrorData::internal_error(e.message, None)) } @@ -76,9 +77,10 @@ impl McpBackend for WindmillBackend { auth: &ApiAuthed, workspace_id: &str, favorites_only: bool, + path_prefix: Option<&str>, ) -> BackendResult> { let scope_type = if favorites_only { "favorites" } else { "all" }; - get_items::(&self.user_db, auth, workspace_id, scope_type, "flow") + get_items::(&self.user_db, auth, workspace_id, scope_type, "flow", path_prefix) .await .map_err(|e| ErrorData::internal_error(e.message, None)) } diff --git a/backend/windmill-api/src/mcp/utils.rs b/backend/windmill-api/src/mcp/utils.rs index d168f8fc48..1cfbfd7085 100644 --- a/backend/windmill-api/src/mcp/utils.rs +++ b/backend/windmill-api/src/mcp/utils.rs @@ -136,6 +136,7 @@ pub async fn get_items sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen workspace_id: &str, scope_type: &str, item_type: &str, + path_prefix: Option<&str>, ) -> Result, ErrorData> { let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type)); let fields = vec!["o.path", "o.summary", "o.description", "o.schema"]; @@ -153,6 +154,11 @@ pub async fn get_items sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen sqlb.and_where("(o.no_main_func IS NOT TRUE OR o.no_main_func IS NULL)"); } + if let Some(prefix) = path_prefix { + let escaped = prefix.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); + sqlb.and_where("o.path LIKE ? ESCAPE '\\'".bind(&format!("{}%", escaped))); + } + sqlb.order_by( if item_type == "flow" { "o.edited_at" diff --git a/backend/windmill-mcp/src/common/mod.rs b/backend/windmill-mcp/src/common/mod.rs index d67c1718cc..60ad18a85c 100644 --- a/backend/windmill-mcp/src/common/mod.rs +++ b/backend/windmill-mcp/src/common/mod.rs @@ -11,6 +11,8 @@ pub mod types; pub use schema::convert_schema_to_schema_type; pub use scope::{is_resource_allowed, parse_mcp_scopes, McpScopeConfig}; pub use transform::{ - apply_key_transformation, reverse_transform, reverse_transform_key, transform_path, + apply_key_transformation, extract_hub_version_id_from_hashed, + extract_path_prefix_from_hashed, parse_tool_prefix, reverse_transform, reverse_transform_key, + transform_hub_path, transform_path, }; pub use types::*; diff --git a/backend/windmill-mcp/src/common/transform.rs b/backend/windmill-mcp/src/common/transform.rs index 9a6fa317b1..0a12c85892 100644 --- a/backend/windmill-mcp/src/common/transform.rs +++ b/backend/windmill-mcp/src/common/transform.rs @@ -4,9 +4,15 @@ //! to make them compatible with MCP tool naming requirements. use super::types::SchemaType; +use windmill_common::utils::calculate_hash; -/// MCP clients do not allow names longer than 60 characters -const MAX_PATH_LENGTH: usize = 60; +/// Max tool name length. The MCP spec allows 64 chars, but some clients +/// (e.g. Cursor) prepend the server name to the tool name, so we use 40 +/// to leave room for that prefix. +const MAX_PATH_LENGTH: usize = 40; + +/// Length of the SHA256 hash suffix used for hashed names +const HASH_LEN: usize = 16; /// Transform the path for workspace scripts/flows /// @@ -14,19 +20,133 @@ const MAX_PATH_LENGTH: usize = 60; /// path with the type prefix. This is used when listing, because we can't /// have names with slashes. Because we replace slashes with underscores, /// we also need to escape underscores. +/// +/// For short names (≤40 chars): `s-{escaped_path}` or `f-{escaped_path}` +/// For long names (>40 chars): `S-{escaped[:22]}{sha256[:16]}` or `F-{escaped[:22]}{sha256[:16]}` +/// +/// The uppercase prefix signals that the name is hashed. pub fn transform_path(path: &str, type_str: &str) -> String { let escaped_path = path.replace('_', "__").replace('/', "_"); - // first letter of type_str is used as prefix, only one letter to avoid reaching 60 char name limit - let transformed_path = format!("{}-{}", &type_str[..1], escaped_path); - if transformed_path.len() > MAX_PATH_LENGTH { - let suffix = "_TRUNC"; - return format!( - "{}{}", - &transformed_path[..MAX_PATH_LENGTH - suffix.len()], - suffix - ); + let prefix_char = &type_str[..1]; + let short_name = format!("{}-{}", prefix_char, escaped_path); + + if short_name.len() <= MAX_PATH_LENGTH { + return short_name; } - transformed_path + + let upper_prefix = prefix_char.to_uppercase(); + // Layout: "{Upper}-" (2 chars) + prefix_body (22 chars) + hash (16 chars) = 40 + let prefix_body_len = MAX_PATH_LENGTH - 2 - HASH_LEN; + let hash = calculate_hash(&short_name); + let hash_suffix = &hash[..HASH_LEN]; + let truncated = truncate_to_char_boundary(&escaped_path, prefix_body_len); + format!("{}-{}{}", upper_prefix, truncated, hash_suffix) +} + +/// Transform the path for hub scripts +/// +/// For short names (≤40 chars): `hs-{id}-{summary}` +/// For long names (>40 chars): `Hs-{id}-{summary[:N]}{sha256[:16]}` +pub fn transform_hub_path(version_id: u64, summary: &str) -> String { + let escaped_summary = summary.replace(' ', "_"); + let short_name = format!("hs-{}-{}", version_id, escaped_summary); + + if short_name.len() <= MAX_PATH_LENGTH { + return short_name; + } + + let hash = calculate_hash(&short_name); + let hash_suffix = &hash[..HASH_LEN]; + // "Hs-{id}-" prefix, then fill remaining with summary + hash + let fixed_prefix = format!("Hs-{}-", version_id); + let available = MAX_PATH_LENGTH - fixed_prefix.len() - HASH_LEN; + let truncated_summary = truncate_to_char_boundary(&escaped_summary, available); + format!("{}{}{}", fixed_prefix, truncated_summary, hash_suffix) +} + +/// Parse the prefix of any tool name (both short and hashed). +/// Returns `(type_str, is_hub, is_hashed)`. +/// Hashed names use an uppercase first character as the signal. +pub fn parse_tool_prefix(name: &str) -> Result<(&str, bool, bool), String> { + let is_hashed = name.chars().next().map(|c| c.is_ascii_uppercase()).unwrap_or(false); + let lower = name.to_ascii_lowercase(); + let (type_str, is_hub) = if lower.starts_with("hs-") { + ("script", true) + } else if lower.starts_with("s-") { + ("script", false) + } else if lower.starts_with("f-") { + ("flow", false) + } else { + return Err(format!("Invalid tool name prefix: {}", name)); + }; + Ok((type_str, is_hub, is_hashed)) +} + +/// Extract the hub version_id from a hashed hub script name like `Hs-{id}-...` +pub fn extract_hub_version_id_from_hashed(name: &str) -> Result { + let rest = name + .strip_prefix("Hs-") + .ok_or_else(|| format!("Not a hashed hub name: {}", name))?; + let id = rest + .split('-') + .next() + .ok_or_else(|| format!("No version_id in hashed hub name: {}", name))?; + if id.is_empty() { + return Err(format!("Empty version_id in hashed hub name: {}", name)); + } + Ok(id.to_string()) +} + +/// Extract a safe original-path prefix from a hashed tool name. +/// +/// Given `S-u_admin_engineering__te`, extracts the escaped prefix between +/// the type prefix (`S-`, `F-`, or `Hs-`) and the hash, un-escapes it, and +/// returns a prefix suitable for `WHERE path LIKE '{prefix}%'`. +/// +/// Returns `None` if the name is too short or has an unrecognized prefix. +pub fn extract_path_prefix_from_hashed(name: &str) -> Option { + let prefix_len = if name.starts_with("Hs-") { + 3 + } else if name.starts_with("S-") || name.starts_with("F-") { + 2 + } else { + return None; + }; + if name.len() <= prefix_len + HASH_LEN { + return None; + } + let escaped_prefix = &name[prefix_len..name.len() - HASH_LEN]; + if escaped_prefix.is_empty() { + return None; + } + + // Strip trailing underscores — they may be half of a `__` pair split by truncation + let trimmed = escaped_prefix.trim_end_matches('_'); + if trimmed.is_empty() { + return None; + } + + Some(unescape_path(trimmed)) +} + +/// Un-escape a mangled path segment: `__` → `_`, standalone `_` → `/`. +fn unescape_path(s: &str) -> String { + const TEMP_PLACEHOLDER: &str = "@@UNDERSCORE@@"; + s.replace("__", TEMP_PLACEHOLDER) + .replace('_', "/") + .replace(TEMP_PLACEHOLDER, "_") +} + +/// Truncate a string to at most `max_len` bytes, ensuring we don't split a UTF-8 character. +fn truncate_to_char_boundary(s: &str, max_len: usize) -> &str { + if s.len() <= max_len { + return s; + } + let mut end = max_len; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] } /// Reverse the transformation of a path @@ -38,25 +158,22 @@ pub fn transform_path(path: &str, type_str: &str) -> String { /// This is used in call_tool to get the original path, and the type of the item. /// /// Returns: (type, original_path, is_hub) +/// +/// Note: This only works for non-hashed (short) names. Hashed names must be +/// resolved via `parse_tool_prefix` + path enumeration in the runner. pub fn reverse_transform(transformed_path: &str) -> Result<(&str, String, bool), String> { - let is_hub = transformed_path.starts_with("h"); - let transformed_path = if is_hub { - transformed_path[1..].to_string() - } else { - transformed_path.to_string() - }; - let type_str = if transformed_path.starts_with("s-") { - "script" - } else if transformed_path.starts_with("f-") { - "flow" - } else { - return Err(format!( - "Invalid prefix in transformed path: {}", - transformed_path - )); - }; + let (type_str, is_hub, is_hashed) = parse_tool_prefix(transformed_path)?; - let mangled_path = &transformed_path[2..]; + if is_hashed { + return Err( + "Hashed names cannot be reverse-transformed directly; use path enumeration instead" + .to_string(), + ); + } + + // Strip the prefix: "hs-" (3 chars) for hub, "s-"/"f-" (2 chars) for others + let prefix_len = if is_hub { 3 } else { 2 }; + let mangled_path = &transformed_path[prefix_len..]; let original_path = if is_hub { let parts = mangled_path.split("-").collect::>(); @@ -65,11 +182,7 @@ pub fn reverse_transform(transformed_path: &str) -> Result<(&str, String, bool), } parts[0].to_string() } else { - const TEMP_PLACEHOLDER: &str = "@@UNDERSCORE@@"; - mangled_path - .replace("__", TEMP_PLACEHOLDER) - .replace('_', "/") - .replace(TEMP_PLACEHOLDER, "_") + unescape_path(mangled_path) }; Ok((type_str, original_path, is_hub)) @@ -97,16 +210,13 @@ pub fn reverse_transform_key(transformed_key: &str, schema_obj: &Option s, None => { - // No schema available, return the key as is (best guess) return transformed_key.to_string(); } }; for original_key_in_schema in schema_obj.properties.keys() { - // Apply the SAME forward transformation to the schema key let potential_transformed_key = apply_key_transformation(original_key_in_schema); - // If it matches the key we received, we found the likely original if potential_transformed_key == transformed_key { return original_key_in_schema.clone(); } @@ -120,7 +230,7 @@ mod tests { use super::*; #[test] - fn test_transform_path() { + fn test_transform_path_short() { assert_eq!( transform_path("u/admin/script", "script"), "s-u_admin_script" @@ -130,7 +240,108 @@ mod tests { } #[test] - fn test_reverse_transform() { + fn test_transform_path_long_is_hashed() { + let long_path = "u/engineering/team/automation/very_long_script_name_that_exceeds_limit"; + let result = transform_path(long_path, "script"); + assert_eq!(result.len(), MAX_PATH_LENGTH); + assert!(result.starts_with("S-")); + let (_, _, is_hashed) = parse_tool_prefix(&result).unwrap(); + assert!(is_hashed); + } + + #[test] + fn test_transform_path_long_flow_is_hashed() { + let long_path = "f/engineering/team/automation/very_long_flow_name_that_exceeds_limit"; + let result = transform_path(long_path, "flow"); + assert_eq!(result.len(), MAX_PATH_LENGTH); + assert!(result.starts_with("F-")); + let (_, _, is_hashed) = parse_tool_prefix(&result).unwrap(); + assert!(is_hashed); + } + + #[test] + fn test_transform_path_hashing_is_deterministic() { + let path = "u/engineering/team/automation/very_long_script_name_that_exceeds_limit"; + let a = transform_path(path, "script"); + let b = transform_path(path, "script"); + assert_eq!(a, b); + } + + #[test] + fn test_transform_path_different_long_paths_differ() { + let a = transform_path( + "u/engineering/team/automation/very_long_script_name_that_exceeds_limit_a", + "script", + ); + let b = transform_path( + "u/engineering/team/automation/very_long_script_name_that_exceeds_limit_b", + "script", + ); + assert_ne!(a, b); + } + + #[test] + fn test_transform_hub_path_short() { + let result = transform_hub_path(12345, "Send Slack Message"); + assert_eq!(result, "hs-12345-Send_Slack_Message"); + let (_, _, is_hashed) = parse_tool_prefix(&result).unwrap(); + assert!(!is_hashed); + } + + #[test] + fn test_transform_hub_path_long_is_hashed() { + let result = transform_hub_path( + 12345, + "Send Slack Message To Channel With Very Long Description That Exceeds Limit", + ); + assert_eq!(result.len(), MAX_PATH_LENGTH); + assert!(result.starts_with("Hs-12345-")); + let (_, _, is_hashed) = parse_tool_prefix(&result).unwrap(); + assert!(is_hashed); + } + + #[test] + fn test_extract_hub_version_id_from_hashed() { + let name = "Hs-12345-Send_Slack_Message_To_Ch9e8d7c6b5a4f3e2d"; + let id = extract_hub_version_id_from_hashed(name).unwrap(); + assert_eq!(id, "12345"); + } + + #[test] + fn test_parse_tool_prefix() { + let (t, hub, hashed) = parse_tool_prefix("S-something").unwrap(); + assert_eq!(t, "script"); + assert!(!hub); + assert!(hashed); + + let (t, hub, hashed) = parse_tool_prefix("F-something").unwrap(); + assert_eq!(t, "flow"); + assert!(!hub); + assert!(hashed); + + let (t, hub, hashed) = parse_tool_prefix("Hs-12345-something").unwrap(); + assert_eq!(t, "script"); + assert!(hub); + assert!(hashed); + + let (t, hub, hashed) = parse_tool_prefix("s-u_admin_script").unwrap(); + assert_eq!(t, "script"); + assert!(!hub); + assert!(!hashed); + + let (t, hub, hashed) = parse_tool_prefix("f-f_folder_flow").unwrap(); + assert_eq!(t, "flow"); + assert!(!hub); + assert!(!hashed); + + let (t, hub, hashed) = parse_tool_prefix("hs-12345-summary").unwrap(); + assert_eq!(t, "script"); + assert!(hub); + assert!(!hashed); + } + + #[test] + fn test_reverse_transform_short_names() { let (type_str, path, is_hub) = reverse_transform("s-u_admin_script").unwrap(); assert_eq!(type_str, "script"); assert_eq!(path, "u/admin/script"); @@ -142,6 +353,70 @@ mod tests { assert!(!is_hub); } + #[test] + fn test_extract_path_prefix_from_hashed() { + // Generate a real hashed name and verify prefix extraction + let long_path = "u/admin/engineering/team/automation/very_long_script"; + let hashed = transform_path(long_path, "script"); + let (_, _, is_hashed) = parse_tool_prefix(&hashed).unwrap(); + assert!(is_hashed); + + let prefix = extract_path_prefix_from_hashed(&hashed).unwrap(); + // The original path should start with the extracted prefix + assert!( + long_path.starts_with(&prefix), + "path '{}' should start with prefix '{}'", + long_path, + prefix + ); + } + + #[test] + fn test_extract_path_prefix_underscore_in_path() { + let long_path = "u/admin/my_team/automation/very_long_script_name_here"; + let hashed = transform_path(long_path, "script"); + let prefix = extract_path_prefix_from_hashed(&hashed).unwrap(); + assert!( + long_path.starts_with(&prefix), + "path '{}' should start with prefix '{}'", + long_path, + prefix + ); + } + + #[test] + fn test_extract_path_prefix_rejects_invalid_prefix() { + assert!(extract_path_prefix_from_hashed("x-something").is_none()); + assert!(extract_path_prefix_from_hashed("").is_none()); + assert!(extract_path_prefix_from_hashed("S-").is_none()); + } + + #[test] + fn test_extract_path_prefix_handles_hs_prefix() { + // Hs- is 3 chars, not 2 — ensure the prefix is stripped correctly + let hashed = transform_hub_path(12345, "a]very long hub script summary that exceeds the limit"); + let (_, is_hub, is_hashed) = parse_tool_prefix(&hashed).unwrap(); + assert!(is_hub); + assert!(is_hashed); + + let prefix = extract_path_prefix_from_hashed(&hashed); + // Should not start with 's' (leftover from Hs- if sliced at index 2) + if let Some(ref p) = prefix { + assert!( + !p.starts_with('s'), + "prefix '{}' should not start with 's' from mis-sliced Hs- prefix", + p + ); + } + } + + #[test] + fn test_reverse_transform_rejects_hashed_names() { + assert!(reverse_transform("S-something").is_err()); + assert!(reverse_transform("F-something").is_err()); + assert!(reverse_transform("Hs-12345-something").is_err()); + } + #[test] fn test_apply_key_transformation() { assert_eq!(apply_key_transformation("my key"), "my_key"); diff --git a/backend/windmill-mcp/src/common/types.rs b/backend/windmill-mcp/src/common/types.rs index 43fe223ded..6161ca7963 100644 --- a/backend/windmill-mcp/src/common/types.rs +++ b/backend/windmill-mcp/src/common/types.rs @@ -92,8 +92,10 @@ pub struct ItemSchema { /// Trait for objects that can be converted to MCP tools pub trait ToolableItem { - /// Get the path or identifier for this item (transformed for MCP compatibility) - fn get_path_or_id(&self) -> String; + /// Get the MCP-compatible tool name (path transformed with escaping/hashing) + fn get_transformed_path(&self) -> String; + /// Get the original full path of this item (for display in tool title) + fn get_full_path(&self) -> &str; /// Get the summary/title of this item fn get_summary(&self) -> &str; /// Get the description of this item diff --git a/backend/windmill-mcp/src/lib.rs b/backend/windmill-mcp/src/lib.rs index a75b545154..7df6ee9f39 100644 --- a/backend/windmill-mcp/src/lib.rs +++ b/backend/windmill-mcp/src/lib.rs @@ -14,9 +14,9 @@ pub mod client; // Re-export common types at crate root for convenience pub use common::{ - convert_schema_to_schema_type, is_resource_allowed, parse_mcp_scopes, transform_path, FlowInfo, - HubResponse, HubScriptInfo, ItemSchema, McpScopeConfig, ResourceInfo, ResourceType, SchemaType, - ScriptInfo, ToolableItem, WorkspaceId, + convert_schema_to_schema_type, is_resource_allowed, parse_mcp_scopes, transform_hub_path, + transform_path, FlowInfo, HubResponse, HubScriptInfo, ItemSchema, McpScopeConfig, ResourceInfo, + ResourceType, SchemaType, ScriptInfo, ToolableItem, WorkspaceId, }; // Re-export client types at crate root for backward compatibility diff --git a/backend/windmill-mcp/src/server/backend.rs b/backend/windmill-mcp/src/server/backend.rs index fd01b2b26c..0b942353b3 100644 --- a/backend/windmill-mcp/src/server/backend.rs +++ b/backend/windmill-mcp/src/server/backend.rs @@ -55,20 +55,22 @@ pub trait McpBackend: Send + Sync + Clone + 'static { // Listing Operations // ───────────────────────────────────────────────────────────────── - /// List scripts, optionally filtered to favorites only + /// List scripts, optionally filtered to favorites only and/or by path prefix async fn list_scripts( &self, auth: &Self::Auth, workspace_id: &str, favorites_only: bool, + path_prefix: Option<&str>, ) -> BackendResult>; - /// List flows, optionally filtered to favorites only + /// List flows, optionally filtered to favorites only and/or by path prefix async fn list_flows( &self, auth: &Self::Auth, workspace_id: &str, favorites_only: bool, + path_prefix: Option<&str>, ) -> BackendResult>; /// List resource types in workspace diff --git a/backend/windmill-mcp/src/server/runner.rs b/backend/windmill-mcp/src/server/runner.rs index dd65c96334..be8764fa2e 100644 --- a/backend/windmill-mcp/src/server/runner.rs +++ b/backend/windmill-mcp/src/server/runner.rs @@ -5,7 +5,10 @@ use crate::common::schema::extract_resource_types_from_schema; use crate::common::scope::parse_mcp_scopes; -use crate::common::transform::{reverse_transform, reverse_transform_key}; +use crate::common::transform::{ + extract_hub_version_id_from_hashed, extract_path_prefix_from_hashed, parse_tool_prefix, + reverse_transform, reverse_transform_key, +}; use crate::common::types::{ResourceInfo, ToolableItem, WorkspaceId}; use crate::server::backend::{McpAuth, McpBackend}; use crate::server::endpoints::endpoint_tool_to_mcp_tool; @@ -81,6 +84,13 @@ impl Runner { } } +fn find_matching_path(candidates: Vec, request_name: &str) -> Option { + candidates + .into_iter() + .find(|item| item.get_transformed_path() == request_name) + .map(|item| item.get_full_path().to_string()) +} + impl ServerHandler for Runner { fn get_info(&self) -> ServerInfo { ServerInfo { @@ -120,9 +130,9 @@ impl ServerHandler for Runner { // Fetch all items concurrently let (scripts, flows, resource_types, hub_scripts) = tokio::try_join!( self.backend - .list_scripts(&auth, &workspace_id, favorites_only), + .list_scripts(&auth, &workspace_id, favorites_only, None), self.backend - .list_flows(&auth, &workspace_id, favorites_only), + .list_flows(&auth, &workspace_id, favorites_only, None), self.backend.list_resource_types(&auth, &workspace_id), async { if let Some(ref apps) = scope_config.hub_apps { @@ -231,17 +241,6 @@ impl ServerHandler for Runner { let scope_config = parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?; - // Handle truncated tool names - if request.name.ends_with("_TRUNC") { - return Ok(CallToolResult::error(vec![rmcp::model::Annotated::new( - rmcp::model::RawContent::Text(rmcp::model::RawTextContent { - text: "Tool path is too long. Consider shortening it to make it compatible with MCP.".to_string(), - meta: None, - }), - None, - )])); - } - let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); // Check if this is an endpoint tool @@ -274,10 +273,58 @@ impl ServerHandler for Runner { } } - // Not an endpoint tool - parse as script/flow - let (tool_type, path, is_hub) = reverse_transform(&request.name).map_err(|e| { - ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None) - })?; + // Resolve the tool name to (type, path, is_hub) + let (type_str, is_hub, is_hashed) = + parse_tool_prefix(&request.name).map_err(|e| { + ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None) + })?; + + let (tool_type, path, is_hub) = if !is_hashed { + reverse_transform(&request.name).map_err(|e| { + ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None) + })? + } else if is_hub { + let version_id = + extract_hub_version_id_from_hashed(&request.name).map_err(|e| { + ErrorData::internal_error( + format!("Failed to extract hub version_id: {}", e), + None, + ) + })?; + (type_str, version_id, true) + } else { + let path_prefix = extract_path_prefix_from_hashed(&request.name); + let favorites_only = scope_config.favorites; + let matched_path = if type_str == "script" { + find_matching_path( + self.backend + .list_scripts(&auth, &workspace_id, favorites_only, path_prefix.as_deref()) + .await + .map_err(|e| ErrorData::internal_error(e.message, None))?, + &request.name, + ) + } else { + find_matching_path( + self.backend + .list_flows(&auth, &workspace_id, favorites_only, path_prefix.as_deref()) + .await + .map_err(|e| ErrorData::internal_error(e.message, None))?, + &request.name, + ) + }; + + let matched_path = matched_path.ok_or_else(|| { + ErrorData::internal_error( + format!( + "No {} found matching hashed tool name '{}'", + type_str, request.name + ), + None, + ) + })?; + + (type_str, matched_path, false) + }; // Validate script/flow scope if !is_hub && scope_config.granular { diff --git a/backend/windmill-mcp/src/server/tools.rs b/backend/windmill-mcp/src/server/tools.rs index a9262cdd6f..50ed03426b 100644 --- a/backend/windmill-mcp/src/server/tools.rs +++ b/backend/windmill-mcp/src/server/tools.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use std::sync::Arc; use crate::common::schema::{convert_schema_to_schema_type, make_schema_compatible}; -use crate::common::transform::transform_path; +use crate::common::transform::{transform_hub_path, transform_path}; use crate::common::types::{ FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, ToolableItem, }; @@ -17,10 +17,14 @@ use crate::server::backend::McpBackend; /// Implementation of ToolableItem for ScriptInfo impl ToolableItem for ScriptInfo { - fn get_path_or_id(&self) -> String { + fn get_transformed_path(&self) -> String { transform_path(&self.path, "script") } + fn get_full_path(&self) -> &str { + &self.path + } + fn get_summary(&self) -> &str { self.summary.as_deref().unwrap_or("No summary") } @@ -48,10 +52,14 @@ impl ToolableItem for ScriptInfo { /// Implementation of ToolableItem for FlowInfo impl ToolableItem for FlowInfo { - fn get_path_or_id(&self) -> String { + fn get_transformed_path(&self) -> String { transform_path(&self.path, "flow") } + fn get_full_path(&self) -> &str { + &self.path + } + fn get_summary(&self) -> &str { self.summary.as_deref().unwrap_or("No summary") } @@ -79,10 +87,13 @@ impl ToolableItem for FlowInfo { /// Implementation of ToolableItem for HubScriptInfo impl ToolableItem for HubScriptInfo { - fn get_path_or_id(&self) -> String { - let id = self.version_id; + fn get_transformed_path(&self) -> String { let summary = self.summary.as_deref().unwrap_or("No summary"); - format!("hs-{}-{}", id, summary.replace(" ", "_")) + transform_hub_path(self.version_id, summary) + } + + fn get_full_path(&self) -> &str { + self.summary.as_deref().unwrap_or("No summary") } fn get_summary(&self) -> &str { @@ -124,7 +135,7 @@ pub fn create_tool_from_item( resources_types: &[ResourceType], ) -> Tool { let is_hub = item.is_hub(); - let path = item.get_path_or_id(); + let path = item.get_transformed_path(); let item_type = item.item_type(); let description = format!( "This is a {} named `{}` with the following description: `{}`.{}", @@ -170,15 +181,24 @@ pub fn create_tool_from_item( } }; + let title = { + let summary = item.get_summary(); + if summary == "No summary" { + item.get_full_path().to_string() + } else { + summary.to_string() + } + }; + Tool { name: Cow::Owned(path), description: Some(Cow::Owned(description)), input_schema: Arc::new(input_schema_map), - title: Some(item.get_summary().to_string()), + title: Some(title.clone()), output_schema: None, icons: None, annotations: Some(ToolAnnotations { - title: Some(item.get_summary().to_string()), + title: Some(title), read_only_hint: Some(false), // Can modify environment destructive_hint: Some(true), // Can potentially be destructive idempotent_hint: Some(false), // Are not guaranteed to be idempotent diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index c951cc8711..ccbf6b167d 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -17,7 +17,7 @@ import { safeSelectItems } from '../select/utils.svelte' import TokenDisplay from './TokenDisplay.svelte' import ScopeSelector from './ScopeSelector.svelte' - import Alert from '../common/alert/Alert.svelte' + import FolderPicker from '../FolderPicker.svelte' import TextInput from '../text_input/TextInput.svelte' import Select from '../select/Select.svelte' @@ -43,8 +43,6 @@ displayCreateToken = true }: Props = $props() - // MCP clients do not allow names longer than 60 characters, here we use 55 because final tool name server side will add ~5 characters - const MAX_PATH_LENGTH = 55 let newToken = $state(undefined) let newMcpToken = $state(undefined) @@ -185,17 +183,6 @@ ? `You do not have any favorite scripts or flows. You can favorite some scripts and flows to include them, or change the scope to "All scripts/flows" to include all your scripts and flows.` : `You do not have any scripts or flows in the selected folder.` ) - const longPathRunnables = $derived( - includedRunnables.filter((path) => path.length > MAX_PATH_LENGTH) - ) - const validRunnables = $derived( - includedRunnables.filter((path) => path.length <= MAX_PATH_LENGTH) - ) - const longPathWarning = $derived( - longPathRunnables.length > 0 - ? `${longPathRunnables.length} script(s)/flow(s) have paths longer than 60 characters and will be excluded from MCP tools. Consider shortening the paths: ${longPathRunnables.slice(0, 3).join(', ')}${longPathRunnables.length > 3 ? ` and ${longPathRunnables.length - 3} more` : ''}` - : '' - ) $effect(() => { if (mcpCreationMode) { @@ -627,23 +614,18 @@ {:else}
- {#if longPathWarning} - - {longPathWarning} - - {/if} Scripts & Flows that will be available via MCP
- {#if validRunnables.length > 0 && validRunnables.length <= 5} - {#each validRunnables as scriptOrFlow} + {#if includedRunnables.length > 0 && includedRunnables.length <= 5} + {#each includedRunnables as scriptOrFlow} {scriptOrFlow} {/each} - {:else if validRunnables.length > 0} - {#each validRunnables.slice(0, 3) as scriptOrFlow} + {:else if includedRunnables.length > 0} + {#each includedRunnables.slice(0, 3) as scriptOrFlow} {scriptOrFlow} {/each} - +{validRunnables.length - 3} more + +{includedRunnables.length - 3} more {:else}