mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 16:02:23 +00:00
fix(mcp): push granular scope patterns into SQL so scoped scripts/flows aren't truncated (#10140)
* fix(mcp): push granular scope patterns into SQL so scoped scripts/flows aren't truncated MCP `list_tools` fetched scripts/flows capped at the 100 newest by `created_at` and only *then* filtered by the token's granular folder/custom scope in Rust. In a workspace with more than 100 scripts/flows, in-scope items outside that newest-100 window were truncated before the scope filter ran, so a folder- or custom-scoped token could see zero tools even though matching items existed. Push the scope patterns into the query via a new `PathFilter::Patterns` (mirroring `is_resource_allowed`: `*` disables filtering, exact paths match by equality, `x/*` matches the folder or its subtree, empty grants nothing) so the filter applies before the `ITEMS_FETCH_MAX_LIMIT` cap. The existing hashed-name resolution path keeps its prefix behavior via `PathFilter::Prefix`, and the Rust post-filter stays as defense in depth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(mcp): warn in scope selector when a scope exceeds the MCP tool cap The server exposes at most ITEMS_FETCH_MAX_LIMIT (100) scripts and 100 flows per token; a scope matching more silently drops the overflow, which bloats the assistant's context with a partial, arbitrary tool set. McpScopeSelector now computes how many scripts/flows the current scope would expose (per type, mirroring the backend's is_resource_allowed) and shows a warning Alert when either exceeds the cap, so the user can narrow the scope before generating the URL/token. An async sequence guard keeps rapid scope changes from applying stale counts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): address review — dedup count fetches, boundary-aware folder counting, fix copy Follow-up to the MCP scope-selector truncation warning: - Reuse a single per-type (scripts/flows) cache for both the preview list and the exposed count, instead of a second concurrent fetch of the same rows. - Count a folder scope against the `f/{folder}/*` subtree (via the same boundary-aware matcher), so a folder like `team` no longer over-counts a sibling like `team2` and falsely warns. - Custom-mode counts are derived synchronously from the already-loaded scripts/flows — no fetch. - Reword the warning to "most recent" (flows are ordered by edited_at, not created_at). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): anchor folder count fetch at the folder boundary Follow-up to review: the folder count fetched the unbounded prefix `f/{folder}` (backend `path LIKE 'f/{folder}%'`), so a prefix-sharing sibling like `f/team2` shared the page. With a page limit, enough newer sibling rows could fill the first page ahead of the target folder's older rows; the client-side boundary filter then dropped them all, wrongly suppressing the warning and emptying the preview. Fetch `f/{folder}/` instead so the backend prefix (`LIKE 'f/{folder}/%'`) is anchored at the folder boundary and never returns siblings. The client-side matcher stays as a backstop for folder names whose LIKE wildcards (`_`, `%`) can still let the backend prefix over-match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
co-authored by
Claude Opus 4.8
Ruben Fiszel
parent
c55ac5326f
commit
91d6606868
@@ -12,7 +12,7 @@ use windmill_mcp::common::transform::apply_key_transformation;
|
||||
use windmill_mcp::common::types::{
|
||||
FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, WorkspaceInfo,
|
||||
};
|
||||
use windmill_mcp::server::{BackendResult, EndpointTool, ErrorData, McpBackend};
|
||||
use windmill_mcp::server::{BackendResult, EndpointTool, ErrorData, McpBackend, PathFilter};
|
||||
|
||||
use crate::auth::AuthCache;
|
||||
use crate::db::ApiAuthed;
|
||||
@@ -78,7 +78,7 @@ impl McpBackend for WindmillBackend {
|
||||
auth: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
favorites_only: bool,
|
||||
path_prefix: Option<&str>,
|
||||
path_filter: Option<PathFilter<'_>>,
|
||||
) -> BackendResult<Vec<ScriptInfo>> {
|
||||
let scope_type = if favorites_only { "favorites" } else { "all" };
|
||||
get_items::<ScriptInfo>(
|
||||
@@ -87,7 +87,7 @@ impl McpBackend for WindmillBackend {
|
||||
workspace_id,
|
||||
scope_type,
|
||||
"script",
|
||||
path_prefix,
|
||||
path_filter,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))
|
||||
@@ -98,7 +98,7 @@ impl McpBackend for WindmillBackend {
|
||||
auth: &ApiAuthed,
|
||||
workspace_id: &str,
|
||||
favorites_only: bool,
|
||||
path_prefix: Option<&str>,
|
||||
path_filter: Option<PathFilter<'_>>,
|
||||
) -> BackendResult<Vec<FlowInfo>> {
|
||||
let scope_type = if favorites_only { "favorites" } else { "all" };
|
||||
get_items::<FlowInfo>(
|
||||
@@ -107,7 +107,7 @@ impl McpBackend for WindmillBackend {
|
||||
workspace_id,
|
||||
scope_type,
|
||||
"flow",
|
||||
path_prefix,
|
||||
path_filter,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))
|
||||
|
||||
@@ -15,7 +15,7 @@ use windmill_common::scripts::{get_full_hub_script_by_path, Schema};
|
||||
use windmill_common::utils::{query_elems_from_hub, StripPath};
|
||||
use windmill_common::worker::to_raw_value;
|
||||
use windmill_common::{DB, HUB_BASE_URL};
|
||||
use windmill_mcp::server::{BackendResult, ErrorData};
|
||||
use windmill_mcp::server::{BackendResult, ErrorData, PathFilter};
|
||||
use windmill_mcp::{HubResponse, HubScriptInfo, ItemSchema, ResourceInfo, ResourceType};
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
@@ -24,6 +24,43 @@ use crate::HTTP_CLIENT;
|
||||
// items max limit
|
||||
const ITEMS_FETCH_MAX_LIMIT: usize = 100;
|
||||
|
||||
/// Escape LIKE wildcards so a literal path is matched as a prefix, not a pattern.
|
||||
fn escape_like(s: &str) -> String {
|
||||
s.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
}
|
||||
|
||||
/// Build the SQL condition matching any MCP scope pattern, mirroring
|
||||
/// `is_resource_allowed`. Returns `None` when no filter should be applied (a `*`
|
||||
/// pattern grants everything); `Some("false")` when the list is empty (grants
|
||||
/// nothing); otherwise an OR of per-pattern `o.path` conditions.
|
||||
fn scope_patterns_condition(patterns: &[String]) -> Option<String> {
|
||||
if patterns.iter().any(|p| p == "*") {
|
||||
return None;
|
||||
}
|
||||
if patterns.is_empty() {
|
||||
return Some("false".to_string());
|
||||
}
|
||||
let conds: Vec<String> = patterns
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if let Some(prefix) = p.strip_suffix("/*") {
|
||||
// A subtree pattern matches the folder itself or anything under it.
|
||||
let subtree = format!("{}/%", escape_like(prefix));
|
||||
format!(
|
||||
"({} OR {})",
|
||||
"o.path = ?".bind(&prefix),
|
||||
"o.path LIKE ? ESCAPE '\\'".bind(&subtree),
|
||||
)
|
||||
} else {
|
||||
"o.path = ?".bind(p)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Some(format!("({})", conds.join(" OR ")))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Database utilities
|
||||
// ============================================================================
|
||||
@@ -135,7 +172,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>,
|
||||
path_filter: Option<PathFilter<'_>>,
|
||||
) -> 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"];
|
||||
@@ -155,12 +192,17 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen
|
||||
sqlb.and_where("(o.auto_kind IS NULL OR o.auto_kind <> 'lib')");
|
||||
}
|
||||
|
||||
if let Some(prefix) = path_prefix {
|
||||
let escaped = prefix
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_");
|
||||
sqlb.and_where("o.path LIKE ? ESCAPE '\\'".bind(&format!("{}%", escaped)));
|
||||
match path_filter {
|
||||
None => {}
|
||||
Some(PathFilter::Prefix(prefix)) => {
|
||||
let escaped = format!("{}%", escape_like(prefix));
|
||||
sqlb.and_where("o.path LIKE ? ESCAPE '\\'".bind(&escaped));
|
||||
}
|
||||
Some(PathFilter::Patterns(patterns)) => {
|
||||
if let Some(cond) = scope_patterns_condition(patterns) {
|
||||
sqlb.and_where(cond);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sqlb.order_by(
|
||||
@@ -781,4 +823,60 @@ mod tests {
|
||||
"?path=u%2Falice%2Fmy%20script"
|
||||
);
|
||||
}
|
||||
|
||||
fn strings(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_patterns_condition_wildcard_disables_filter() {
|
||||
// A `*` pattern grants everything, so no SQL condition should be added.
|
||||
assert_eq!(scope_patterns_condition(&strings(&["*"])), None);
|
||||
assert_eq!(scope_patterns_condition(&strings(&["f/team/*", "*"])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_patterns_condition_empty_matches_nothing() {
|
||||
// An empty pattern list grants no items of this type.
|
||||
assert_eq!(scope_patterns_condition(&[]), Some("false".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_patterns_condition_exact_path() {
|
||||
assert_eq!(
|
||||
scope_patterns_condition(&strings(&["u/admin/my_script"])),
|
||||
Some("(o.path = 'u/admin/my_script')".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_patterns_condition_subtree() {
|
||||
// `f/team/*` matches the folder itself or anything beneath it, mirroring
|
||||
// resource_matches_pattern. Underscores in the prefix are LIKE-escaped.
|
||||
assert_eq!(
|
||||
scope_patterns_condition(&strings(&["f/team/*"])),
|
||||
Some("((o.path = 'f/team' OR o.path LIKE 'f/team/%' ESCAPE '\\'))".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_patterns_condition_mixed_ored() {
|
||||
assert_eq!(
|
||||
scope_patterns_condition(&strings(&["u/admin/one", "f/team/*"])),
|
||||
Some(
|
||||
"(o.path = 'u/admin/one' OR (o.path = 'f/team' OR o.path LIKE 'f/team/%' ESCAPE '\\'))"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_patterns_condition_escapes_like_wildcards() {
|
||||
// A subtree prefix containing `%`/`_` must be escaped so it isn't treated
|
||||
// as a LIKE pattern; the exact-match arm is quoted verbatim by bind.
|
||||
assert_eq!(
|
||||
scope_patterns_condition(&strings(&["f/a_b/*"])),
|
||||
Some("((o.path = 'f/a_b' OR o.path LIKE 'f/a\\_b/%' ESCAPE '\\'))".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,24 @@ use crate::server::endpoints::EndpointTool;
|
||||
/// Result type for backend operations using rmcp's ErrorData directly
|
||||
pub type BackendResult<T> = Result<T, ErrorData>;
|
||||
|
||||
/// How a script/flow listing is narrowed by path at the SQL layer, *before* the
|
||||
/// `ITEMS_FETCH_MAX_LIMIT` cap applies.
|
||||
///
|
||||
/// This must be pushed into the query, not applied in Rust after the fetch: the
|
||||
/// listing is capped to the newest N rows, so a granular token whose in-scope
|
||||
/// items are not among those N would have them truncated away before any Rust
|
||||
/// filter ran (returning zero tools even though the items exist).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PathFilter<'a> {
|
||||
/// Raw `LIKE '{prefix}%'` prefix — used to resolve a hashed tool name back to
|
||||
/// its full path.
|
||||
Prefix(&'a str),
|
||||
/// MCP scope patterns (`*`, an exact path, or an `x/*` subtree). Mirrors
|
||||
/// `is_resource_allowed`: a `*` pattern matches everything, an empty list
|
||||
/// matches nothing.
|
||||
Patterns(&'a [String]),
|
||||
}
|
||||
|
||||
/// Authentication context required by the MCP server
|
||||
pub trait McpAuth: Send + Sync + Clone + 'static {
|
||||
/// Get the username
|
||||
@@ -62,22 +80,22 @@ pub trait McpBackend: Send + Sync + Clone + 'static {
|
||||
// Listing Operations
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// List scripts, optionally filtered to favorites only and/or by path prefix
|
||||
/// List scripts, optionally filtered to favorites only and/or by path
|
||||
async fn list_scripts(
|
||||
&self,
|
||||
auth: &Self::Auth,
|
||||
workspace_id: &str,
|
||||
favorites_only: bool,
|
||||
path_prefix: Option<&str>,
|
||||
path_filter: Option<PathFilter<'_>>,
|
||||
) -> BackendResult<Vec<ScriptInfo>>;
|
||||
|
||||
/// List flows, optionally filtered to favorites only and/or by path prefix
|
||||
/// List flows, optionally filtered to favorites only and/or by path
|
||||
async fn list_flows(
|
||||
&self,
|
||||
auth: &Self::Auth,
|
||||
workspace_id: &str,
|
||||
favorites_only: bool,
|
||||
path_prefix: Option<&str>,
|
||||
path_filter: Option<PathFilter<'_>>,
|
||||
) -> BackendResult<Vec<FlowInfo>>;
|
||||
|
||||
/// List resource types in workspace
|
||||
|
||||
@@ -12,7 +12,7 @@ pub mod tools;
|
||||
|
||||
// Re-export main types
|
||||
pub use crate::common::types::{McpToken, MultiWorkspaceMcp, WorkspaceInfo};
|
||||
pub use backend::{BackendResult, McpAuth, McpBackend};
|
||||
pub use backend::{BackendResult, McpAuth, McpBackend, PathFilter};
|
||||
pub use endpoints::{
|
||||
endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, is_endpoint_read_only,
|
||||
list_workspaces_tool, EndpointTool,
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::common::transform::{
|
||||
reverse_transform, reverse_transform_key,
|
||||
};
|
||||
use crate::common::types::{McpToken, MultiWorkspaceMcp, ResourceInfo, ToolableItem, WorkspaceId};
|
||||
use crate::server::backend::{McpAuth, McpBackend};
|
||||
use crate::server::backend::{McpAuth, McpBackend, PathFilter};
|
||||
use crate::server::endpoints::{
|
||||
endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, list_workspaces_tool,
|
||||
};
|
||||
@@ -273,11 +273,23 @@ impl<B: McpBackend> Runner<B> {
|
||||
// mutating action), so skip the script/flow/hub/resource fetches
|
||||
// entirely — they would only be discarded below.
|
||||
if !read_only {
|
||||
// For granular tokens, push the scope patterns into the SQL query so
|
||||
// in-scope items survive the fetch cap (see `PathFilter`). The Rust
|
||||
// filter below still runs as a defense-in-depth check. Non-granular
|
||||
// (favorites/all) tokens are already narrowed by the favorites join
|
||||
// or intentionally unfiltered.
|
||||
let script_filter = scope_config
|
||||
.granular
|
||||
.then(|| PathFilter::Patterns(scope_config.scripts.as_slice()));
|
||||
let flow_filter = scope_config
|
||||
.granular
|
||||
.then(|| PathFilter::Patterns(scope_config.flows.as_slice()));
|
||||
|
||||
let (scripts, flows, resource_types, hub_scripts) = tokio::try_join!(
|
||||
self.backend
|
||||
.list_scripts(auth, workspace_id, favorites_only, None),
|
||||
.list_scripts(auth, workspace_id, favorites_only, script_filter),
|
||||
self.backend
|
||||
.list_flows(auth, workspace_id, favorites_only, None),
|
||||
.list_flows(auth, workspace_id, favorites_only, flow_filter),
|
||||
self.backend.list_resource_types(auth, workspace_id),
|
||||
async {
|
||||
if let Some(ref apps) = scope_config.hub_apps {
|
||||
@@ -456,11 +468,12 @@ impl<B: McpBackend> Runner<B> {
|
||||
(type_str, version_id, true)
|
||||
} else {
|
||||
let path_prefix = extract_path_prefix_from_hashed(name.as_ref());
|
||||
let path_filter = path_prefix.as_deref().map(PathFilter::Prefix);
|
||||
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())
|
||||
.list_scripts(auth, workspace_id, favorites_only, path_filter)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?,
|
||||
name.as_ref(),
|
||||
@@ -468,7 +481,7 @@ impl<B: McpBackend> Runner<B> {
|
||||
} else {
|
||||
find_matching_path(
|
||||
self.backend
|
||||
.list_flows(auth, workspace_id, favorites_only, path_prefix.as_deref())
|
||||
.list_flows(auth, workspace_id, favorites_only, path_filter)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?,
|
||||
name.as_ref(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Badge, Button } from '$lib/components/common'
|
||||
import { Alert, Badge, Button } from '$lib/components/common'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
@@ -20,6 +20,12 @@
|
||||
|
||||
let { workspaceId, scope = $bindable(), initialScope, readOnly = false }: Props = $props()
|
||||
|
||||
// Mirrors ITEMS_FETCH_MAX_LIMIT in backend/windmill-api/src/mcp/utils.rs: the
|
||||
// server exposes at most this many scripts and this many flows (per type) as
|
||||
// MCP tools, taking the most recently created. A scope matching more than this
|
||||
// silently drops the overflow, so we warn at configuration time.
|
||||
const MCP_TOOL_FETCH_LIMIT = 100
|
||||
|
||||
// Endpoints we can actually advertise to a read-only MCP token. Mirrors the
|
||||
// runner's filter (only GET endpoints).
|
||||
const visibleEndpointTools = $derived(
|
||||
@@ -59,7 +65,7 @@
|
||||
let loadingRunnables = $state(false)
|
||||
let includedRunnables = $state<string[]>([])
|
||||
|
||||
let runnablesCache = new SvelteMap<string, string[]>()
|
||||
let runnablesCache = new SvelteMap<string, { scripts: string[]; flows: string[] }>()
|
||||
|
||||
function parsePatterns(input: string): string[] {
|
||||
return input
|
||||
@@ -267,7 +273,11 @@
|
||||
folder: string | undefined
|
||||
) {
|
||||
if (!workspace) return []
|
||||
const pathStart = folder ? `f/${folder}` : undefined
|
||||
// Trailing slash so the backend prefix (`o.path LIKE 'f/{folder}/%'`) is
|
||||
// anchored at the folder boundary — otherwise a sibling like `f/team2`
|
||||
// shares the `f/team` prefix and its newer rows can fill the page ahead
|
||||
// of the target folder's rows.
|
||||
const pathStart = folder ? `f/${folder}/` : undefined
|
||||
const scripts = await ScriptService.listScripts({
|
||||
starredOnly: favoriteOnly,
|
||||
workspace,
|
||||
@@ -283,7 +293,11 @@
|
||||
folder: string | undefined
|
||||
) {
|
||||
if (!workspace) return []
|
||||
const pathStart = folder ? `f/${folder}` : undefined
|
||||
// Trailing slash so the backend prefix (`o.path LIKE 'f/{folder}/%'`) is
|
||||
// anchored at the folder boundary — otherwise a sibling like `f/team2`
|
||||
// shares the `f/team` prefix and its newer rows can fill the page ahead
|
||||
// of the target folder's rows.
|
||||
const pathStart = folder ? `f/${folder}/` : undefined
|
||||
const flows = await FlowService.listFlows({
|
||||
starredOnly: favoriteOnly,
|
||||
workspace,
|
||||
@@ -293,28 +307,31 @@
|
||||
return flows.map((x) => x.path)
|
||||
}
|
||||
|
||||
async function getScriptsAndFlows(
|
||||
favoriteOnly: boolean = false,
|
||||
// Fetch scripts+flows for a scope, cached and split by type so the same data
|
||||
// feeds both the preview list and the truncation count (no duplicate fetch).
|
||||
// The `f/{folder}/` fetch prefix already anchors at the folder boundary; the
|
||||
// extra filter only guards a folder name whose LIKE wildcards (`_`, `%`) let
|
||||
// the backend prefix over-match (e.g. `a_b` also matching `axb`).
|
||||
async function fetchScriptsAndFlows(
|
||||
workspace: string,
|
||||
favoriteOnly: boolean,
|
||||
folder: string | undefined
|
||||
) {
|
||||
): Promise<{ scripts: string[]; flows: string[] }> {
|
||||
const cacheKey = `${workspace}-${favoriteOnly}${folder ? `-${folder}` : ''}`
|
||||
if (runnablesCache.has(cacheKey)) {
|
||||
includedRunnables = runnablesCache.get(cacheKey) || []
|
||||
return
|
||||
}
|
||||
try {
|
||||
loadingRunnables = true
|
||||
const [scripts, flows] = await Promise.all([
|
||||
getScripts(favoriteOnly, workspace, folder),
|
||||
getFlows(favoriteOnly, workspace, folder)
|
||||
])
|
||||
const combined = [...scripts, ...flows]
|
||||
runnablesCache.set(cacheKey, combined)
|
||||
includedRunnables = combined
|
||||
} finally {
|
||||
loadingRunnables = false
|
||||
const cached = runnablesCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
let [scripts, flows] = await Promise.all([
|
||||
getScripts(favoriteOnly, workspace, folder),
|
||||
getFlows(favoriteOnly, workspace, folder)
|
||||
])
|
||||
if (folder) {
|
||||
const pattern = [`f/${folder}/*`]
|
||||
scripts = scripts.filter((p) => matchesAnyPattern(p, pattern))
|
||||
flows = flows.filter((p) => matchesAnyPattern(p, pattern))
|
||||
}
|
||||
const result = { scripts, flows }
|
||||
runnablesCache.set(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
async function loadAllScriptsAndFlows(workspace: string) {
|
||||
@@ -331,45 +348,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Load runnables based on mode
|
||||
$effect(() => {
|
||||
if (workspaceId) {
|
||||
if (selectedMode === 'folder') {
|
||||
if (selectedFolders.length > 0) {
|
||||
loadRunnablesForFolders(workspaceId, selectedFolders)
|
||||
} else {
|
||||
includedRunnables = []
|
||||
}
|
||||
} else {
|
||||
getScriptsAndFlows(selectedMode === 'favorites', workspaceId, undefined)
|
||||
}
|
||||
// Load the preview list AND the exposed per-type counts for non-custom modes
|
||||
// from a single (cached) fetch — no duplicate requests. A sequence guard drops
|
||||
// stale async results when the scope changes faster than the fetches resolve.
|
||||
let runnablesSeq = 0
|
||||
async function loadRunnablesAndCounts() {
|
||||
const seq = ++runnablesSeq
|
||||
const ws = workspaceId
|
||||
if (!ws || selectedMode === 'custom') return
|
||||
if (selectedMode === 'folder' && selectedFolders.length === 0) {
|
||||
includedRunnables = []
|
||||
exposedScriptCount = 0
|
||||
exposedFlowCount = 0
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
async function getCachedRunnables(workspace: string, folder: string): Promise<string[]> {
|
||||
const cacheKey = `${workspace}-false-${folder}`
|
||||
if (runnablesCache.has(cacheKey)) {
|
||||
return runnablesCache.get(cacheKey) || []
|
||||
}
|
||||
const [scripts, flows] = await Promise.all([
|
||||
getScripts(false, workspace, folder),
|
||||
getFlows(false, workspace, folder)
|
||||
])
|
||||
const combined = [...scripts, ...flows]
|
||||
runnablesCache.set(cacheKey, combined)
|
||||
return combined
|
||||
}
|
||||
|
||||
async function loadRunnablesForFolders(workspace: string, folders: string[]) {
|
||||
loadingRunnables = true
|
||||
try {
|
||||
loadingRunnables = true
|
||||
const results = await Promise.all(folders.map((f) => getCachedRunnables(workspace, f)))
|
||||
includedRunnables = [...new Set(results.flat())]
|
||||
let scripts: string[]
|
||||
let flows: string[]
|
||||
if (selectedMode === 'folder') {
|
||||
const perFolder = await Promise.all(
|
||||
selectedFolders.map((f) => fetchScriptsAndFlows(ws, false, f))
|
||||
)
|
||||
scripts = [...new Set(perFolder.flatMap((r) => r.scripts))]
|
||||
flows = [...new Set(perFolder.flatMap((r) => r.flows))]
|
||||
} else {
|
||||
const r = await fetchScriptsAndFlows(ws, selectedMode === 'favorites', undefined)
|
||||
scripts = r.scripts
|
||||
flows = r.flows
|
||||
}
|
||||
if (seq !== runnablesSeq) return
|
||||
includedRunnables = [...scripts, ...flows]
|
||||
exposedScriptCount = scripts.length
|
||||
exposedFlowCount = flows.length
|
||||
} finally {
|
||||
loadingRunnables = false
|
||||
if (seq === runnablesSeq) loadingRunnables = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// React to scope inputs (non-custom modes load list + counts together).
|
||||
selectedMode
|
||||
selectedFolders
|
||||
workspaceId
|
||||
loadRunnablesAndCounts()
|
||||
})
|
||||
|
||||
// Load all scripts/flows for custom mode
|
||||
$effect(() => {
|
||||
if (selectedMode === 'custom' && workspaceId) {
|
||||
@@ -415,6 +439,52 @@
|
||||
: `You do not have any scripts or flows in the selected folder(s).`
|
||||
)
|
||||
|
||||
// How many scripts / flows the current scope would expose, per type, so we can
|
||||
// warn when it exceeds MCP_TOOL_FETCH_LIMIT (the server caps each type).
|
||||
let exposedScriptCount = $state<number | undefined>(undefined)
|
||||
let exposedFlowCount = $state<number | undefined>(undefined)
|
||||
|
||||
// Mirror the backend's is_resource_allowed: `*`, an exact path, or an `x/*`
|
||||
// subtree (matching the folder itself or anything beneath it).
|
||||
function matchesAnyPattern(path: string, patterns: string[]): boolean {
|
||||
for (const p of patterns) {
|
||||
if (p === '*' || p === path) return true
|
||||
if (p.endsWith('/*')) {
|
||||
const prefix = p.slice(0, -2)
|
||||
if (path === prefix || (path.startsWith(prefix) && path[prefix.length] === '/')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Custom-mode counts are computed synchronously from the already-loaded
|
||||
// allScripts/allFlows plus the current selections/patterns — no extra fetch.
|
||||
$effect(() => {
|
||||
if (selectedMode !== 'custom') return
|
||||
const scriptPatterns = parsePatterns(customScriptPatterns)
|
||||
const flowPatterns = parsePatterns(customFlowPatterns)
|
||||
const scriptMatches = scriptPatterns.length
|
||||
? allScripts.filter((p) => matchesAnyPattern(p, scriptPatterns))
|
||||
: []
|
||||
const flowMatches = flowPatterns.length
|
||||
? allFlows.filter((p) => matchesAnyPattern(p, flowPatterns))
|
||||
: []
|
||||
exposedScriptCount = new Set([...selectedScripts, ...scriptMatches]).size
|
||||
exposedFlowCount = new Set([...selectedFlows, ...flowMatches]).size
|
||||
})
|
||||
|
||||
const truncationWarning = $derived.by(() => {
|
||||
if (readOnly) return undefined
|
||||
const parts: string[] = []
|
||||
if ((exposedScriptCount ?? 0) > MCP_TOOL_FETCH_LIMIT)
|
||||
parts.push(`${exposedScriptCount} scripts`)
|
||||
if ((exposedFlowCount ?? 0) > MCP_TOOL_FETCH_LIMIT) parts.push(`${exposedFlowCount} flows`)
|
||||
if (parts.length === 0) return undefined
|
||||
return `This scope matches ${parts.join(' and ')}. Only the ${MCP_TOOL_FETCH_LIMIT} most recent of each type are exposed as MCP tools; the rest are omitted. Narrow the scope to avoid overloading the assistant's context.`
|
||||
})
|
||||
|
||||
function selectAllScripts() {
|
||||
selectedScripts = [...allScripts]
|
||||
}
|
||||
@@ -468,6 +538,12 @@
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
{#if truncationWarning}
|
||||
<Alert type="warning" size="xs" title="Too many tools for this scope">
|
||||
{truncationWarning}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if selectedMode === 'folder'}
|
||||
<div>
|
||||
<span class="block mb-1 text-emphasis text-xs font-semibold">Select Folders</span>
|
||||
|
||||
Reference in New Issue
Block a user