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:
Alexander Petric
2026-07-17 00:15:34 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 Ruben Fiszel
parent c55ac5326f
commit 91d6606868
6 changed files with 283 additions and 78 deletions
+5 -5
View File
@@ -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))
+106 -8
View File
@@ -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())
);
}
}
+22 -4
View File
@@ -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
+1 -1
View File
@@ -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,
+18 -5
View File
@@ -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>