feat(mcp): add streamable http server and fine-grained authorization
This commit is contained in:
47 files changed
+4960
-509
No files matched your search
@@ -4,13 +4,26 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
const IDENTIFIER_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*";
|
||||
const TARGET_NAME_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*(?:\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*)){0,2}";
|
||||
const QUALIFIED_NAME_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*)(?:\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*))?";
|
||||
const TARGET_NAME_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*(?:\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*)){0,3}";
|
||||
const QUALIFIED_NAME_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*(?:\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*)){1,3}";
|
||||
|
||||
static DML_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(r"(?is)\b(?:FROM|JOIN|UPDATE|INTO|REFERENCES)\s+({TARGET_NAME_PATTERN})"))
|
||||
.expect("valid DML target regex")
|
||||
});
|
||||
static APPLY_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(r"(?is)\b(?:CROSS|OUTER)\s+APPLY\s+({TARGET_NAME_PATTERN})")).expect("valid APPLY target regex")
|
||||
});
|
||||
static APPLY_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?is)\b(?:CROSS|OUTER)\s+APPLY\b").expect("valid APPLY regex"));
|
||||
static FROM_CLAUSE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?is)\bFROM\s+(.+?)(?:\b(?:WHERE|GROUP\s+BY|HAVING|ORDER\s+BY|LIMIT|OFFSET|FETCH|FOR|UNION|EXCEPT|INTERSECT|JOIN)\b|$)")
|
||||
.expect("valid FROM clause regex")
|
||||
});
|
||||
static LEADING_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(r"(?is)^\s*({TARGET_NAME_PATTERN})(?:\s+(?:AS\s+)?{IDENTIFIER_PATTERN})?\s*$"))
|
||||
.expect("valid leading target regex")
|
||||
});
|
||||
static DDL_OBJECT_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?is)\b(?:CREATE|ALTER|DROP)\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|MATERIALIZED\s+VIEW|INDEX|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|TRIGGER|EVENT|TYPE|SYNONYM)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(?:ONLY\s+)?({TARGET_NAME_PATTERN})"
|
||||
@@ -192,15 +205,42 @@ pub fn targets_production_database(config: &ConnectionConfig, active_database: &
|
||||
return false;
|
||||
}
|
||||
|
||||
let assessment = referenced_databases(sql, &config.db_type, active_database);
|
||||
let assessment = referenced_databases(sql, &config.db_type, active_database, false);
|
||||
assessment.databases.into_iter().any(|database| marked.contains(&database)) || assessment.uncertain
|
||||
}
|
||||
|
||||
/// Returns whether a statement references a database outside an MCP database
|
||||
/// allowlist. Unlike production protection this examines read queries too:
|
||||
/// `SELECT * FROM other_db.users` must not bypass a scope that was selected via
|
||||
/// the request's `database` argument.
|
||||
///
|
||||
/// The caller is responsible for checking the active database itself. This
|
||||
/// helper only adds protection for object references qualified in SQL.
|
||||
pub fn sql_references_disallowed_database(
|
||||
sql: &str,
|
||||
db_type: &DatabaseType,
|
||||
active_database: &str,
|
||||
allowed_databases: &[String],
|
||||
) -> bool {
|
||||
let allowed = allowed_databases
|
||||
.iter()
|
||||
.map(|database| normalize_database_name(database))
|
||||
.filter(|database| !database.is_empty())
|
||||
.collect::<HashSet<_>>();
|
||||
let assessment = referenced_databases(sql, db_type, active_database, true);
|
||||
assessment.uncertain || assessment.databases.into_iter().any(|database| !allowed.contains(&database))
|
||||
}
|
||||
|
||||
fn normalize_database_name(value: &str) -> String {
|
||||
value.trim().trim_matches(|ch| matches!(ch, '`' | '"' | '[' | ']')).to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn referenced_databases(sql: &str, db_type: &DatabaseType, active_database: &str) -> ReferencedDatabaseAssessment {
|
||||
fn referenced_databases(
|
||||
sql: &str,
|
||||
db_type: &DatabaseType,
|
||||
active_database: &str,
|
||||
include_read_references: bool,
|
||||
) -> ReferencedDatabaseAssessment {
|
||||
let mut assessment = ReferencedDatabaseAssessment::default();
|
||||
let cleaned = sql_target_safety_text(sql);
|
||||
let mut use_database = String::new();
|
||||
@@ -220,7 +260,7 @@ fn referenced_databases(sql: &str, db_type: &DatabaseType, active_database: &str
|
||||
continue;
|
||||
}
|
||||
|
||||
if !statement_is_mutation {
|
||||
if !statement_is_mutation && !include_read_references {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -235,6 +275,7 @@ fn referenced_databases(sql: &str, db_type: &DatabaseType, active_database: &str
|
||||
&mut statement_databases,
|
||||
&[
|
||||
&DML_TARGET_RE,
|
||||
&APPLY_TARGET_RE,
|
||||
&DDL_OBJECT_TARGET_RE,
|
||||
&INDEX_ON_TARGET_RE,
|
||||
&TRUNCATE_TARGET_RE,
|
||||
@@ -244,6 +285,22 @@ fn referenced_databases(sql: &str, db_type: &DatabaseType, active_database: &str
|
||||
&PRIVILEGE_TARGET_RE,
|
||||
],
|
||||
);
|
||||
if include_read_references
|
||||
&& collect_comma_separated_from_databases(
|
||||
statement,
|
||||
db_type,
|
||||
&cleaned.quoted_identifiers,
|
||||
current_database,
|
||||
&mut statement_databases,
|
||||
)
|
||||
{
|
||||
assessment.uncertain = true;
|
||||
}
|
||||
if include_read_references
|
||||
&& APPLY_RE.find_iter(statement).count() != APPLY_TARGET_RE.captures_iter(statement).count()
|
||||
{
|
||||
assessment.uncertain = true;
|
||||
}
|
||||
collect_qualified_target_database_groups(
|
||||
statement,
|
||||
db_type,
|
||||
@@ -316,6 +373,57 @@ fn collect_qualified_target_databases(
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects every table factor in a comma-separated FROM list. Table aliases
|
||||
/// are accepted, while derived tables and other forms this lightweight parser
|
||||
/// cannot safely resolve are reported to the caller as uncertain.
|
||||
fn collect_comma_separated_from_databases(
|
||||
statement: &str,
|
||||
db_type: &DatabaseType,
|
||||
quoted_identifiers: &HashMap<String, String>,
|
||||
current_database: &str,
|
||||
databases: &mut HashSet<String>,
|
||||
) -> bool {
|
||||
let mut uncertain = false;
|
||||
for capture in FROM_CLAUSE_RE.captures_iter(statement) {
|
||||
let Some(from_body) = capture.get(1).map(|value| value.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let factors = split_top_level_comma_separated_targets(from_body);
|
||||
if factors.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for factor in factors {
|
||||
let Some(target) = LEADING_TARGET_RE.captures(factor).and_then(|target| target.get(1)).and_then(|target| {
|
||||
database_from_qualified_name(target.as_str(), db_type, quoted_identifiers, current_database)
|
||||
}) else {
|
||||
uncertain = true;
|
||||
continue;
|
||||
};
|
||||
databases.insert(target);
|
||||
}
|
||||
}
|
||||
uncertain
|
||||
}
|
||||
|
||||
fn split_top_level_comma_separated_targets(value: &str) -> Vec<&str> {
|
||||
let mut targets = Vec::new();
|
||||
let mut depth = 0usize;
|
||||
let mut start = 0usize;
|
||||
for (index, ch) in value.char_indices() {
|
||||
match ch {
|
||||
'(' => depth += 1,
|
||||
')' => depth = depth.saturating_sub(1),
|
||||
',' if depth == 0 => {
|
||||
targets.push(value[start..index].trim());
|
||||
start = index + ch.len_utf8();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
targets.push(value[start..].trim());
|
||||
targets
|
||||
}
|
||||
|
||||
fn collect_qualified_target_database_groups(
|
||||
statement: &str,
|
||||
db_type: &DatabaseType,
|
||||
@@ -354,6 +462,12 @@ fn database_from_qualified_name(
|
||||
if parts.len() < 2 {
|
||||
return (!current_database.is_empty()).then(|| current_database.to_string());
|
||||
}
|
||||
// SQL Server accepts four-part names as `server.database.schema.object`.
|
||||
// The database scope must use the second component in that form, otherwise
|
||||
// a linked-server name that matches an allowed database could bypass it.
|
||||
if matches!(db_type, DatabaseType::SqlServer) && parts.len() >= 4 {
|
||||
return parts.get(1).cloned();
|
||||
}
|
||||
if qualified_first_part_is_database(db_type, parts.len()) {
|
||||
return parts.first().cloned();
|
||||
}
|
||||
@@ -624,7 +738,10 @@ fn append_quoted_identifier_token(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_production_database, mongo_pipeline_targets_production_database, targets_production_database};
|
||||
use super::{
|
||||
is_production_database, mongo_pipeline_targets_production_database, sql_references_disallowed_database,
|
||||
targets_production_database,
|
||||
};
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -737,6 +854,76 @@ mod tests {
|
||||
assert!(targets_production_database(&config(), "staging", "USE prod_app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_cross_database_references_for_mcp_database_scope() {
|
||||
let allowed = vec!["reporting".to_string()];
|
||||
let mysql = DatabaseType::Mysql;
|
||||
assert!(!sql_references_disallowed_database(
|
||||
"SELECT * FROM reporting.users JOIN reporting.audit_log ON 1 = 1",
|
||||
&mysql,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(sql_references_disallowed_database("SELECT * FROM production.users", &mysql, "reporting", &allowed,));
|
||||
assert!(sql_references_disallowed_database(
|
||||
"INSERT INTO reporting.audit_log SELECT * FROM production.users",
|
||||
&mysql,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(!sql_references_disallowed_database(
|
||||
"SELECT * FROM reporting.users AS users, reporting.audit_log AS audit_log",
|
||||
&mysql,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(sql_references_disallowed_database(
|
||||
"SELECT * FROM reporting.users, production.secrets",
|
||||
&mysql,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(sql_references_disallowed_database(
|
||||
"SELECT * FROM (SELECT * FROM reporting.users) AS users, reporting.audit_log AS audit_log",
|
||||
&mysql,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
|
||||
let sqlserver = DatabaseType::SqlServer;
|
||||
assert!(sql_references_disallowed_database(
|
||||
"SELECT * FROM production.dbo.users",
|
||||
&sqlserver,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(sql_references_disallowed_database(
|
||||
"SELECT * FROM reporting.production.dbo.users",
|
||||
&sqlserver,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(!sql_references_disallowed_database(
|
||||
"SELECT * FROM reporting.dbo.users AS users CROSS APPLY reporting.dbo.visible_data(users.id) AS data",
|
||||
&sqlserver,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(sql_references_disallowed_database(
|
||||
"SELECT * FROM reporting.dbo.users AS users OUTER APPLY production.dbo.sensitive_data(users.id) AS data",
|
||||
&sqlserver,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(sql_references_disallowed_database(
|
||||
"SELECT * FROM reporting.dbo.users AS users CROSS APPLY (SELECT users.id) AS data",
|
||||
&sqlserver,
|
||||
"reporting",
|
||||
&allowed,
|
||||
));
|
||||
assert!(!sql_references_disallowed_database("SELECT * FROM dbo.users", &sqlserver, "reporting", &allowed,));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_shared_sql_target_safety_corpus() {
|
||||
let corpus: Vec<ProductionSafetyCorpusCase> =
|
||||
|
||||
@@ -36,6 +36,7 @@ const APP_STATE_OPEN_TABS_KEY: &str = "open_tabs";
|
||||
const APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY: &str = "saved_sql_editor_positions";
|
||||
const APP_STATE_TRANSFER_TASK_LIBRARY_KEY: &str = "transfer_task_library";
|
||||
const MCP_GLOBAL_POLICY_KEY: &str = "mcp_global_policy";
|
||||
const MCP_HTTP_SERVER_SETTINGS_KEY: &str = "mcp_http_server_settings";
|
||||
const MAX_RETRIES_KEY: &str = "max_retries";
|
||||
const APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY: &str = "ai_global_custom_instructions";
|
||||
const APP_STATE_AI_CHAT_SELECTION_KEY: &str = "ai_chat_selection_v1";
|
||||
@@ -224,10 +225,110 @@ pub struct McpGlobalPolicy {
|
||||
#[serde(default)]
|
||||
pub allow_dangerous_sql: bool,
|
||||
pub allowed_connection_ids: Option<Vec<String>>,
|
||||
/// `None` exposes every built-in MCP tool. A list is an explicit
|
||||
/// allowlist and is enforced independently of connection permissions.
|
||||
#[serde(default)]
|
||||
pub allowed_tool_names: Option<Vec<String>>,
|
||||
/// Per-connection rules may only reduce the global execution ceiling.
|
||||
/// Keeping this in the global document preserves compatibility with the
|
||||
/// existing policy API while allowing each exposed connection to be safer.
|
||||
#[serde(default)]
|
||||
pub connection_policies: Vec<McpConnectionPolicy>,
|
||||
#[serde(default)]
|
||||
pub query_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
fn default_mcp_connection_execution_mode_configured() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpConnectionPolicy {
|
||||
pub connection_id: String,
|
||||
/// When true, this connection is read-only even if the MCP-wide policy
|
||||
/// permits writes.
|
||||
#[serde(default)]
|
||||
pub read_only: bool,
|
||||
/// Enables high-risk SQL only when the MCP-wide policy also enables it.
|
||||
/// The default is deliberately false, so adding a connection rule narrows
|
||||
/// a full-access global policy to safe writes unless chosen otherwise.
|
||||
#[serde(default)]
|
||||
pub allow_dangerous_sql: bool,
|
||||
/// Whether the operation ceiling is explicitly overridden for this
|
||||
/// connection. Missing on older saved policies defaults to true so their
|
||||
/// existing safe-write/read-only behavior is preserved; database-only
|
||||
/// rules leave it false and inherit the global ceiling.
|
||||
#[serde(default = "default_mcp_connection_execution_mode_configured")]
|
||||
pub execution_mode_configured: bool,
|
||||
/// Limits which databases below this connection can be reached by MCP.
|
||||
/// The default preserves existing installations: all databases remain
|
||||
/// available until a user explicitly narrows the scope.
|
||||
#[serde(default)]
|
||||
pub database_scope: McpDatabaseScope,
|
||||
/// Exact database names allowed when `database_scope` is `selected`.
|
||||
/// An empty selected list intentionally denies every database.
|
||||
#[serde(default)]
|
||||
pub allowed_databases: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum McpDatabaseScope {
|
||||
#[default]
|
||||
All,
|
||||
Selected,
|
||||
None,
|
||||
}
|
||||
|
||||
/// Configuration for the optional MCP Streamable HTTP server managed by the
|
||||
/// desktop application. Credentials deliberately do not live here: the
|
||||
/// desktop service stores its token in a private file under the DBX data dir.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpHttpServerSettings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_mcp_http_host")]
|
||||
pub host: String,
|
||||
#[serde(default = "default_mcp_http_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_mcp_http_path")]
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub allow_remote: bool,
|
||||
#[serde(default)]
|
||||
pub allowed_hosts: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for McpHttpServerSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
host: default_mcp_http_host(),
|
||||
port: default_mcp_http_port(),
|
||||
path: default_mcp_http_path(),
|
||||
allow_remote: false,
|
||||
allowed_hosts: Vec::new(),
|
||||
allowed_origins: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_mcp_http_host() -> String {
|
||||
"127.0.0.1".to_string()
|
||||
}
|
||||
|
||||
fn default_mcp_http_port() -> u16 {
|
||||
5225
|
||||
}
|
||||
|
||||
fn default_mcp_http_path() -> String {
|
||||
"/mcp".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpGlobalPolicyState {
|
||||
@@ -236,6 +337,10 @@ pub struct McpGlobalPolicyState {
|
||||
pub allow_dangerous_sql: bool,
|
||||
pub allowed_connection_ids: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub allowed_tool_names: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub connection_policies: Vec<McpConnectionPolicy>,
|
||||
#[serde(default)]
|
||||
pub query_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -245,11 +350,135 @@ impl McpGlobalPolicyState {
|
||||
read_only: self.read_only,
|
||||
allow_dangerous_sql: self.allow_dangerous_sql,
|
||||
allowed_connection_ids: self.allowed_connection_ids.clone(),
|
||||
allowed_tool_names: self.allowed_tool_names.clone(),
|
||||
connection_policies: self.connection_policies.clone(),
|
||||
query_timeout_secs: self.query_timeout_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl McpGlobalPolicy {
|
||||
/// Produces the single fail-closed representation persisted by the MCP
|
||||
/// policy API. This protects the policy boundary even when a caller does
|
||||
/// not use the desktop settings form (for example, a Web API client).
|
||||
pub fn normalized(&self) -> Self {
|
||||
let allowed_connection_ids = self.allowed_connection_ids.as_ref().map(|ids| {
|
||||
let mut ids =
|
||||
ids.iter().map(|id| id.trim()).filter(|id| !id.is_empty()).map(ToOwned::to_owned).collect::<Vec<_>>();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
ids
|
||||
});
|
||||
let allowed_tool_names = self.allowed_tool_names.as_ref().map(|tools| {
|
||||
let mut tools = tools
|
||||
.iter()
|
||||
.map(|tool| tool.trim())
|
||||
.filter(|tool| !tool.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
tools.sort();
|
||||
tools.dedup();
|
||||
tools
|
||||
});
|
||||
|
||||
let mut policies = HashMap::<String, McpConnectionPolicy>::new();
|
||||
for rule in &self.connection_policies {
|
||||
let connection_id = rule.connection_id.trim();
|
||||
if connection_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
policies
|
||||
.entry(connection_id.to_string())
|
||||
.and_modify(|current| {
|
||||
// Multiple rules are treated as a conjunction: any
|
||||
// read-only rule wins and high-risk access requires every
|
||||
// duplicate rule to explicitly permit it.
|
||||
if rule.execution_mode_configured {
|
||||
if current.execution_mode_configured {
|
||||
current.read_only |= rule.read_only;
|
||||
current.allow_dangerous_sql &= rule.allow_dangerous_sql;
|
||||
} else {
|
||||
current.read_only = rule.read_only;
|
||||
current.allow_dangerous_sql = rule.allow_dangerous_sql;
|
||||
}
|
||||
current.execution_mode_configured = true;
|
||||
}
|
||||
let (scope, databases) = intersect_mcp_database_scopes(
|
||||
current.database_scope,
|
||||
¤t.allowed_databases,
|
||||
rule.database_scope,
|
||||
&rule.allowed_databases,
|
||||
);
|
||||
current.database_scope = scope;
|
||||
current.allowed_databases = databases;
|
||||
})
|
||||
.or_insert_with(|| McpConnectionPolicy {
|
||||
connection_id: connection_id.to_string(),
|
||||
read_only: rule.read_only,
|
||||
allow_dangerous_sql: rule.allow_dangerous_sql,
|
||||
execution_mode_configured: rule.execution_mode_configured,
|
||||
database_scope: rule.database_scope,
|
||||
allowed_databases: normalize_mcp_database_names(&rule.allowed_databases),
|
||||
});
|
||||
}
|
||||
let mut connection_policies = policies.into_values().collect::<Vec<_>>();
|
||||
connection_policies.sort_by(|left, right| left.connection_id.cmp(&right.connection_id));
|
||||
for rule in &mut connection_policies {
|
||||
if rule.read_only {
|
||||
rule.allow_dangerous_sql = false;
|
||||
}
|
||||
rule.allowed_databases = normalize_mcp_database_names(&rule.allowed_databases);
|
||||
if rule.database_scope != McpDatabaseScope::Selected {
|
||||
rule.allowed_databases.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
read_only: self.read_only,
|
||||
allow_dangerous_sql: !self.read_only && self.allow_dangerous_sql,
|
||||
allowed_connection_ids,
|
||||
allowed_tool_names,
|
||||
connection_policies,
|
||||
query_timeout_secs: self.query_timeout_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_mcp_database_names(databases: &[String]) -> Vec<String> {
|
||||
let mut databases = databases
|
||||
.iter()
|
||||
.map(|database| database.trim())
|
||||
.filter(|database| !database.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
databases.sort();
|
||||
databases.dedup();
|
||||
databases
|
||||
}
|
||||
|
||||
fn intersect_mcp_database_scopes(
|
||||
left_scope: McpDatabaseScope,
|
||||
left_databases: &[String],
|
||||
right_scope: McpDatabaseScope,
|
||||
right_databases: &[String],
|
||||
) -> (McpDatabaseScope, Vec<String>) {
|
||||
use McpDatabaseScope::{All, None, Selected};
|
||||
match (left_scope, right_scope) {
|
||||
(None, _) | (_, None) => (None, Vec::new()),
|
||||
(All, All) => (All, Vec::new()),
|
||||
(All, Selected) => (Selected, normalize_mcp_database_names(right_databases)),
|
||||
(Selected, All) => (Selected, normalize_mcp_database_names(left_databases)),
|
||||
(Selected, Selected) => {
|
||||
let right = normalize_mcp_database_names(right_databases);
|
||||
let databases = normalize_mcp_database_names(left_databases)
|
||||
.into_iter()
|
||||
.filter(|database| right.binary_search(database).is_ok())
|
||||
.collect();
|
||||
(Selected, databases)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_sidebar_table_page_size() -> usize {
|
||||
1000
|
||||
}
|
||||
@@ -1644,6 +1873,8 @@ impl Storage {
|
||||
read_only: policy.read_only,
|
||||
allow_dangerous_sql: policy.allow_dangerous_sql,
|
||||
allowed_connection_ids: policy.allowed_connection_ids,
|
||||
allowed_tool_names: policy.allowed_tool_names,
|
||||
connection_policies: policy.connection_policies,
|
||||
query_timeout_secs: policy.query_timeout_secs,
|
||||
});
|
||||
};
|
||||
@@ -1656,16 +1887,21 @@ impl Storage {
|
||||
read_only: policy.read_only,
|
||||
allow_dangerous_sql: policy.allow_dangerous_sql,
|
||||
allowed_connection_ids: policy.allowed_connection_ids,
|
||||
allowed_tool_names: policy.allowed_tool_names,
|
||||
connection_policies: policy.connection_policies,
|
||||
query_timeout_secs: policy.query_timeout_secs,
|
||||
});
|
||||
};
|
||||
let policy = serde_json::from_value::<McpGlobalPolicy>(value.clone())
|
||||
.map_err(|e| format!("invalid MCP policy: {e}"))?;
|
||||
.map_err(|e| format!("invalid MCP policy: {e}"))?
|
||||
.normalized();
|
||||
Ok(McpGlobalPolicyState {
|
||||
configured: true,
|
||||
read_only: policy.read_only,
|
||||
allow_dangerous_sql: policy.allow_dangerous_sql,
|
||||
allowed_connection_ids: policy.allowed_connection_ids,
|
||||
allowed_tool_names: policy.allowed_tool_names,
|
||||
connection_policies: policy.connection_policies,
|
||||
query_timeout_secs: policy.query_timeout_secs,
|
||||
})
|
||||
})
|
||||
@@ -1674,7 +1910,7 @@ impl Storage {
|
||||
}
|
||||
|
||||
pub async fn save_mcp_global_policy(&self, policy: &McpGlobalPolicy) -> Result<(), String> {
|
||||
let policy = serde_json::to_value(policy).map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
|
||||
let policy = serde_json::to_value(policy.normalized()).map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?;
|
||||
self.with_conn(move |conn| {
|
||||
let current: Option<String> = conn
|
||||
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
|
||||
@@ -1695,6 +1931,22 @@ impl Storage {
|
||||
.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))
|
||||
}
|
||||
|
||||
pub async fn load_mcp_http_server_settings(&self) -> Result<McpHttpServerSettings, String> {
|
||||
let settings = self.load_app_settings_json().await?;
|
||||
match settings.get(MCP_HTTP_SERVER_SETTINGS_KEY) {
|
||||
Some(value) => serde_json::from_value(value.clone())
|
||||
.map_err(|error| format!("invalid MCP HTTP server settings: {error}")),
|
||||
None => Ok(McpHttpServerSettings::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_mcp_http_server_settings(&self, settings: &McpHttpServerSettings) -> Result<(), String> {
|
||||
let mut app_settings = self.load_app_settings_json().await?;
|
||||
let value = serde_json::to_value(settings).map_err(|error| error.to_string())?;
|
||||
app_settings.insert(MCP_HTTP_SERVER_SETTINGS_KEY.to_string(), value);
|
||||
self.save_app_settings_json(&app_settings).await
|
||||
}
|
||||
|
||||
pub async fn save_desktop_settings(&self, desktop_settings: &DesktopSettings) -> Result<(), String> {
|
||||
let mut settings = self.load_app_settings_json().await?;
|
||||
settings.remove("run_in_background");
|
||||
@@ -2619,7 +2871,8 @@ fn load_mcp_global_policy_in_tx(tx: &rusqlite::Transaction<'_>) -> Result<McpGlo
|
||||
.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: invalid app settings JSON: {e}"))?;
|
||||
match settings.get(MCP_GLOBAL_POLICY_KEY) {
|
||||
Some(value) => serde_json::from_value::<McpGlobalPolicy>(value.clone())
|
||||
.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: invalid MCP policy: {e}"))?,
|
||||
.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: invalid MCP policy: {e}"))?
|
||||
.normalized(),
|
||||
None => McpGlobalPolicy::default(),
|
||||
}
|
||||
}
|
||||
@@ -6028,6 +6281,8 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
allowed_tool_names: None,
|
||||
connection_policies: Vec::new(),
|
||||
query_timeout_secs: None,
|
||||
}
|
||||
);
|
||||
@@ -6039,6 +6294,7 @@ mod tests {
|
||||
allow_dangerous_sql: true,
|
||||
allowed_connection_ids: Some(vec!["conn-1".to_string(), "conn-2".to_string()]),
|
||||
query_timeout_secs: Some(120),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -6048,15 +6304,17 @@ mod tests {
|
||||
McpGlobalPolicyState {
|
||||
configured: true,
|
||||
read_only: true,
|
||||
allow_dangerous_sql: true,
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec!["conn-1".to_string(), "conn-2".to_string()]),
|
||||
allowed_tool_names: None,
|
||||
connection_policies: Vec::new(),
|
||||
query_timeout_secs: Some(120),
|
||||
}
|
||||
);
|
||||
assert_eq!(storage.load_password_hash().await.unwrap().as_deref(), Some("preserved"));
|
||||
let settings = storage.load_app_settings_json().await.unwrap();
|
||||
assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["readOnly"], true);
|
||||
assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["allowDangerousSql"], true);
|
||||
assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["allowDangerousSql"], false);
|
||||
assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["allowedConnectionIds"][0], "conn-1");
|
||||
assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["queryTimeoutSecs"], 120);
|
||||
assert!(settings[MCP_GLOBAL_POLICY_KEY].get("configured").is_none());
|
||||
@@ -6148,6 +6406,7 @@ mod tests {
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: Some(vec![kept.id.clone()]),
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -6172,6 +6431,7 @@ mod tests {
|
||||
allow_dangerous_sql: false,
|
||||
allowed_connection_ids: None,
|
||||
query_timeout_secs: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Reference in new issue
Block a user