use std::sync::Arc;
use std::time::Duration;
use serde_json::{json, Value};
use tokio::io::{AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use crate::agent_contract::{self, AgentContract};
use crate::backend::client::QueryResult;
use crate::backend::types::TextValue;
use crate::backend::{tls::default_client_config, BackendClient, BackendConfig, TlsMode};
use crate::config::McpConfig;
use crate::{ProxyError, Result};
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
pub struct McpServer {
config: McpConfig,
contract: Option<AgentContract>,
}
impl McpServer {
pub fn new(config: McpConfig, contract: Option<AgentContract>) -> Self {
Self { config, contract }
}
pub async fn run(self) -> Result<()> {
let listener = TcpListener::bind(&self.config.listen_address)
.await
.map_err(|e| {
ProxyError::Network(format!("MCP bind {}: {}", self.config.listen_address, e))
})?;
tracing::info!(addr = %self.config.listen_address, read_only = self.config.read_only,
contract = ?self.contract.as_ref().map(|c| &c.id), "MCP agent gateway listening");
let cfg = Arc::new(self.config);
let contract = Arc::new(self.contract);
loop {
let (stream, peer) = match listener.accept().await {
Ok(x) => x,
Err(e) => {
tracing::warn!("MCP accept error: {}", e);
continue;
}
};
let cfg = cfg.clone();
let contract = contract.clone();
tokio::spawn(async move {
if let Err(e) = Self::handle_connection(stream, cfg, contract).await {
tracing::debug!(%peer, "MCP connection error: {}", e);
}
});
}
}
async fn handle_connection(
mut stream: tokio::net::TcpStream,
cfg: Arc<McpConfig>,
contract: Arc<Option<AgentContract>>,
) -> Result<()> {
use crate::http_util;
let (reader, mut writer) = stream.split();
let mut reader = BufReader::new(reader);
let deadline = tokio::time::Instant::now() + http_util::HTTP_READ_TIMEOUT;
let head = match http_util::read_head(&mut reader, deadline).await {
Ok(h) => h,
Err(_) => return Ok(()), };
if let Some(tok) = cfg.auth_token.as_ref() {
let ok = head
.header("authorization")
.and_then(|v| v.strip_prefix("Bearer "))
.map(|got| http_util::constant_time_eq_str(got, tok))
.unwrap_or(false);
if !ok {
Self::write_http(
&mut writer,
401,
"application/json",
br#"{"error":"unauthorized"}"#,
)
.await?;
return Ok(());
}
}
if head.content_length > http_util::MAX_HTTP_BODY_BYTES {
Self::write_http(
&mut writer,
413,
"application/json",
br#"{"error":"request body too large"}"#,
)
.await?;
return Ok(());
}
let body = match http_util::read_body(&mut reader, head.content_length, deadline).await {
Ok(b) => String::from_utf8_lossy(&b).to_string(),
Err(_) => return Ok(()),
};
let response = Self::dispatch(&body, &cfg, (*contract).as_ref()).await;
match response {
Some(v) => {
let payload = serde_json::to_string(&v).unwrap_or_else(|_| "{}".to_string());
Self::write_http(&mut writer, 200, "application/json", payload.as_bytes()).await
}
None => Self::write_http(&mut writer, 202, "application/json", b"").await,
}
}
async fn dispatch(
body: &str,
cfg: &McpConfig,
contract: Option<&AgentContract>,
) -> Option<Value> {
let req: Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(e) => {
return Some(rpc_error(
Value::Null,
-32700,
&format!("parse error: {}", e),
))
}
};
let id = req.get("id").cloned().unwrap_or(Value::Null);
let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
let params = req.get("params").cloned().unwrap_or(json!({}));
match method {
"initialize" => Some(rpc_ok(
id,
json!({
"protocolVersion": MCP_PROTOCOL_VERSION,
"serverInfo": { "name": "heliosproxy-mcp", "version": crate::VERSION },
"capabilities": { "tools": { "listChanged": false } }
}),
)),
"notifications/initialized" | "notifications/cancelled" => None,
"ping" => Some(rpc_ok(id, json!({}))),
"tools/list" => Some(rpc_ok(id, json!({ "tools": Self::tool_defs(cfg) }))),
"tools/call" => Some(Self::handle_tool_call(id, ¶ms, cfg, contract).await),
other => Some(rpc_error(
id,
-32601,
&format!("method not found: {}", other),
)),
}
}
fn tool_defs(cfg: &McpConfig) -> Value {
let query_desc = if cfg.read_only {
"Run a read-only SQL query and return rows. Writes/DDL are refused."
} else {
"Run a SQL query and return rows (or the command tag for writes)."
};
json!([
{
"name": "query",
"description": query_desc,
"inputSchema": {
"type": "object",
"properties": { "sql": { "type": "string", "description": "SQL to execute" } },
"required": ["sql"]
}
},
{
"name": "list_tables",
"description": "List user tables (schema.table) in the connected database.",
"inputSchema": { "type": "object", "properties": {} }
},
{
"name": "explain",
"description": "Return the query plan for a SQL statement (EXPLAIN).",
"inputSchema": {
"type": "object",
"properties": { "sql": { "type": "string" } },
"required": ["sql"]
}
}
])
}
async fn handle_tool_call(
id: Value,
params: &Value,
cfg: &McpConfig,
contract: Option<&AgentContract>,
) -> Value {
let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or(json!({}));
let result: std::result::Result<String, String> = match name {
"query" => {
let sql = args
.get("sql")
.and_then(|s| s.as_str())
.unwrap_or("")
.trim();
if sql.is_empty() {
Err("missing 'sql'".to_string())
} else {
match Self::check_policy(cfg, contract, sql) {
Err(hint) => Err(hint),
Ok(()) => Self::run_sql(cfg, sql, effective_read_only(cfg, contract))
.await
.map(|r| format_result(&r)),
}
}
}
"list_tables" => {
let sql = "SELECT table_schema, table_name FROM information_schema.tables \
WHERE table_schema NOT IN ('pg_catalog','information_schema') \
ORDER BY table_schema, table_name";
Self::run_sql(cfg, sql, effective_read_only(cfg, contract))
.await
.map(|r| format_result(&r))
}
"explain" => {
let sql = args
.get("sql")
.and_then(|s| s.as_str())
.unwrap_or("")
.trim();
if sql.is_empty() {
Err("missing 'sql'".to_string())
} else {
match Self::check_policy(cfg, contract, sql) {
Err(hint) => Err(hint),
Ok(()) => Self::run_sql(
cfg,
&format!("EXPLAIN {}", sql),
effective_read_only(cfg, contract),
)
.await
.map(|r| format_result(&r)),
}
}
}
other => Err(format!("unknown tool: {}", other)),
};
match result {
Ok(text) => {
tracing::info!(tool = %name, "MCP tool call ok");
rpc_ok(
id,
json!({ "content": [{ "type": "text", "text": text }], "isError": false }),
)
}
Err(e) => {
tracing::info!(tool = %name, error = %e, "MCP tool call error");
rpc_ok(
id,
json!({ "content": [{ "type": "text", "text": e }], "isError": true }),
)
}
}
}
fn check_policy(
cfg: &McpConfig,
contract: Option<&AgentContract>,
sql: &str,
) -> std::result::Result<(), String> {
let stmts = split_statements(sql);
if stmts.len() > 1 {
return Err("multiple SQL statements are not permitted over the MCP \
gateway; send one statement per call"
.to_string());
}
let stmt = stmts.first().copied().unwrap_or(sql).trim();
if let Some(c) = contract {
agent_contract::validate(stmt, c).map_err(|v| v.to_json())?;
if c.read_only && has_data_modifying_cte(stmt) {
return Err("write via data-modifying CTE refused: this agent \
contract is read-only"
.to_string());
}
Ok(())
} else if cfg.read_only && (is_write_sql(stmt) || has_data_modifying_cte(stmt)) {
Err("write/DDL refused: the MCP gateway is read-only".to_string())
} else {
Ok(())
}
}
async fn run_sql(
cfg: &McpConfig,
sql: &str,
read_only: bool,
) -> std::result::Result<QueryResult, String> {
let bcfg = BackendConfig {
host: cfg.backend_host.clone(),
port: cfg.backend_port,
user: cfg.backend_user.clone(),
password: cfg.backend_password.clone(),
database: cfg.backend_database.clone(),
application_name: Some("heliosproxy-mcp".to_string()),
tls_mode: TlsMode::Disable,
connect_timeout: Duration::from_secs(5),
query_timeout: Duration::from_secs(30),
tls_config: default_client_config(),
};
let mut client = BackendClient::connect(&bcfg)
.await
.map_err(|e| format!("backend connect: {}", e))?;
if read_only {
if let Err(e) = client
.execute("SET default_transaction_read_only = on")
.await
{
client.close().await;
return Err(format!("failed to enforce read-only mode: {}", e));
}
}
let res = client.simple_query(sql).await.map_err(|e| format!("{}", e));
client.close().await;
res
}
async fn write_http(
writer: &mut tokio::net::tcp::WriteHalf<'_>,
status: u16,
content_type: &str,
body: &[u8],
) -> Result<()> {
let head = format!(
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
status,
if status == 200 { "OK" } else { "Accepted" },
content_type,
body.len()
);
writer
.write_all(head.as_bytes())
.await
.map_err(|e| ProxyError::Network(format!("MCP write: {}", e)))?;
if !body.is_empty() {
writer
.write_all(body)
.await
.map_err(|e| ProxyError::Network(format!("MCP write: {}", e)))?;
}
Ok(())
}
}
fn rpc_ok(id: Value, result: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
fn rpc_error(id: Value, code: i32, message: &str) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
}
fn format_result(r: &QueryResult) -> String {
if r.columns.is_empty() {
return r.command_tag.clone();
}
let header: Vec<&str> = r.columns.iter().map(|c| c.name.as_str()).collect();
let mut out = String::new();
out.push_str(&header.join(" | "));
out.push('\n');
for row in &r.rows {
let cells: Vec<String> = row
.iter()
.map(|v| match v {
TextValue::Null => "NULL".to_string(),
TextValue::Text(s) => s.clone(),
})
.collect();
out.push_str(&cells.join(" | "));
out.push('\n');
}
out.push_str(&format!("({} rows)", r.rows.len()));
out
}
fn is_write_sql(sql: &str) -> bool {
use crate::protocol::starts_with_ci;
let s = sql.trim_start();
for kw in [
"INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", "TRUNCATE", "GRANT", "REVOKE",
"COPY", "MERGE", "CALL", "DO", "VACUUM", "REINDEX", "CLUSTER", "LOCK", "COMMENT", "SET",
"RESET",
] {
if starts_with_ci(s, kw) {
return true;
}
}
false
}
fn effective_read_only(cfg: &McpConfig, contract: Option<&AgentContract>) -> bool {
cfg.read_only || contract.is_some_and(|c| c.read_only)
}
#[inline]
fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
#[inline]
fn is_ident_cont(b: u8) -> bool {
is_word_byte(b) || b == b'$' || b >= 0x80
}
fn skip_single_quoted(bytes: &[u8], i: usize) -> usize {
let escape_string = i >= 1
&& (bytes[i - 1] == b'E' || bytes[i - 1] == b'e')
&& (i < 2 || (bytes[i - 2] < 0x80 && !is_word_byte(bytes[i - 2])));
let mut j = i + 1;
while j < bytes.len() {
match bytes[j] {
b'\\' if escape_string => {
j += 2; continue;
}
b'\'' => {
if j + 1 < bytes.len() && bytes[j + 1] == b'\'' {
j += 2; continue;
}
return j + 1;
}
_ => j += 1,
}
}
bytes.len()
}
fn skip_double_quoted(bytes: &[u8], i: usize) -> usize {
let mut j = i + 1;
while j < bytes.len() {
if bytes[j] == b'"' {
if j + 1 < bytes.len() && bytes[j + 1] == b'"' {
j += 2;
continue;
}
return j + 1;
}
j += 1;
}
bytes.len()
}
fn skip_line_comment(bytes: &[u8], i: usize) -> usize {
let mut j = i + 2;
while j < bytes.len() && bytes[j] != b'\n' {
j += 1;
}
j
}
fn skip_block_comment(bytes: &[u8], i: usize) -> usize {
let mut depth = 0usize;
let mut j = i;
while j + 1 < bytes.len() {
if bytes[j] == b'/' && bytes[j + 1] == b'*' {
depth += 1;
j += 2;
} else if bytes[j] == b'*' && bytes[j + 1] == b'/' {
depth -= 1;
j += 2;
if depth == 0 {
return j;
}
} else {
j += 1;
}
}
bytes.len()
}
fn skip_dollar_quoted(bytes: &[u8], i: usize) -> Option<usize> {
let mut j = i + 1;
if j < bytes.len() && bytes[j] != b'$' {
let c = bytes[j];
if !(c.is_ascii_alphabetic() || c == b'_') {
return None; }
j += 1;
while j < bytes.len() && bytes[j] != b'$' {
if !is_word_byte(bytes[j]) {
return None; }
j += 1;
}
}
if j >= bytes.len() || bytes[j] != b'$' {
return None; }
let tag = &bytes[i..=j]; let mut k = j + 1;
while k + tag.len() <= bytes.len() {
if &bytes[k..k + tag.len()] == tag {
return Some(k + tag.len());
}
k += 1;
}
Some(bytes.len()) }
fn split_statements(sql: &str) -> Vec<&str> {
let bytes = sql.as_bytes();
let mut out = Vec::new();
let mut start = 0usize;
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\'' => i = skip_single_quoted(bytes, i),
b'"' => i = skip_double_quoted(bytes, i),
b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => i = skip_line_comment(bytes, i),
b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => i = skip_block_comment(bytes, i),
b'$' if i == 0 || !is_ident_cont(bytes[i - 1]) => match skip_dollar_quoted(bytes, i) {
Some(end) => i = end,
None => i += 1,
},
b';' => {
let seg = sql[start..i].trim();
if !seg.is_empty() {
out.push(seg);
}
start = i + 1;
i += 1;
}
_ => i += 1,
}
}
let seg = sql[start..].trim();
if !seg.is_empty() {
out.push(seg);
}
out
}
fn has_data_modifying_cte(stmt: &str) -> bool {
use crate::protocol::starts_with_ci;
if !starts_with_ci(stmt.trim_start(), "WITH") {
return false;
}
let bytes = stmt.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\'' => i = skip_single_quoted(bytes, i),
b'"' => i = skip_double_quoted(bytes, i),
b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => i = skip_line_comment(bytes, i),
b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => i = skip_block_comment(bytes, i),
b'$' if i == 0 || !is_ident_cont(bytes[i - 1]) => match skip_dollar_quoted(bytes, i) {
Some(end) => i = end,
None => i += 1,
},
c if c.is_ascii_alphabetic() => {
let at_word_start = i == 0 || !is_word_byte(bytes[i - 1]);
if at_word_start {
for kw in ["INSERT", "UPDATE", "DELETE", "MERGE"] {
if matches_keyword(bytes, i, kw.as_bytes()) {
return true;
}
}
}
i += 1;
}
_ => i += 1,
}
}
false
}
fn matches_keyword(bytes: &[u8], i: usize, kw: &[u8]) -> bool {
let end = i + kw.len();
if end > bytes.len() {
return false;
}
if !bytes[i..end].eq_ignore_ascii_case(kw) {
return false;
}
end == bytes.len() || !is_word_byte(bytes[end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_only_guardrail() {
assert!(is_write_sql("INSERT INTO t VALUES (1)"));
assert!(is_write_sql(" drop table t"));
assert!(is_write_sql("CREATE TABLE t(x int)"));
assert!(is_write_sql("SET default_transaction_read_only=off"));
assert!(is_write_sql("RESET default_transaction_read_only"));
assert!(is_write_sql("reset all"));
assert!(!is_write_sql("SELECT * FROM t"));
assert!(!is_write_sql(" with x as (select 1) select * from x"));
}
fn cfg_ro(read_only: bool) -> McpConfig {
McpConfig {
read_only,
..McpConfig::default()
}
}
#[test]
fn split_statements_single_and_trailing_semicolon() {
assert_eq!(split_statements("SELECT 1"), vec!["SELECT 1"]);
assert_eq!(split_statements("SELECT 1;"), vec!["SELECT 1"]);
assert_eq!(
split_statements("SELECT 1; DROP TABLE t"),
vec!["SELECT 1", "DROP TABLE t"]
);
assert!(split_statements(" ").is_empty());
assert!(split_statements(";;").is_empty());
}
#[test]
fn split_statements_semicolon_inside_single_quotes() {
assert_eq!(split_statements("SELECT ';' AS s"), vec!["SELECT ';' AS s"]);
assert_eq!(
split_statements("SELECT 'a;''b;c' AS s"),
vec!["SELECT 'a;''b;c' AS s"]
);
}
#[test]
fn split_statements_escape_string_backslash_quote() {
assert_eq!(
split_statements(r"SELECT E'a\';b' AS s"),
vec![r"SELECT E'a\';b' AS s"]
);
assert_eq!(
split_statements(r"select e'x\';drop' AS s"),
vec![r"select e'x\';drop' AS s"]
);
assert_eq!(
split_statements(r"SELECT 'a\'; DROP TABLE t"),
vec![r"SELECT 'a\'", "DROP TABLE t"]
);
assert_eq!(
split_statements(r"SELECT xE'a\'; DROP TABLE t"),
vec![r"SELECT xE'a\'", "DROP TABLE t"]
);
}
#[test]
fn split_statements_semicolon_inside_double_quotes() {
assert_eq!(
split_statements(r#"SELECT 1 AS "a;b""#),
vec![r#"SELECT 1 AS "a;b""#]
);
}
#[test]
fn split_statements_semicolon_inside_dollar_quotes() {
assert_eq!(
split_statements("SELECT $$a;b$$ AS s"),
vec!["SELECT $$a;b$$ AS s"]
);
assert_eq!(
split_statements("SELECT $tag$a;b$tag$ AS s"),
vec!["SELECT $tag$a;b$tag$ AS s"]
);
assert_eq!(
split_statements("SELECT $1; DROP TABLE t"),
vec!["SELECT $1", "DROP TABLE t"]
);
}
#[test]
fn split_statements_dollar_in_identifier_does_not_hide_semicolon() {
assert_eq!(
split_statements("SELECT 1 AS a$x$ ; DROP TABLE t"),
vec!["SELECT 1 AS a$x$", "DROP TABLE t"]
);
assert_eq!(
split_statements("SELECT 1 AS a$$ ; DELETE FROM t"),
vec!["SELECT 1 AS a$$", "DELETE FROM t"]
);
assert_eq!(
split_statements("SELECT 1 AS a$qz$ ; TRUNCATE users"),
vec!["SELECT 1 AS a$qz$", "TRUNCATE users"]
);
assert_eq!(
split_statements("SELECT 1 AS a$$b$$ ; DROP TABLE t"),
vec!["SELECT 1 AS a$$b$$", "DROP TABLE t"]
);
assert_eq!(
split_statements("SELECT 1 AS a$$x$$ ; TRUNCATE users"),
vec!["SELECT 1 AS a$$x$$", "TRUNCATE users"]
);
assert_eq!(
split_statements("SELECT 1 AS é$$ ; DROP TABLE t"),
vec!["SELECT 1 AS é$$", "DROP TABLE t"]
);
assert_eq!(
split_statements("SELECT 1 AS naïve$$x$$ ; DELETE FROM t"),
vec!["SELECT 1 AS naïve$$x$$", "DELETE FROM t"]
);
assert_eq!(
split_statements("SELECT $$a;b$$ AS s ; SELECT 2"),
vec!["SELECT $$a;b$$ AS s", "SELECT 2"]
);
assert_eq!(
split_statements("SELECT $q$a;b$q$ AS s ; SELECT 2"),
vec!["SELECT $q$a;b$q$ AS s", "SELECT 2"]
);
}
#[test]
fn split_statements_semicolon_inside_comments() {
assert_eq!(
split_statements("SELECT 1 -- a;b\n"),
vec!["SELECT 1 -- a;b"]
);
assert_eq!(
split_statements("SELECT 1 /* a;b */ FROM t"),
vec!["SELECT 1 /* a;b */ FROM t"]
);
assert_eq!(
split_statements("SELECT 1 /* a /* b;c */ d;e */ FROM t"),
vec!["SELECT 1 /* a /* b;c */ d;e */ FROM t"]
);
}
#[test]
fn has_data_modifying_cte_detects_writes() {
assert!(has_data_modifying_cte(
"WITH x AS (INSERT INTO t VALUES(1) RETURNING *) SELECT * FROM x"
));
assert!(has_data_modifying_cte(
"with recursive r as (delete from t returning *) select * from r"
));
assert!(!has_data_modifying_cte(
"WITH x AS (SELECT 1) SELECT * FROM x"
));
assert!(!has_data_modifying_cte("INSERT INTO t VALUES(1)"));
assert!(!has_data_modifying_cte(
"WITH x AS (SELECT deleted_at, insert_col FROM t) SELECT * FROM x"
));
assert!(!has_data_modifying_cte(
"WITH x AS (SELECT 'delete me' AS s) SELECT * FROM x"
));
assert!(has_data_modifying_cte(
"WITH t AS (SELECT 1 AS a$x$), w AS (INSERT INTO logs VALUES(1) RETURNING *) SELECT * FROM w"
));
assert!(has_data_modifying_cte(
"WITH t AS (SELECT 1 AS a$$), w AS (DELETE FROM logs RETURNING *) SELECT * FROM w"
));
assert!(has_data_modifying_cte(
"WITH t AS (SELECT 1 AS a$$b$$), w AS (INSERT INTO logs VALUES(1) RETURNING *) SELECT * FROM w"
));
assert!(!has_data_modifying_cte(
"WITH x AS (SELECT $$insert$$ AS s) SELECT * FROM x"
));
}
#[test]
fn check_policy_read_only_rejects_bypasses() {
let cfg = cfg_ro(true);
for sql in [
"SELECT 1; DROP TABLE t",
"select 1 ; drop table t",
"SELECT 1 AS a$x$ ; DROP TABLE t",
"SELECT 1 AS a$$ ; TRUNCATE users",
"SELECT 1 AS a$$b$$ ; DROP TABLE t",
"SELECT 1 AS a$$x$$ ; DELETE FROM t",
"SELECT 1 AS é$$ ; DROP TABLE t",
"SELECT 1 AS a$x$ ; COMMIT ; SET default_transaction_read_only=off ; INSERT INTO t VALUES(1)",
"WITH x AS (INSERT INTO t VALUES(1) RETURNING *) SELECT * FROM x",
"with recursive r as (delete from t returning *) select * from r",
"SET default_transaction_read_only=off",
"RESET default_transaction_read_only",
"RESET ALL",
] {
assert!(
McpServer::check_policy(&cfg, None, sql).is_err(),
"expected rejection for: {sql}"
);
}
}
#[test]
fn check_policy_read_only_admits_safe() {
let cfg = cfg_ro(true);
for sql in [
"SELECT * FROM t",
"WITH x AS (SELECT 1) SELECT * FROM x",
"SELECT ';' AS s",
"SELECT 'delete me' AS s",
"SELECT $$a;b$$ AS s",
"SELECT $tag$a;b$tag$ AS s",
"SELECT 1 AS a$x$",
"SELECT 1 AS a$$b$$",
"SELECT 1 AS é$$",
r"SELECT E'a\';b' AS s",
] {
assert!(
McpServer::check_policy(&cfg, None, sql).is_ok(),
"expected admit for: {sql}"
);
}
}
#[test]
fn check_policy_rejects_multi_statement_even_with_permissive_contract() {
let contract = AgentContract {
id: "writer".into(),
read_only: false,
allowed_verbs: Some(vec!["INSERT".into(), "SELECT".into()]),
allowed_tables: None,
denied_tables: vec![],
require_predicate_on: vec![],
require_limit: false,
max_rows: None,
};
let cfg = cfg_ro(false);
assert!(McpServer::check_policy(
&cfg,
Some(&contract),
"INSERT INTO a VALUES(1); DROP TABLE b"
)
.is_err());
for sql in [
"SELECT 1 AS a$x$ ; DROP TABLE b",
"SELECT 1 AS a$$ ; TRUNCATE b",
"SELECT 1 AS a$qz$ ; INSERT INTO b VALUES(1)",
"SELECT 1 AS a$$b$$ ; DROP TABLE b",
"SELECT 1 AS a$$x$$ ; TRUNCATE b",
"SELECT 1 AS é$$ ; DROP TABLE b",
] {
assert!(
McpServer::check_policy(&cfg, Some(&contract), sql).is_err(),
"expected multi-statement rejection for: {sql}"
);
}
assert!(McpServer::check_policy(&cfg, Some(&contract), "INSERT INTO a VALUES(1)").is_ok());
}
#[test]
fn effective_read_only_decision() {
let ro = cfg_ro(true);
let rw = cfg_ro(false);
assert!(effective_read_only(&ro, None));
assert!(!effective_read_only(&rw, None));
let ro_contract = AgentContract {
id: "r".into(),
read_only: true,
allowed_verbs: None,
allowed_tables: None,
denied_tables: vec![],
require_predicate_on: vec![],
require_limit: false,
max_rows: None,
};
assert!(effective_read_only(&rw, Some(&ro_contract)));
}
#[tokio::test]
async fn initialize_and_tools_list() {
let cfg = McpConfig::default();
let init = McpServer::dispatch(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
&cfg,
None,
)
.await
.unwrap();
assert_eq!(init["result"]["protocolVersion"], MCP_PROTOCOL_VERSION);
assert_eq!(init["result"]["serverInfo"]["name"], "heliosproxy-mcp");
let tools = McpServer::dispatch(
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
&cfg,
None,
)
.await
.unwrap();
let names: Vec<&str> = tools["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"query"));
assert!(names.contains(&"list_tables"));
assert!(names.contains(&"explain"));
}
#[tokio::test]
async fn notification_has_no_response() {
let cfg = McpConfig::default();
let r = McpServer::dispatch(
r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
&cfg,
None,
)
.await;
assert!(r.is_none());
}
#[tokio::test]
async fn read_only_blocks_write_tool_call() {
let cfg = McpConfig::default(); let r = McpServer::dispatch(
r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"query","arguments":{"sql":"DELETE FROM t"}}}"#,
&cfg,
None,
)
.await
.unwrap();
assert_eq!(r["result"]["isError"], true);
assert!(r["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("read-only"));
}
}