1use std::sync::Arc;
16use std::time::Duration;
17
18use serde_json::{json, Value};
19use tokio::io::{AsyncWriteExt, BufReader};
20use tokio::net::TcpListener;
21
22use crate::agent_contract::{self, AgentContract};
23use crate::backend::client::QueryResult;
24use crate::backend::types::TextValue;
25use crate::backend::{tls::default_client_config, BackendClient, BackendConfig, TlsMode};
26use crate::config::McpConfig;
27use crate::{ProxyError, Result};
28
29const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
30
31pub struct McpServer {
33 config: McpConfig,
34 contract: Option<AgentContract>,
35}
36
37impl McpServer {
38 pub fn new(config: McpConfig, contract: Option<AgentContract>) -> Self {
39 Self { config, contract }
40 }
41
42 pub async fn run(self) -> Result<()> {
44 let listener = TcpListener::bind(&self.config.listen_address)
45 .await
46 .map_err(|e| {
47 ProxyError::Network(format!("MCP bind {}: {}", self.config.listen_address, e))
48 })?;
49 tracing::info!(addr = %self.config.listen_address, read_only = self.config.read_only,
50 contract = ?self.contract.as_ref().map(|c| &c.id), "MCP agent gateway listening");
51 let cfg = Arc::new(self.config);
52 let contract = Arc::new(self.contract);
53 loop {
54 let (stream, peer) = match listener.accept().await {
55 Ok(x) => x,
56 Err(e) => {
57 tracing::warn!("MCP accept error: {}", e);
58 continue;
59 }
60 };
61 let cfg = cfg.clone();
62 let contract = contract.clone();
63 tokio::spawn(async move {
64 if let Err(e) = Self::handle_connection(stream, cfg, contract).await {
65 tracing::debug!(%peer, "MCP connection error: {}", e);
66 }
67 });
68 }
69 }
70
71 async fn handle_connection(
72 mut stream: tokio::net::TcpStream,
73 cfg: Arc<McpConfig>,
74 contract: Arc<Option<AgentContract>>,
75 ) -> Result<()> {
76 use crate::http_util;
77 let (reader, mut writer) = stream.split();
78 let mut reader = BufReader::new(reader);
79
80 let deadline = tokio::time::Instant::now() + http_util::HTTP_READ_TIMEOUT;
82 let head = match http_util::read_head(&mut reader, deadline).await {
83 Ok(h) => h,
84 Err(_) => return Ok(()), };
86
87 if let Some(tok) = cfg.auth_token.as_ref() {
92 let ok = head
93 .header("authorization")
94 .and_then(|v| v.strip_prefix("Bearer "))
95 .map(|got| http_util::constant_time_eq_str(got, tok))
96 .unwrap_or(false);
97 if !ok {
98 Self::write_http(
99 &mut writer,
100 401,
101 "application/json",
102 br#"{"error":"unauthorized"}"#,
103 )
104 .await?;
105 return Ok(());
106 }
107 }
108
109 if head.content_length > http_util::MAX_HTTP_BODY_BYTES {
111 Self::write_http(
112 &mut writer,
113 413,
114 "application/json",
115 br#"{"error":"request body too large"}"#,
116 )
117 .await?;
118 return Ok(());
119 }
120 let body = match http_util::read_body(&mut reader, head.content_length, deadline).await {
121 Ok(b) => String::from_utf8_lossy(&b).to_string(),
122 Err(_) => return Ok(()),
123 };
124
125 let response = Self::dispatch(&body, &cfg, (*contract).as_ref()).await;
126 match response {
127 Some(v) => {
128 let payload = serde_json::to_string(&v).unwrap_or_else(|_| "{}".to_string());
129 Self::write_http(&mut writer, 200, "application/json", payload.as_bytes()).await
130 }
131 None => Self::write_http(&mut writer, 202, "application/json", b"").await,
133 }
134 }
135
136 async fn dispatch(
138 body: &str,
139 cfg: &McpConfig,
140 contract: Option<&AgentContract>,
141 ) -> Option<Value> {
142 let req: Value = match serde_json::from_str(body) {
143 Ok(v) => v,
144 Err(e) => {
145 return Some(rpc_error(
146 Value::Null,
147 -32700,
148 &format!("parse error: {}", e),
149 ))
150 }
151 };
152 let id = req.get("id").cloned().unwrap_or(Value::Null);
153 let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
154 let params = req.get("params").cloned().unwrap_or(json!({}));
155
156 match method {
157 "initialize" => Some(rpc_ok(
158 id,
159 json!({
160 "protocolVersion": MCP_PROTOCOL_VERSION,
161 "serverInfo": { "name": "heliosproxy-mcp", "version": crate::VERSION },
162 "capabilities": { "tools": { "listChanged": false } }
163 }),
164 )),
165 "notifications/initialized" | "notifications/cancelled" => None,
167 "ping" => Some(rpc_ok(id, json!({}))),
168 "tools/list" => Some(rpc_ok(id, json!({ "tools": Self::tool_defs(cfg) }))),
169 "tools/call" => Some(Self::handle_tool_call(id, ¶ms, cfg, contract).await),
170 other => Some(rpc_error(
171 id,
172 -32601,
173 &format!("method not found: {}", other),
174 )),
175 }
176 }
177
178 fn tool_defs(cfg: &McpConfig) -> Value {
179 let query_desc = if cfg.read_only {
180 "Run a read-only SQL query and return rows. Writes/DDL are refused."
181 } else {
182 "Run a SQL query and return rows (or the command tag for writes)."
183 };
184 json!([
185 {
186 "name": "query",
187 "description": query_desc,
188 "inputSchema": {
189 "type": "object",
190 "properties": { "sql": { "type": "string", "description": "SQL to execute" } },
191 "required": ["sql"]
192 }
193 },
194 {
195 "name": "list_tables",
196 "description": "List user tables (schema.table) in the connected database.",
197 "inputSchema": { "type": "object", "properties": {} }
198 },
199 {
200 "name": "explain",
201 "description": "Return the query plan for a SQL statement (EXPLAIN).",
202 "inputSchema": {
203 "type": "object",
204 "properties": { "sql": { "type": "string" } },
205 "required": ["sql"]
206 }
207 }
208 ])
209 }
210
211 async fn handle_tool_call(
212 id: Value,
213 params: &Value,
214 cfg: &McpConfig,
215 contract: Option<&AgentContract>,
216 ) -> Value {
217 let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
218 let args = params.get("arguments").cloned().unwrap_or(json!({}));
219
220 let result: std::result::Result<String, String> = match name {
221 "query" => {
222 let sql = args
223 .get("sql")
224 .and_then(|s| s.as_str())
225 .unwrap_or("")
226 .trim();
227 if sql.is_empty() {
228 Err("missing 'sql'".to_string())
229 } else {
230 match Self::check_policy(cfg, contract, sql) {
231 Err(hint) => Err(hint),
232 Ok(()) => Self::run_sql(cfg, sql, effective_read_only(cfg, contract))
233 .await
234 .map(|r| format_result(&r)),
235 }
236 }
237 }
238 "list_tables" => {
239 let sql = "SELECT table_schema, table_name FROM information_schema.tables \
240 WHERE table_schema NOT IN ('pg_catalog','information_schema') \
241 ORDER BY table_schema, table_name";
242 Self::run_sql(cfg, sql, effective_read_only(cfg, contract))
243 .await
244 .map(|r| format_result(&r))
245 }
246 "explain" => {
247 let sql = args
248 .get("sql")
249 .and_then(|s| s.as_str())
250 .unwrap_or("")
251 .trim();
252 if sql.is_empty() {
253 Err("missing 'sql'".to_string())
254 } else {
255 match Self::check_policy(cfg, contract, sql) {
256 Err(hint) => Err(hint),
257 Ok(()) => Self::run_sql(
261 cfg,
262 &format!("EXPLAIN {}", sql),
263 effective_read_only(cfg, contract),
264 )
265 .await
266 .map(|r| format_result(&r)),
267 }
268 }
269 }
270 other => Err(format!("unknown tool: {}", other)),
271 };
272
273 match result {
274 Ok(text) => {
275 tracing::info!(tool = %name, "MCP tool call ok");
276 rpc_ok(
277 id,
278 json!({ "content": [{ "type": "text", "text": text }], "isError": false }),
279 )
280 }
281 Err(e) => {
282 tracing::info!(tool = %name, error = %e, "MCP tool call error");
283 rpc_ok(
286 id,
287 json!({ "content": [{ "type": "text", "text": e }], "isError": true }),
288 )
289 }
290 }
291 }
292
293 fn check_policy(
310 cfg: &McpConfig,
311 contract: Option<&AgentContract>,
312 sql: &str,
313 ) -> std::result::Result<(), String> {
314 let stmts = split_statements(sql);
318 if stmts.len() > 1 {
319 return Err("multiple SQL statements are not permitted over the MCP \
320 gateway; send one statement per call"
321 .to_string());
322 }
323 let stmt = stmts.first().copied().unwrap_or(sql).trim();
326
327 if let Some(c) = contract {
328 agent_contract::validate(stmt, c).map_err(|v| v.to_json())?;
331 if c.read_only && has_data_modifying_cte(stmt) {
334 return Err("write via data-modifying CTE refused: this agent \
335 contract is read-only"
336 .to_string());
337 }
338 Ok(())
339 } else if cfg.read_only && (is_write_sql(stmt) || has_data_modifying_cte(stmt)) {
340 Err("write/DDL refused: the MCP gateway is read-only".to_string())
341 } else {
342 Ok(())
343 }
344 }
345
346 async fn run_sql(
363 cfg: &McpConfig,
364 sql: &str,
365 read_only: bool,
366 ) -> std::result::Result<QueryResult, String> {
367 let bcfg = BackendConfig {
368 host: cfg.backend_host.clone(),
369 port: cfg.backend_port,
370 user: cfg.backend_user.clone(),
371 password: cfg.backend_password.clone(),
372 database: cfg.backend_database.clone(),
373 application_name: Some("heliosproxy-mcp".to_string()),
374 tls_mode: TlsMode::Disable,
375 connect_timeout: Duration::from_secs(5),
376 query_timeout: Duration::from_secs(30),
377 tls_config: default_client_config(),
378 };
379 let mut client = BackendClient::connect(&bcfg)
380 .await
381 .map_err(|e| format!("backend connect: {}", e))?;
382 if read_only {
383 if let Err(e) = client
384 .execute("SET default_transaction_read_only = on")
385 .await
386 {
387 client.close().await;
388 return Err(format!("failed to enforce read-only mode: {}", e));
389 }
390 }
391 let res = client.simple_query(sql).await.map_err(|e| format!("{}", e));
392 client.close().await;
393 res
394 }
395
396 async fn write_http(
397 writer: &mut tokio::net::tcp::WriteHalf<'_>,
398 status: u16,
399 content_type: &str,
400 body: &[u8],
401 ) -> Result<()> {
402 let head = format!(
403 "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
404 status,
405 if status == 200 { "OK" } else { "Accepted" },
406 content_type,
407 body.len()
408 );
409 writer
410 .write_all(head.as_bytes())
411 .await
412 .map_err(|e| ProxyError::Network(format!("MCP write: {}", e)))?;
413 if !body.is_empty() {
414 writer
415 .write_all(body)
416 .await
417 .map_err(|e| ProxyError::Network(format!("MCP write: {}", e)))?;
418 }
419 Ok(())
420 }
421}
422
423fn rpc_ok(id: Value, result: Value) -> Value {
424 json!({ "jsonrpc": "2.0", "id": id, "result": result })
425}
426
427fn rpc_error(id: Value, code: i32, message: &str) -> Value {
428 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
429}
430
431fn format_result(r: &QueryResult) -> String {
433 if r.columns.is_empty() {
434 return r.command_tag.clone();
435 }
436 let header: Vec<&str> = r.columns.iter().map(|c| c.name.as_str()).collect();
437 let mut out = String::new();
438 out.push_str(&header.join(" | "));
439 out.push('\n');
440 for row in &r.rows {
441 let cells: Vec<String> = row
442 .iter()
443 .map(|v| match v {
444 TextValue::Null => "NULL".to_string(),
445 TextValue::Text(s) => s.clone(),
446 })
447 .collect();
448 out.push_str(&cells.join(" | "));
449 out.push('\n');
450 }
451 out.push_str(&format!("({} rows)", r.rows.len()));
452 out
453}
454
455fn is_write_sql(sql: &str) -> bool {
459 use crate::protocol::starts_with_ci;
460 let s = sql.trim_start();
461 for kw in [
462 "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", "TRUNCATE", "GRANT", "REVOKE",
463 "COPY", "MERGE", "CALL", "DO", "VACUUM", "REINDEX", "CLUSTER", "LOCK", "COMMENT", "SET",
464 "RESET",
465 ] {
466 if starts_with_ci(s, kw) {
467 return true;
468 }
469 }
470 false
471}
472
473fn effective_read_only(cfg: &McpConfig, contract: Option<&AgentContract>) -> bool {
478 cfg.read_only || contract.is_some_and(|c| c.read_only)
479}
480
481#[inline]
482fn is_word_byte(b: u8) -> bool {
483 b.is_ascii_alphanumeric() || b == b'_'
484}
485
486#[inline]
499fn is_ident_cont(b: u8) -> bool {
500 is_word_byte(b) || b == b'$' || b >= 0x80
501}
502
503fn skip_single_quoted(bytes: &[u8], i: usize) -> usize {
519 let escape_string = i >= 1
520 && (bytes[i - 1] == b'E' || bytes[i - 1] == b'e')
521 && (i < 2 || (bytes[i - 2] < 0x80 && !is_word_byte(bytes[i - 2])));
522 let mut j = i + 1;
523 while j < bytes.len() {
524 match bytes[j] {
525 b'\\' if escape_string => {
526 j += 2; continue;
528 }
529 b'\'' => {
530 if j + 1 < bytes.len() && bytes[j + 1] == b'\'' {
531 j += 2; continue;
533 }
534 return j + 1;
535 }
536 _ => j += 1,
537 }
538 }
539 bytes.len()
540}
541
542fn skip_double_quoted(bytes: &[u8], i: usize) -> usize {
545 let mut j = i + 1;
546 while j < bytes.len() {
547 if bytes[j] == b'"' {
548 if j + 1 < bytes.len() && bytes[j + 1] == b'"' {
549 j += 2;
550 continue;
551 }
552 return j + 1;
553 }
554 j += 1;
555 }
556 bytes.len()
557}
558
559fn skip_line_comment(bytes: &[u8], i: usize) -> usize {
562 let mut j = i + 2;
563 while j < bytes.len() && bytes[j] != b'\n' {
564 j += 1;
565 }
566 j
567}
568
569fn skip_block_comment(bytes: &[u8], i: usize) -> usize {
572 let mut depth = 0usize;
573 let mut j = i;
574 while j + 1 < bytes.len() {
575 if bytes[j] == b'/' && bytes[j + 1] == b'*' {
576 depth += 1;
577 j += 2;
578 } else if bytes[j] == b'*' && bytes[j + 1] == b'/' {
579 depth -= 1;
580 j += 2;
581 if depth == 0 {
582 return j;
583 }
584 } else {
585 j += 1;
586 }
587 }
588 bytes.len()
589}
590
591fn skip_dollar_quoted(bytes: &[u8], i: usize) -> Option<usize> {
597 let mut j = i + 1;
598 if j < bytes.len() && bytes[j] != b'$' {
600 let c = bytes[j];
601 if !(c.is_ascii_alphabetic() || c == b'_') {
602 return None; }
604 j += 1;
605 while j < bytes.len() && bytes[j] != b'$' {
606 if !is_word_byte(bytes[j]) {
607 return None; }
609 j += 1;
610 }
611 }
612 if j >= bytes.len() || bytes[j] != b'$' {
613 return None; }
615 let tag = &bytes[i..=j]; let mut k = j + 1;
617 while k + tag.len() <= bytes.len() {
618 if &bytes[k..k + tag.len()] == tag {
619 return Some(k + tag.len());
620 }
621 k += 1;
622 }
623 Some(bytes.len()) }
625
626fn split_statements(sql: &str) -> Vec<&str> {
632 let bytes = sql.as_bytes();
633 let mut out = Vec::new();
634 let mut start = 0usize;
635 let mut i = 0usize;
636 while i < bytes.len() {
637 match bytes[i] {
638 b'\'' => i = skip_single_quoted(bytes, i),
639 b'"' => i = skip_double_quoted(bytes, i),
640 b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => i = skip_line_comment(bytes, i),
641 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => i = skip_block_comment(bytes, i),
642 b'$' if i == 0 || !is_ident_cont(bytes[i - 1]) => match skip_dollar_quoted(bytes, i) {
652 Some(end) => i = end,
653 None => i += 1,
654 },
655 b';' => {
656 let seg = sql[start..i].trim();
657 if !seg.is_empty() {
658 out.push(seg);
659 }
660 start = i + 1;
661 i += 1;
662 }
663 _ => i += 1,
664 }
665 }
666 let seg = sql[start..].trim();
667 if !seg.is_empty() {
668 out.push(seg);
669 }
670 out
671}
672
673fn has_data_modifying_cte(stmt: &str) -> bool {
685 use crate::protocol::starts_with_ci;
686 if !starts_with_ci(stmt.trim_start(), "WITH") {
687 return false;
688 }
689 let bytes = stmt.as_bytes();
690 let mut i = 0usize;
691 while i < bytes.len() {
692 match bytes[i] {
693 b'\'' => i = skip_single_quoted(bytes, i),
694 b'"' => i = skip_double_quoted(bytes, i),
695 b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => i = skip_line_comment(bytes, i),
696 b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => i = skip_block_comment(bytes, i),
697 b'$' if i == 0 || !is_ident_cont(bytes[i - 1]) => match skip_dollar_quoted(bytes, i) {
702 Some(end) => i = end,
703 None => i += 1,
704 },
705 c if c.is_ascii_alphabetic() => {
706 let at_word_start = i == 0 || !is_word_byte(bytes[i - 1]);
707 if at_word_start {
708 for kw in ["INSERT", "UPDATE", "DELETE", "MERGE"] {
709 if matches_keyword(bytes, i, kw.as_bytes()) {
710 return true;
711 }
712 }
713 }
714 i += 1;
715 }
716 _ => i += 1,
717 }
718 }
719 false
720}
721
722fn matches_keyword(bytes: &[u8], i: usize, kw: &[u8]) -> bool {
726 let end = i + kw.len();
727 if end > bytes.len() {
728 return false;
729 }
730 if !bytes[i..end].eq_ignore_ascii_case(kw) {
731 return false;
732 }
733 end == bytes.len() || !is_word_byte(bytes[end])
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 #[test]
741 fn read_only_guardrail() {
742 assert!(is_write_sql("INSERT INTO t VALUES (1)"));
743 assert!(is_write_sql(" drop table t"));
744 assert!(is_write_sql("CREATE TABLE t(x int)"));
745 assert!(is_write_sql("SET default_transaction_read_only=off"));
747 assert!(is_write_sql("RESET default_transaction_read_only"));
748 assert!(is_write_sql("reset all"));
749 assert!(!is_write_sql("SELECT * FROM t"));
750 assert!(!is_write_sql(" with x as (select 1) select * from x"));
751 }
752
753 fn cfg_ro(read_only: bool) -> McpConfig {
754 McpConfig {
755 read_only,
756 ..McpConfig::default()
757 }
758 }
759
760 #[test]
761 fn split_statements_single_and_trailing_semicolon() {
762 assert_eq!(split_statements("SELECT 1"), vec!["SELECT 1"]);
763 assert_eq!(split_statements("SELECT 1;"), vec!["SELECT 1"]);
765 assert_eq!(
766 split_statements("SELECT 1; DROP TABLE t"),
767 vec!["SELECT 1", "DROP TABLE t"]
768 );
769 assert!(split_statements(" ").is_empty());
771 assert!(split_statements(";;").is_empty());
772 }
773
774 #[test]
775 fn split_statements_semicolon_inside_single_quotes() {
776 assert_eq!(split_statements("SELECT ';' AS s"), vec!["SELECT ';' AS s"]);
777 assert_eq!(
779 split_statements("SELECT 'a;''b;c' AS s"),
780 vec!["SELECT 'a;''b;c' AS s"]
781 );
782 }
783
784 #[test]
785 fn split_statements_escape_string_backslash_quote() {
786 assert_eq!(
791 split_statements(r"SELECT E'a\';b' AS s"),
792 vec![r"SELECT E'a\';b' AS s"]
793 );
794 assert_eq!(
795 split_statements(r"select e'x\';drop' AS s"),
796 vec![r"select e'x\';drop' AS s"]
797 );
798 assert_eq!(
803 split_statements(r"SELECT 'a\'; DROP TABLE t"),
804 vec![r"SELECT 'a\'", "DROP TABLE t"]
805 );
806 assert_eq!(
809 split_statements(r"SELECT xE'a\'; DROP TABLE t"),
810 vec![r"SELECT xE'a\'", "DROP TABLE t"]
811 );
812 }
813
814 #[test]
815 fn split_statements_semicolon_inside_double_quotes() {
816 assert_eq!(
817 split_statements(r#"SELECT 1 AS "a;b""#),
818 vec![r#"SELECT 1 AS "a;b""#]
819 );
820 }
821
822 #[test]
823 fn split_statements_semicolon_inside_dollar_quotes() {
824 assert_eq!(
825 split_statements("SELECT $$a;b$$ AS s"),
826 vec!["SELECT $$a;b$$ AS s"]
827 );
828 assert_eq!(
829 split_statements("SELECT $tag$a;b$tag$ AS s"),
830 vec!["SELECT $tag$a;b$tag$ AS s"]
831 );
832 assert_eq!(
834 split_statements("SELECT $1; DROP TABLE t"),
835 vec!["SELECT $1", "DROP TABLE t"]
836 );
837 }
838
839 #[test]
840 fn split_statements_dollar_in_identifier_does_not_hide_semicolon() {
841 assert_eq!(
847 split_statements("SELECT 1 AS a$x$ ; DROP TABLE t"),
848 vec!["SELECT 1 AS a$x$", "DROP TABLE t"]
849 );
850 assert_eq!(
851 split_statements("SELECT 1 AS a$$ ; DELETE FROM t"),
852 vec!["SELECT 1 AS a$$", "DELETE FROM t"]
853 );
854 assert_eq!(
855 split_statements("SELECT 1 AS a$qz$ ; TRUNCATE users"),
856 vec!["SELECT 1 AS a$qz$", "TRUNCATE users"]
857 );
858 assert_eq!(
864 split_statements("SELECT 1 AS a$$b$$ ; DROP TABLE t"),
865 vec!["SELECT 1 AS a$$b$$", "DROP TABLE t"]
866 );
867 assert_eq!(
868 split_statements("SELECT 1 AS a$$x$$ ; TRUNCATE users"),
869 vec!["SELECT 1 AS a$$x$$", "TRUNCATE users"]
870 );
871 assert_eq!(
875 split_statements("SELECT 1 AS é$$ ; DROP TABLE t"),
876 vec!["SELECT 1 AS é$$", "DROP TABLE t"]
877 );
878 assert_eq!(
879 split_statements("SELECT 1 AS naïve$$x$$ ; DELETE FROM t"),
880 vec!["SELECT 1 AS naïve$$x$$", "DELETE FROM t"]
881 );
882 assert_eq!(
885 split_statements("SELECT $$a;b$$ AS s ; SELECT 2"),
886 vec!["SELECT $$a;b$$ AS s", "SELECT 2"]
887 );
888 assert_eq!(
889 split_statements("SELECT $q$a;b$q$ AS s ; SELECT 2"),
890 vec!["SELECT $q$a;b$q$ AS s", "SELECT 2"]
891 );
892 }
893
894 #[test]
895 fn split_statements_semicolon_inside_comments() {
896 assert_eq!(
897 split_statements("SELECT 1 -- a;b\n"),
898 vec!["SELECT 1 -- a;b"]
899 );
900 assert_eq!(
901 split_statements("SELECT 1 /* a;b */ FROM t"),
902 vec!["SELECT 1 /* a;b */ FROM t"]
903 );
904 assert_eq!(
906 split_statements("SELECT 1 /* a /* b;c */ d;e */ FROM t"),
907 vec!["SELECT 1 /* a /* b;c */ d;e */ FROM t"]
908 );
909 }
910
911 #[test]
912 fn has_data_modifying_cte_detects_writes() {
913 assert!(has_data_modifying_cte(
914 "WITH x AS (INSERT INTO t VALUES(1) RETURNING *) SELECT * FROM x"
915 ));
916 assert!(has_data_modifying_cte(
917 "with recursive r as (delete from t returning *) select * from r"
918 ));
919 assert!(!has_data_modifying_cte(
921 "WITH x AS (SELECT 1) SELECT * FROM x"
922 ));
923 assert!(!has_data_modifying_cte("INSERT INTO t VALUES(1)"));
925 assert!(!has_data_modifying_cte(
927 "WITH x AS (SELECT deleted_at, insert_col FROM t) SELECT * FROM x"
928 ));
929 assert!(!has_data_modifying_cte(
931 "WITH x AS (SELECT 'delete me' AS s) SELECT * FROM x"
932 ));
933 assert!(has_data_modifying_cte(
937 "WITH t AS (SELECT 1 AS a$x$), w AS (INSERT INTO logs VALUES(1) RETURNING *) SELECT * FROM w"
938 ));
939 assert!(has_data_modifying_cte(
940 "WITH t AS (SELECT 1 AS a$$), w AS (DELETE FROM logs RETURNING *) SELECT * FROM w"
941 ));
942 assert!(has_data_modifying_cte(
946 "WITH t AS (SELECT 1 AS a$$b$$), w AS (INSERT INTO logs VALUES(1) RETURNING *) SELECT * FROM w"
947 ));
948 assert!(!has_data_modifying_cte(
951 "WITH x AS (SELECT $$insert$$ AS s) SELECT * FROM x"
952 ));
953 }
954
955 #[test]
956 fn check_policy_read_only_rejects_bypasses() {
957 let cfg = cfg_ro(true);
958 for sql in [
959 "SELECT 1; DROP TABLE t",
960 "select 1 ; drop table t",
961 "SELECT 1 AS a$x$ ; DROP TABLE t",
964 "SELECT 1 AS a$$ ; TRUNCATE users",
965 "SELECT 1 AS a$$b$$ ; DROP TABLE t",
966 "SELECT 1 AS a$$x$$ ; DELETE FROM t",
967 "SELECT 1 AS é$$ ; DROP TABLE t",
968 "SELECT 1 AS a$x$ ; COMMIT ; SET default_transaction_read_only=off ; INSERT INTO t VALUES(1)",
969 "WITH x AS (INSERT INTO t VALUES(1) RETURNING *) SELECT * FROM x",
970 "with recursive r as (delete from t returning *) select * from r",
971 "SET default_transaction_read_only=off",
972 "RESET default_transaction_read_only",
974 "RESET ALL",
975 ] {
976 assert!(
977 McpServer::check_policy(&cfg, None, sql).is_err(),
978 "expected rejection for: {sql}"
979 );
980 }
981 }
982
983 #[test]
984 fn check_policy_read_only_admits_safe() {
985 let cfg = cfg_ro(true);
986 for sql in [
987 "SELECT * FROM t",
988 "WITH x AS (SELECT 1) SELECT * FROM x",
989 "SELECT ';' AS s",
990 "SELECT 'delete me' AS s",
991 "SELECT $$a;b$$ AS s",
994 "SELECT $tag$a;b$tag$ AS s",
995 "SELECT 1 AS a$x$",
997 "SELECT 1 AS a$$b$$",
1000 "SELECT 1 AS é$$",
1003 r"SELECT E'a\';b' AS s",
1006 ] {
1007 assert!(
1008 McpServer::check_policy(&cfg, None, sql).is_ok(),
1009 "expected admit for: {sql}"
1010 );
1011 }
1012 }
1013
1014 #[test]
1015 fn check_policy_rejects_multi_statement_even_with_permissive_contract() {
1016 let contract = AgentContract {
1019 id: "writer".into(),
1020 read_only: false,
1021 allowed_verbs: Some(vec!["INSERT".into(), "SELECT".into()]),
1022 allowed_tables: None,
1023 denied_tables: vec![],
1024 require_predicate_on: vec![],
1025 require_limit: false,
1026 max_rows: None,
1027 };
1028 let cfg = cfg_ro(false);
1029 assert!(McpServer::check_policy(
1030 &cfg,
1031 Some(&contract),
1032 "INSERT INTO a VALUES(1); DROP TABLE b"
1033 )
1034 .is_err());
1035 for sql in [
1041 "SELECT 1 AS a$x$ ; DROP TABLE b",
1042 "SELECT 1 AS a$$ ; TRUNCATE b",
1043 "SELECT 1 AS a$qz$ ; INSERT INTO b VALUES(1)",
1044 "SELECT 1 AS a$$b$$ ; DROP TABLE b",
1045 "SELECT 1 AS a$$x$$ ; TRUNCATE b",
1046 "SELECT 1 AS é$$ ; DROP TABLE b",
1050 ] {
1051 assert!(
1052 McpServer::check_policy(&cfg, Some(&contract), sql).is_err(),
1053 "expected multi-statement rejection for: {sql}"
1054 );
1055 }
1056 assert!(McpServer::check_policy(&cfg, Some(&contract), "INSERT INTO a VALUES(1)").is_ok());
1058 }
1059
1060 #[test]
1061 fn effective_read_only_decision() {
1062 let ro = cfg_ro(true);
1063 let rw = cfg_ro(false);
1064 assert!(effective_read_only(&ro, None));
1065 assert!(!effective_read_only(&rw, None));
1066 let ro_contract = AgentContract {
1067 id: "r".into(),
1068 read_only: true,
1069 allowed_verbs: None,
1070 allowed_tables: None,
1071 denied_tables: vec![],
1072 require_predicate_on: vec![],
1073 require_limit: false,
1074 max_rows: None,
1075 };
1076 assert!(effective_read_only(&rw, Some(&ro_contract)));
1078 }
1079
1080 #[tokio::test]
1081 async fn initialize_and_tools_list() {
1082 let cfg = McpConfig::default();
1083 let init = McpServer::dispatch(
1084 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
1085 &cfg,
1086 None,
1087 )
1088 .await
1089 .unwrap();
1090 assert_eq!(init["result"]["protocolVersion"], MCP_PROTOCOL_VERSION);
1091 assert_eq!(init["result"]["serverInfo"]["name"], "heliosproxy-mcp");
1092
1093 let tools = McpServer::dispatch(
1094 r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
1095 &cfg,
1096 None,
1097 )
1098 .await
1099 .unwrap();
1100 let names: Vec<&str> = tools["result"]["tools"]
1101 .as_array()
1102 .unwrap()
1103 .iter()
1104 .map(|t| t["name"].as_str().unwrap())
1105 .collect();
1106 assert!(names.contains(&"query"));
1107 assert!(names.contains(&"list_tables"));
1108 assert!(names.contains(&"explain"));
1109 }
1110
1111 #[tokio::test]
1112 async fn notification_has_no_response() {
1113 let cfg = McpConfig::default();
1114 let r = McpServer::dispatch(
1115 r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
1116 &cfg,
1117 None,
1118 )
1119 .await;
1120 assert!(r.is_none());
1121 }
1122
1123 #[tokio::test]
1124 async fn read_only_blocks_write_tool_call() {
1125 let cfg = McpConfig::default(); let r = McpServer::dispatch(
1127 r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"query","arguments":{"sql":"DELETE FROM t"}}}"#,
1128 &cfg,
1129 None,
1130 )
1131 .await
1132 .unwrap();
1133 assert_eq!(r["result"]["isError"], true);
1134 assert!(r["result"]["content"][0]["text"]
1135 .as_str()
1136 .unwrap()
1137 .contains("read-only"));
1138 }
1139}