feat(mcp): add streamable http server and fine-grained authorization

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-31 22:56:16 +08:00
1 parent f725c50b2c
commit f075caef21
47 files changed
+4960 -509

No files matched your search

+193 -6
View File
@@ -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> =
+265 -5
View File
@@ -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,
&current.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();