feat: hash-based MCP tool names for long paths (#8133)

* feat: replace _TRUNC with hash-based MCP tool names (50 char limit)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: reduce MCP tool name limit from 50 to 40 chars

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: use path prefix filtering instead of separate DB query for hashed name resolution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove long path warning from MCP token creation (hashing handles long names)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: unify tool prefix parsing and fix extract_path_prefix_from_hashed for Hs- names

- Replace `is_hashed_name` + `parse_hashed_name` with unified `parse_tool_prefix`
  that returns `(type_str, is_hub, is_hashed)` in one call
- Fix `extract_path_prefix_from_hashed` to dynamically determine prefix length
  (3 for `Hs-`, 2 for `S-`/`F-`) instead of hardcoding index 2
- Simplify `reverse_transform` to reuse `parse_tool_prefix`
- Add tests for invalid prefixes and `Hs-` prefix handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: escape LIKE wildcards in MCP hashed name path prefix query

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: respect favorites scope in hashed tool name resolution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: deduplicate MCP tool name resolution and rename get_path_or_id

- Extract `unescape_path` helper in transform.rs to deduplicate the
  3-step placeholder unescape logic
- Extract `find_matching_path` helper in runner.rs to deduplicate
  script/flow candidate matching via ToolableItem trait
- Remove verbose tracing::info! logs from hashed tool resolution hot path
- Fix doc comment referencing nonexistent `is_hashed_name` function
- Rename `get_path_or_id` to `get_transformed_path` for clarity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update stale doc comments to reflect MAX_PATH_LENGTH=40

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-03-05 12:04:20 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 65082159d8
commit ce041e8a5e
10 changed files with 438 additions and 100 deletions
+4 -2
View File
@@ -64,9 +64,10 @@ impl McpBackend for WindmillBackend {
auth: &ApiAuthed,
workspace_id: &str,
favorites_only: bool,
path_prefix: Option<&str>,
) -> BackendResult<Vec<ScriptInfo>> {
let scope_type = if favorites_only { "favorites" } else { "all" };
get_items::<ScriptInfo>(&self.user_db, auth, workspace_id, scope_type, "script")
get_items::<ScriptInfo>(&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<Vec<FlowInfo>> {
let scope_type = if favorites_only { "favorites" } else { "all" };
get_items::<FlowInfo>(&self.user_db, auth, workspace_id, scope_type, "flow")
get_items::<FlowInfo>(&self.user_db, auth, workspace_id, scope_type, "flow", path_prefix)
.await
.map_err(|e| ErrorData::internal_error(e.message, None))
}
+6
View File
@@ -136,6 +136,7 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen
workspace_id: &str,
scope_type: &str,
item_type: &str,
path_prefix: 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"];
@@ -153,6 +154,11 @@ 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)");
}
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"
+3 -1
View File
@@ -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::*;
+314 -39
View File
@@ -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<String, String> {
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<hash16>`, 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<String> {
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::<Vec<&str>>();
@@ -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<SchemaTy
let schema_obj = match schema_obj {
Some(s) => 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");
+4 -2
View File
@@ -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
+3 -3
View File
@@ -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
+4 -2
View File
@@ -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<Vec<ScriptInfo>>;
/// 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<Vec<FlowInfo>>;
/// List resource types in workspace
+65 -18
View File
@@ -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<B: McpBackend> Runner<B> {
}
}
fn find_matching_path<T: ToolableItem>(candidates: Vec<T>, request_name: &str) -> Option<String> {
candidates
.into_iter()
.find(|item| item.get_transformed_path() == request_name)
.map(|item| item.get_full_path().to_string())
}
impl<B: McpBackend> ServerHandler for Runner<B> {
fn get_info(&self) -> ServerInfo {
ServerInfo {
@@ -120,9 +130,9 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
// 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<B: McpBackend> ServerHandler for Runner<B> {
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<B: McpBackend> ServerHandler for Runner<B> {
}
}
// 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 {
+29 -9
View File
@@ -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<T: ToolableItem, B: McpBackend>(
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<T: ToolableItem, B: McpBackend>(
}
};
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
@@ -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<string | undefined>(undefined)
let newMcpToken = $state<string | undefined>(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 @@
</div>
{:else}
<div class="flex flex-col gap-2 col-span-2 pr-4">
{#if longPathWarning}
<Alert type="warning" title="Some paths are too long" size="xs">
{longPathWarning}
</Alert>
{/if}
<span class="block text-xs">Scripts & Flows that will be available via MCP</span>
<div class="flex flex-wrap gap-1">
{#if validRunnables.length > 0 && validRunnables.length <= 5}
{#each validRunnables as scriptOrFlow}
{#if includedRunnables.length > 0 && includedRunnables.length <= 5}
{#each includedRunnables as scriptOrFlow}
<Badge rounded small color="blue">{scriptOrFlow}</Badge>
{/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}
<Badge rounded small color="blue">{scriptOrFlow}</Badge>
{/each}
<Badge rounded small color="dark-gray">
+{validRunnables.length - 3} more
+{includedRunnables.length - 3} more
</Badge>
{:else}
<p class="text-xs text-primary">