mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 08:02:19 +00:00
feat: dedicated opt-in data tables toggle with read/write scope
This commit is contained in:
@@ -382,16 +382,14 @@ impl McpBackend for WindmillBackend {
|
||||
// Filter datatable list responses down to the datatables this token
|
||||
// is scoped to, so restricted tokens never even see other names.
|
||||
if let Some(config) = &dt_scope {
|
||||
if config.datatables_restricted && !config.all {
|
||||
if let Some(key) = datatable_list_filter_key(&endpoint_tool.name) {
|
||||
if let Value::Array(items) = &mut result {
|
||||
items.retain(|item| {
|
||||
item.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|n| config.is_datatable_allowed(n))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
if let Some(key) = datatable_list_filter_key(&endpoint_tool.name) {
|
||||
if let Value::Array(items) = &mut result {
|
||||
items.retain(|item| {
|
||||
item.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|n| config.is_datatable_allowed(n))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,8 @@ fn supported_scopes() -> Vec<String> {
|
||||
"mcp:scripts:*".to_string(),
|
||||
"mcp:flows:*".to_string(),
|
||||
"mcp:endpoints:*".to_string(),
|
||||
"mcp:datatables:*".to_string(),
|
||||
"mcp:datatables:read:*".to_string(),
|
||||
"mcp:datatables:write:*".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,15 @@ pub struct McpScopeConfig {
|
||||
pub flows: Vec<String>,
|
||||
/// Endpoint names/patterns allowed by this token
|
||||
pub endpoints: Vec<String>,
|
||||
/// Datatable names/patterns this token may read/write. Only meaningful when
|
||||
/// `datatables_restricted` is set; otherwise datatable access is unrestricted.
|
||||
/// Whether the datatable read tools (list/query/get schema) are granted.
|
||||
/// Datatable access is opt-in and off by default — it is never implied by the
|
||||
/// generic endpoint or favorites scopes, only by an explicit `mcp:datatables:`
|
||||
/// scope (or the legacy `mcp:all`).
|
||||
pub datatables_read: bool,
|
||||
/// Whether the datatable write tools (insert/update) are granted. Implies read.
|
||||
pub datatables_write: bool,
|
||||
/// Datatable names/patterns access is restricted to. `*` (or empty) means all.
|
||||
pub datatables: Vec<String>,
|
||||
/// Whether any `mcp:datatables:` scope was present. Absent = opt-out (all
|
||||
/// datatables allowed, backward compatible); present = restrict to the list.
|
||||
pub datatables_restricted: bool,
|
||||
/// Whether this is a legacy "all" scope
|
||||
pub all: bool,
|
||||
/// Whether this is a "favorites" scope
|
||||
@@ -39,22 +42,30 @@ impl McpScopeConfig {
|
||||
"script" => &self.scripts,
|
||||
"flow" => &self.flows,
|
||||
"endpoint" => &self.endpoints,
|
||||
"datatable" => &self.datatables,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
is_resource_allowed(path, patterns)
|
||||
}
|
||||
|
||||
/// Whether the token may touch datatable `name`. Datatable filtering is
|
||||
/// opt-in: a token with no `mcp:datatables:` scope (`datatables_restricted`
|
||||
/// false) may reach every datatable, so existing tokens keep working. Once a
|
||||
/// restriction is present, only the listed names/patterns (or `*`) pass.
|
||||
/// Whether datatable `name` is within this token's datatable restriction.
|
||||
/// This is only the name filter — whether datatable access is granted at all
|
||||
/// (and at which level) is `datatable_tool_allowed`. An empty list or `*`
|
||||
/// means all datatables.
|
||||
pub fn is_datatable_allowed(&self, name: &str) -> bool {
|
||||
if self.all || !self.datatables_restricted {
|
||||
return true;
|
||||
self.datatables.is_empty()
|
||||
|| self.datatables.iter().any(|d| d == "*")
|
||||
|| is_resource_allowed(name, &self.datatables)
|
||||
}
|
||||
|
||||
/// Whether a datatable tool with the given access level (`"read"` or
|
||||
/// `"write"`) may be exposed/called. Write tools require write access; read
|
||||
/// tools require read (write implies read).
|
||||
pub fn datatable_tool_allowed(&self, access: &str) -> bool {
|
||||
match access {
|
||||
"write" => self.datatables_write,
|
||||
_ => self.datatables_read || self.datatables_write,
|
||||
}
|
||||
is_resource_allowed(name, &self.datatables)
|
||||
}
|
||||
|
||||
/// Directional subset check: does this config grant at least everything
|
||||
@@ -91,21 +102,27 @@ impl McpScopeConfig {
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
// Datatable containment: an unrestricted request grants every datatable,
|
||||
// so a restricted caller (without `*`) cannot cover it. A restricted
|
||||
// request must be covered by the caller's list — treating an unrestricted
|
||||
// caller as `*`.
|
||||
if !requested.datatables_restricted {
|
||||
if self.datatables_restricted && !self.datatables.iter().any(|d| d == "*") {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
let caller = if self.datatables_restricted {
|
||||
self.datatables.clone()
|
||||
} else {
|
||||
// Datatable containment: the caller must grant at least the requested
|
||||
// access level, and its datatable name list must cover the requested one
|
||||
// (empty list = all datatables, i.e. `*`).
|
||||
if requested.datatables_read && !(self.datatables_read || self.datatables_write) {
|
||||
return false;
|
||||
}
|
||||
if requested.datatables_write && !self.datatables_write {
|
||||
return false;
|
||||
}
|
||||
if requested.datatables_read || requested.datatables_write {
|
||||
let caller = if self.datatables.is_empty() {
|
||||
vec!["*".to_string()]
|
||||
} else {
|
||||
self.datatables.clone()
|
||||
};
|
||||
if !resource_list_covers(&caller, &requested.datatables) {
|
||||
let req = if requested.datatables.is_empty() {
|
||||
vec!["*".to_string()]
|
||||
} else {
|
||||
requested.datatables.clone()
|
||||
};
|
||||
if !resource_list_covers(&caller, &req) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -152,11 +169,14 @@ pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> {
|
||||
}
|
||||
|
||||
if scope == "mcp:all" {
|
||||
// Legacy scope: grant access to everything
|
||||
// Legacy scope: grant access to everything, including datatable
|
||||
// read+write on every datatable.
|
||||
config.all = true;
|
||||
config.scripts.push("*".to_string());
|
||||
config.flows.push("*".to_string());
|
||||
config.endpoints.push("*".to_string());
|
||||
config.datatables_read = true;
|
||||
config.datatables_write = true;
|
||||
config.datatables.push("*".to_string());
|
||||
continue;
|
||||
}
|
||||
@@ -204,10 +224,23 @@ pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(resources) = scope.strip_prefix("mcp:datatables:") {
|
||||
// Datatable restriction: mcp:datatables:name1,name2 or mcp:datatables:*
|
||||
config.datatables_restricted = true;
|
||||
config.datatables.extend(parse_resource_list(resources));
|
||||
if let Some(rest) = scope.strip_prefix("mcp:datatables:") {
|
||||
// Datatable access (opt-in). Forms:
|
||||
// mcp:datatables:write:<names|*> -> read+write on those datatables
|
||||
// mcp:datatables:read:<names|*> -> read-only
|
||||
// mcp:datatables:<names|*> -> read-only (shorthand)
|
||||
let (write, names) = if let Some(n) = rest.strip_prefix("write:") {
|
||||
(true, n)
|
||||
} else if let Some(n) = rest.strip_prefix("read:") {
|
||||
(false, n)
|
||||
} else {
|
||||
(false, rest)
|
||||
};
|
||||
config.datatables_read = true;
|
||||
if write {
|
||||
config.datatables_write = true;
|
||||
}
|
||||
config.datatables.extend(parse_resource_list(names));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -219,6 +252,23 @@ pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Classify an MCP endpoint tool as a datatable tool and its required access
|
||||
/// level (`"read"` or `"write"`), or `None` if it is not a datatable tool.
|
||||
/// Datatable tools are gated by the dedicated `mcp:datatables:` scope rather
|
||||
/// than the generic endpoint/all scopes, so both the tool lister and the caller
|
||||
/// need this classification.
|
||||
pub fn datatable_access_level(tool_name: &str) -> Option<&'static str> {
|
||||
match tool_name {
|
||||
"listDataTables"
|
||||
| "listDataTableTables"
|
||||
| "listDataTableSchemas"
|
||||
| "getDataTableTableSchema"
|
||||
| "queryDataTable" => Some("read"),
|
||||
"insertDataTable" | "updateDataTable" => Some("write"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse comma-separated resource list
|
||||
fn parse_resource_list(resources: &str) -> Vec<String> {
|
||||
if resources.is_empty() {
|
||||
@@ -398,43 +448,59 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_datatable_scope_parsing_and_allow() {
|
||||
// No datatable scope => unrestricted (opt-in), every datatable allowed.
|
||||
let c = cfg(&["mcp:endpoints:queryDataTable"]);
|
||||
assert!(!c.datatables_restricted);
|
||||
assert!(c.is_datatable_allowed("main"));
|
||||
fn test_datatable_scope_opt_in_and_levels() {
|
||||
// Datatable access is off by default — the generic endpoint/favorites
|
||||
// scopes never grant it.
|
||||
let c = cfg(&["mcp:endpoints:*"]);
|
||||
assert!(!c.datatables_read && !c.datatables_write);
|
||||
assert!(!c.datatable_tool_allowed("read"));
|
||||
assert!(!c.datatable_tool_allowed("write"));
|
||||
|
||||
// mcp:all grants read+write on all datatables.
|
||||
let c = cfg(&["mcp:all"]);
|
||||
assert!(c.datatable_tool_allowed("read"));
|
||||
assert!(c.datatable_tool_allowed("write"));
|
||||
assert!(c.is_datatable_allowed("anything"));
|
||||
|
||||
// mcp:all grants all datatables.
|
||||
assert!(cfg(&["mcp:all"]).is_datatable_allowed("main"));
|
||||
// Read scope grants read tools only.
|
||||
let c = cfg(&["mcp:datatables:read:*"]);
|
||||
assert!(c.datatable_tool_allowed("read"));
|
||||
assert!(!c.datatable_tool_allowed("write"));
|
||||
|
||||
// Explicit restriction to specific names.
|
||||
let c = cfg(&["mcp:datatables:main,analytics"]);
|
||||
assert!(c.datatables_restricted);
|
||||
// Write scope implies read.
|
||||
let c = cfg(&["mcp:datatables:write:main,analytics"]);
|
||||
assert!(c.datatable_tool_allowed("read"));
|
||||
assert!(c.datatable_tool_allowed("write"));
|
||||
assert!(c.is_datatable_allowed("main"));
|
||||
assert!(c.is_datatable_allowed("analytics"));
|
||||
assert!(!c.is_datatable_allowed("secret"));
|
||||
|
||||
// Wildcard restriction allows all.
|
||||
let c = cfg(&["mcp:datatables:*"]);
|
||||
assert!(c.datatables_restricted);
|
||||
assert!(c.is_datatable_allowed("anything"));
|
||||
// Shorthand (no read/write segment) is read-only.
|
||||
let c = cfg(&["mcp:datatables:main"]);
|
||||
assert!(c.datatable_tool_allowed("read"));
|
||||
assert!(!c.datatable_tool_allowed("write"));
|
||||
assert!(c.is_datatable_allowed("main"));
|
||||
assert!(!c.is_datatable_allowed("other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_datatables() {
|
||||
// Unrestricted caller covers a restricted request.
|
||||
assert!(cfg(&["mcp:endpoints:*"]).contains(&cfg(&["mcp:datatables:main"])));
|
||||
// Restricted caller covers a subset request.
|
||||
assert!(cfg(&["mcp:datatables:main,analytics"]).contains(&cfg(&["mcp:datatables:main"])));
|
||||
// Restricted caller must NOT widen into a sibling or into unrestricted.
|
||||
assert!(!cfg(&["mcp:datatables:main"]).contains(&cfg(&["mcp:datatables:analytics"])));
|
||||
assert!(!cfg(&["mcp:datatables:main"]).contains(&cfg(&["mcp:endpoints:queryDataTable"])));
|
||||
// `*` restriction covers both a subset and an unrestricted request.
|
||||
assert!(cfg(&["mcp:datatables:*"]).contains(&cfg(&["mcp:datatables:main"])));
|
||||
assert!(cfg(&["mcp:datatables:*"]).contains(&cfg(&[])));
|
||||
// A write caller covers a read request on a subset.
|
||||
assert!(cfg(&["mcp:datatables:write:main,analytics"])
|
||||
.contains(&cfg(&["mcp:datatables:read:main"])));
|
||||
// A read caller must NOT grant write.
|
||||
assert!(!cfg(&["mcp:datatables:read:*"]).contains(&cfg(&["mcp:datatables:write:main"])));
|
||||
// No datatable caller cannot grant datatable access.
|
||||
assert!(!cfg(&["mcp:endpoints:*"]).contains(&cfg(&["mcp:datatables:read:main"])));
|
||||
// Restricted caller must NOT widen into a sibling or into `*`.
|
||||
assert!(
|
||||
!cfg(&["mcp:datatables:read:main"]).contains(&cfg(&["mcp:datatables:read:analytics"]))
|
||||
);
|
||||
assert!(!cfg(&["mcp:datatables:read:main"]).contains(&cfg(&["mcp:datatables:read:*"])));
|
||||
// `*` caller covers a subset request.
|
||||
assert!(cfg(&["mcp:datatables:write:*"]).contains(&cfg(&["mcp:datatables:read:main"])));
|
||||
// mcp:all covers any datatable request.
|
||||
assert!(cfg(&["mcp:all"]).contains(&cfg(&["mcp:datatables:main"])));
|
||||
assert!(cfg(&["mcp:all"]).contains(&cfg(&["mcp:datatables:write:main"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -244,7 +244,16 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
// Add endpoint tools from the generated MCP tools, filtered by scope
|
||||
let endpoint_tools = self.backend.all_endpoint_tools();
|
||||
for endpoint_tool in endpoint_tools {
|
||||
if scope_config.granular && !scope_config.is_allowed("endpoint", &endpoint_tool.name) {
|
||||
// Datatable tools are opt-in via the dedicated `mcp:datatables:` scope,
|
||||
// independent of the generic endpoint/favorites/all scopes.
|
||||
if let Some(access) = crate::common::scope::datatable_access_level(&endpoint_tool.name)
|
||||
{
|
||||
if !scope_config.datatable_tool_allowed(access) {
|
||||
continue;
|
||||
}
|
||||
} else if scope_config.granular
|
||||
&& !scope_config.is_allowed("endpoint", &endpoint_tool.name)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if read_only && !crate::server::is_endpoint_read_only(&endpoint_tool) {
|
||||
@@ -276,8 +285,21 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
let endpoint_tools = self.backend.all_endpoint_tools();
|
||||
for endpoint_tool in &endpoint_tools {
|
||||
if endpoint_tool.name.as_ref() == request.name {
|
||||
// Validate endpoint scope
|
||||
if scope_config.granular
|
||||
// Validate endpoint scope. Datatable tools are gated by the
|
||||
// dedicated datatable scope, not the generic endpoint scope.
|
||||
if let Some(access) =
|
||||
crate::common::scope::datatable_access_level(&endpoint_tool.name)
|
||||
{
|
||||
if !scope_config.datatable_tool_allowed(access) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!(
|
||||
"Access denied: datatable {} access is not in this token's scope (tool '{}')",
|
||||
access, endpoint_tool.name
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
} else if scope_config.granular
|
||||
&& !scope_config.is_allowed("endpoint", &endpoint_tool.name)
|
||||
{
|
||||
return Err(ErrorData::internal_error(
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import { mcpEndpointTools } from '$lib/mcpEndpointTools'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import InfoIcon from 'lucide-svelte/icons/info'
|
||||
import { SvelteMap } from 'svelte/reactivity'
|
||||
|
||||
@@ -26,10 +27,24 @@
|
||||
|
||||
let { workspaceId, scope = $bindable(), initialScope, readOnly = false }: Props = $props()
|
||||
|
||||
// Datatable tools are controlled by the dedicated "Data tables" section, not
|
||||
// the generic API-endpoint picker, so they're excluded from that list.
|
||||
const DATATABLE_TOOL_NAMES = new Set([
|
||||
'listDataTables',
|
||||
'listDataTableTables',
|
||||
'listDataTableSchemas',
|
||||
'getDataTableTableSchema',
|
||||
'queryDataTable',
|
||||
'insertDataTable',
|
||||
'updateDataTable'
|
||||
])
|
||||
|
||||
// Endpoints we can actually advertise to a read-only MCP token. Mirrors the
|
||||
// runner's filter (only GET endpoints).
|
||||
// runner's filter (only GET endpoints) and drops the datatable tools.
|
||||
const visibleEndpointTools = $derived(
|
||||
readOnly ? mcpEndpointTools.filter((e) => e.method === 'GET') : mcpEndpointTools
|
||||
(readOnly ? mcpEndpointTools.filter((e) => e.method === 'GET') : mcpEndpointTools).filter(
|
||||
(e) => !DATATABLE_TOOL_NAMES.has(e.name)
|
||||
)
|
||||
)
|
||||
|
||||
// When read-only flips on, prune already-selected non-GET endpoints so the
|
||||
@@ -54,6 +69,8 @@
|
||||
let selectedFlows = $state<string[]>(parsedInitial.flows)
|
||||
let selectedEndpoints = $state<string[]>(parsedInitial.endpoints)
|
||||
let selectedDatatables = $state<string[]>(parsedInitial.datatables)
|
||||
let datatablesEnabled = $state(parsedInitial.datatablesEnabled)
|
||||
let datatablesWrite = $state(parsedInitial.datatablesWrite)
|
||||
let allDatatables = $state<string[]>([])
|
||||
let loadingDatatables = $state(false)
|
||||
let customScriptPatterns = $state<string>(parsedInitial.scriptPatterns)
|
||||
@@ -84,6 +101,8 @@
|
||||
flows: string[]
|
||||
endpoints: string[]
|
||||
datatables: string[]
|
||||
datatablesEnabled: boolean
|
||||
datatablesWrite: boolean
|
||||
scriptPatterns: string
|
||||
flowPatterns: string
|
||||
hubApps: string[]
|
||||
@@ -97,6 +116,8 @@
|
||||
flows: [],
|
||||
endpoints: [],
|
||||
datatables: [],
|
||||
datatablesEnabled: false,
|
||||
datatablesWrite: false,
|
||||
scriptPatterns: '',
|
||||
flowPatterns: '',
|
||||
hubApps: []
|
||||
@@ -109,6 +130,9 @@
|
||||
const byKind: Record<string, string[]> = {}
|
||||
let mode: ParsedScope['mode'] = 'custom'
|
||||
const hubApps: string[] = []
|
||||
let datatablesEnabled = false
|
||||
let datatablesWrite = false
|
||||
let datatableNames: string[] = []
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === 'mcp:favorites') {
|
||||
@@ -124,9 +148,19 @@
|
||||
} else if (part.startsWith('mcp:endpoints:')) {
|
||||
byKind.endpoints = parsePatterns(part.slice('mcp:endpoints:'.length))
|
||||
} else if (part.startsWith('mcp:datatables:')) {
|
||||
byKind.datatables = parsePatterns(part.slice('mcp:datatables:'.length))
|
||||
datatablesEnabled = true
|
||||
let rest = part.slice('mcp:datatables:'.length)
|
||||
if (rest.startsWith('write:')) {
|
||||
datatablesWrite = true
|
||||
rest = rest.slice('write:'.length)
|
||||
} else if (rest.startsWith('read:')) {
|
||||
rest = rest.slice('read:'.length)
|
||||
}
|
||||
// `*` means "all data tables" — an empty explicit selection.
|
||||
datatableNames = parsePatterns(rest).filter((n) => n !== '*')
|
||||
}
|
||||
}
|
||||
const dt = { datatables: datatableNames, datatablesEnabled, datatablesWrite }
|
||||
|
||||
// Detect folder mode: scripts and flows are exclusively `f/X/*` patterns
|
||||
// for the same set of folders, and endpoints is exactly `*`.
|
||||
@@ -149,7 +183,7 @@
|
||||
scripts: [],
|
||||
flows: [],
|
||||
endpoints: [],
|
||||
datatables: [],
|
||||
...dt,
|
||||
scriptPatterns: '',
|
||||
flowPatterns: '',
|
||||
hubApps
|
||||
@@ -157,7 +191,7 @@
|
||||
}
|
||||
|
||||
if (mode === 'favorites' || mode === 'all') {
|
||||
return { ...empty, mode, hubApps }
|
||||
return { ...empty, mode, hubApps, ...dt }
|
||||
}
|
||||
|
||||
// Custom mode: split each list into "selectable" entries (later filtered
|
||||
@@ -171,7 +205,7 @@
|
||||
scripts: [],
|
||||
flows: [],
|
||||
endpoints: byKind.endpoints ?? [],
|
||||
datatables: byKind.datatables ?? [],
|
||||
...dt,
|
||||
scriptPatterns: (byKind.scripts ?? []).join(','),
|
||||
flowPatterns: (byKind.flows ?? []).join(','),
|
||||
hubApps
|
||||
@@ -202,12 +236,6 @@
|
||||
if (selectedEndpoints.length > 0) {
|
||||
scopeParts.push(`mcp:endpoints:${selectedEndpoints.join(',')}`)
|
||||
}
|
||||
|
||||
// Restrict which data tables the datatable tools may touch. Omitting
|
||||
// this leaves datatable access unrestricted (all data tables).
|
||||
if (selectedDatatables.length > 0) {
|
||||
scopeParts.push(`mcp:datatables:${selectedDatatables.join(',')}`)
|
||||
}
|
||||
} else if (selectedMode === 'folder') {
|
||||
const folderPaths = selectedFolders.map((f) => `f/${f}/*`).join(',')
|
||||
if (selectedFolders.length > 0) {
|
||||
@@ -221,6 +249,14 @@
|
||||
scopeParts.push(`mcp:hub:${newMcpApps.join(',')}`)
|
||||
}
|
||||
|
||||
// Data table access is opt-in and independent of the mode. In "all" mode
|
||||
// `mcp:all` already grants full datatable access, so no separate scope.
|
||||
if (datatablesEnabled && selectedMode !== 'all') {
|
||||
const level = datatablesWrite && !readOnly ? 'write' : 'read'
|
||||
const names = selectedDatatables.length > 0 ? selectedDatatables.join(',') : '*'
|
||||
scopeParts.push(`mcp:datatables:${level}:${names}`)
|
||||
}
|
||||
|
||||
scope = scopeParts.join(' ')
|
||||
})
|
||||
|
||||
@@ -416,13 +452,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Load data table names for the custom-mode restriction picker
|
||||
// Load data table names once the Data tables section is enabled.
|
||||
$effect(() => {
|
||||
if (selectedMode === 'custom' && workspaceId) {
|
||||
if (datatablesEnabled && workspaceId) {
|
||||
loadDatatables(workspaceId)
|
||||
}
|
||||
})
|
||||
|
||||
// A read-only token can never expose the write tools, so keep the write
|
||||
// sub-toggle off while read-only is on.
|
||||
$effect(() => {
|
||||
if (readOnly && datatablesWrite) {
|
||||
datatablesWrite = false
|
||||
}
|
||||
})
|
||||
|
||||
// One-shot: once allScripts/allFlows are loaded, split the
|
||||
// initial pattern text into known paths (selectedScripts/Flows) vs
|
||||
// remaining wildcards/unknowns (kept in pattern textbox).
|
||||
@@ -479,12 +523,6 @@
|
||||
function clearAllEndpoints() {
|
||||
selectedEndpoints = []
|
||||
}
|
||||
function selectAllDatatables() {
|
||||
selectedDatatables = [...allDatatables]
|
||||
}
|
||||
function clearAllDatatables() {
|
||||
selectedDatatables = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -550,6 +588,53 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedMode !== 'all'}
|
||||
<div class="flex flex-col gap-2 rounded-md border border-surface-hover p-3">
|
||||
<Toggle
|
||||
bind:checked={datatablesEnabled}
|
||||
options={{
|
||||
right: 'Data tables',
|
||||
rightTooltip:
|
||||
'Expose tools to read (and optionally write) the workspace data tables directly via MCP.'
|
||||
}}
|
||||
size="2xs"
|
||||
/>
|
||||
{#if datatablesEnabled}
|
||||
<p class="text-xs text-tertiary">
|
||||
This grants the MCP client <b>full access to the data</b> in the selected data tables (all
|
||||
of them if none is selected). Data tables have no per-user or row-level permissions yet
|
||||
<span class="text-secondary">(coming soon)</span>, so only enable this for trusted or
|
||||
development use.
|
||||
</p>
|
||||
|
||||
{#if !readOnly}
|
||||
<Toggle
|
||||
bind:checked={datatablesWrite}
|
||||
options={{
|
||||
right: 'Write access (insert & update)',
|
||||
rightTooltip:
|
||||
'Also expose the insert and update tools. Leave off for read-only access.'
|
||||
}}
|
||||
size="2xs"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<span class="block text-xs font-semibold mt-1">Restrict to specific data tables</span>
|
||||
{#if loadingDatatables}
|
||||
<p class="text-xs text-primary">Loading data tables...</p>
|
||||
{:else if allDatatables.length > 0}
|
||||
<MultiSelect
|
||||
items={safeSelectItems(allDatatables)}
|
||||
placeholder="Leave empty to allow all data tables"
|
||||
bind:value={selectedDatatables}
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-xs text-primary">No data tables configured in this workspace.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedMode === 'custom'}
|
||||
{#if loadingRunnables}
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -605,28 +690,9 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if allDatatables.length > 0 || loadingDatatables}
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
{@render sectionHeader('Data tables', selectAllDatatables, clearAllDatatables)}
|
||||
{#if loadingDatatables}
|
||||
<p class="text-xs text-primary">Loading data tables...</p>
|
||||
{:else}
|
||||
<MultiSelect
|
||||
items={safeSelectItems(allDatatables)}
|
||||
placeholder="Restrict to specific data tables (leave empty for all)"
|
||||
bind:value={selectedDatatables}
|
||||
/>
|
||||
<p class="text-xs text-tertiary">
|
||||
Applies only to the datatable tools (query/insert/update/list). Leave empty to allow
|
||||
every data table.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="text-xs text-primary mt-2">
|
||||
Selected: {selectedScripts.length} scripts, {selectedFlows.length} flows, {selectedEndpoints.length}
|
||||
endpoints{#if selectedDatatables.length > 0}, {selectedDatatables.length} data tables{/if}
|
||||
endpoints
|
||||
</div>
|
||||
|
||||
<!-- Wildcard Patterns Section -->
|
||||
|
||||
Reference in New Issue
Block a user