Skip to main content

heliosdb_proxy/
mcp.rs

1//! MCP (Model Context Protocol) agent gateway.
2//!
3//! When `[mcp] enabled = true`, the proxy exposes a native MCP server so AI
4//! agents call structured, policy-gated tools (`query`, `list_tables`,
5//! `explain`) instead of opening raw SQL connections. This is the AI-data-
6//! plane differentiator: every tool call goes through one auditable surface
7//! and a read-only-by-default guardrail, and runs over the proxy's backend
8//! PG-wire client so it is backend-agnostic (PostgreSQL or HeliosDB-Nano).
9//!
10//! Transport: JSON-RPC 2.0 over HTTP POST (the simplest MCP transport; an
11//! SSE/Streamable-HTTP upgrade is a follow-on). Methods implemented:
12//! `initialize`, `notifications/initialized`, `ping`, `tools/list`,
13//! `tools/call`.
14
15use 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
31/// The MCP gateway server.
32pub 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    /// Bind and serve the MCP HTTP endpoint until the task is dropped.
43    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        // Bounded request read: overall deadline + header count/byte caps.
81        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(()), // timeout / oversized headers / early close
85        };
86
87        // Bearer auth, when configured. Unlike the wire port there is no
88        // pass-through identity here — the gateway runs SQL under its own
89        // backend credentials — so an unauthenticated request must be refused.
90        // Constant-time comparison (no `==` oracle).
91        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        // Reject an oversized declared body BEFORE allocating for it.
110        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            // Notifications get a bare 202 with no JSON-RPC body.
132            None => Self::write_http(&mut writer, 202, "application/json", b"").await,
133        }
134    }
135
136    /// Dispatch one JSON-RPC request. Returns `None` for notifications.
137    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 (no id) — no response.
166            "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, &params, 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                        // Policy is checked against the RAW sql (so `EXPLAIN a;
258                        // DROP b` is caught as multi-statement); the `EXPLAIN `
259                        // prefix is applied only for execution.
260                        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                // Tool errors are reported in-band (isError) per MCP, not as a
284                // protocol error, so the agent can read + self-correct.
285                rpc_ok(
286                    id,
287                    json!({ "content": [{ "type": "text", "text": e }], "isError": true }),
288                )
289            }
290        }
291    }
292
293    /// Gate a SQL statement: when an agent contract is configured, validate
294    /// against it and return a structured JSON repair hint on violation;
295    /// otherwise apply the plain read-only guardrail.
296    ///
297    /// Both the contract path and the read-only path historically inspected
298    /// only the leading verb of the string, which two shapes bypass:
299    ///
300    /// 1. multi-statement batches (`SELECT 1; DROP TABLE t`) — PostgreSQL's
301    ///    simple-query protocol runs every `;`-separated statement, so the
302    ///    trailing write executes even though the head is a SELECT; this also
303    ///    defeats contract verb/table allow-lists.
304    /// 2. data-modifying CTEs (`WITH x AS (INSERT ... RETURNING *) SELECT ...`)
305    ///    — the head is WITH but the statement writes.
306    ///
307    /// We close both here (a lexical guard) and again in `run_sql` (a backend
308    /// READ ONLY backstop).
309    fn check_policy(
310        cfg: &McpConfig,
311        contract: Option<&AgentContract>,
312        sql: &str,
313    ) -> std::result::Result<(), String> {
314        // Universal: refuse multi-statement batches regardless of mode, since
315        // a second statement bypasses both the read-only check and contract
316        // allow-lists.
317        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        // The single statement to gate. If the splitter found none (e.g. a
324        // comment-only body), fall back to the raw trimmed input.
325        let stmt = stmts.first().copied().unwrap_or(sql).trim();
326
327        if let Some(c) = contract {
328            // `validate` only reads the leading verb; pass the single trimmed
329            // statement (not the raw multi-statement string).
330            agent_contract::validate(stmt, c).map_err(|v| v.to_json())?;
331            // `validate` covers the leading-verb write case but not a
332            // data-modifying CTE, so backstop that for a read-only contract.
333            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    /// Connect to the configured backend, run one statement, return rows.
347    ///
348    /// When `read_only` is set, the fresh per-call connection is put into
349    /// `default_transaction_read_only = on` BEFORE the user statement runs.
350    /// This is the hard backstop behind the lexical guard: even if the guard
351    /// has a gap, the backend refuses any write. It is safe against
352    /// re-enabling — the guard rejects any statement starting with `SET` or
353    /// `RESET` (in `is_write_sql`) and rejects multi-statement, and each call
354    /// gets a fresh connection, so a single user statement cannot turn the GUC
355    /// back off. The user statement runs as its OWN `simple_query` (not
356    /// concatenated) so result parsing / command_tag stay correct.
357    ///
358    /// Known limitation: `default_transaction_read_only` blocks DML/DDL but NOT
359    /// volatile functions with side effects (e.g. `SELECT nextval('s')`,
360    /// `setval`, superuser file/lo functions). Constrain the gateway's backend
361    /// role if that matters for your deployment.
362    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
431/// Render a QueryResult as a compact text table for the agent.
432fn 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
455/// First-keyword write/DDL detection (read-only guardrail). `SET`/`RESET` are
456/// included so that, in read-only mode, no single statement can flip the
457/// backend's `default_transaction_read_only` GUC back off (see `run_sql`).
458fn 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
473/// Effective read-only decision for the gateway: its own `read_only` OR a
474/// read-only agent contract. Both the lexical guard (`has_data_modifying_cte`
475/// application in `check_policy`) and the backend READ ONLY backstop in
476/// `run_sql` key off this. Pure so it can be unit-tested without a backend.
477fn 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/// PostgreSQL identifier-continuation byte: `[A-Za-z0-9_$]` AND every high-bit
487/// byte `0x80..=0xFF` (any UTF-8 byte of a non-ASCII identifier char such as
488/// `é`). Distinct from `is_word_byte` because `$` and the multibyte range are
489/// identifier-continuation chars but NOT valid dollar-quote tag bytes nor
490/// keyword-boundary word chars. Used ONLY by the dollar-quote-open
491/// token-boundary gates: a `$` opens a dollar quote only when it STARTS a token,
492/// and PG's lexer keeps `$` inside a running identifier — `a$$b$$` AND `é$$` are
493/// each ONE identifier, not a `$$…` dollar quote — so a `$` preceded by another
494/// `$` OR by a multibyte identifier byte must NOT be treated as an opener.
495/// Omitting the high-bit range reopens the statement-splitter under-count for a
496/// multibyte-led alias (`SELECT 1 AS é$$ ; DROP TABLE t`). Mirrors the `< 0x80`
497/// multibyte guard already used in `skip_single_quoted`.
498#[inline]
499fn is_ident_cont(b: u8) -> bool {
500    is_word_byte(b) || b == b'$' || b >= 0x80
501}
502
503/// `bytes[i]` is `'` — return the index just past the closing quote (or
504/// end-of-input if unterminated). `''` is an in-string escape, not a close.
505///
506/// If the string is an escape-string (`E'…'` / `e'…'` — a standalone `E`
507/// immediately before the quote), a backslash escapes the next byte, so `\'`
508/// does NOT close the string. Regular strings are treated as
509/// standard-conforming (PostgreSQL's default: backslash is literal, only `''`
510/// escapes), which is what the MCP gateway's fresh backend sessions use.
511///
512/// Detecting the `E` prefix EXACTLY matters for safety: a false positive would
513/// skip a real closing quote and could hide a top-level `;` (an under-count →
514/// bypass). So we require the preceding byte to be `E`/`e` AND the byte before
515/// THAT to be an ASCII non-word boundary — a multibyte identifier char before
516/// `E` (`éE'…'`, where `éE` is one identifier) is deliberately NOT treated as
517/// an escape-string, which at worst over-rejects (fail-closed, safe).
518fn 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; // backslash escapes the next byte in an E'…' string
527                continue;
528            }
529            b'\'' => {
530                if j + 1 < bytes.len() && bytes[j + 1] == b'\'' {
531                    j += 2; // doubled quote → literal, stay in string
532                    continue;
533                }
534                return j + 1;
535            }
536            _ => j += 1,
537        }
538    }
539    bytes.len()
540}
541
542/// `bytes[i]` is `"` — return the index just past the closing quote (or
543/// end-of-input if unterminated). `""` is an in-identifier escape.
544fn 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
559/// `bytes[i..]` starts a `--` line comment — return the index of the
560/// terminating newline (or end-of-input).
561fn 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
569/// `bytes[i..]` starts a `/*` block comment — return the index just past the
570/// matching `*/`. Postgres block comments NEST, so depth is tracked.
571fn 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
591/// If a dollar-quoted string opens at `i` (`bytes[i]` is `$`), return the
592/// index just past its matching closing delimiter; else `None`. Handles the
593/// empty tag (`$$...$$`) and named tags (`$tag$...$tag$`). A tag follows
594/// identifier rules (starts with a letter/underscore, then alphanumerics/
595/// underscores), so `$1` (a parameter placeholder) is NOT a dollar quote.
596fn skip_dollar_quoted(bytes: &[u8], i: usize) -> Option<usize> {
597    let mut j = i + 1;
598    // Parse an optional tag between the two `$`s.
599    if j < bytes.len() && bytes[j] != b'$' {
600        let c = bytes[j];
601        if !(c.is_ascii_alphabetic() || c == b'_') {
602            return None; // first tag char must not be a digit / symbol
603        }
604        j += 1;
605        while j < bytes.len() && bytes[j] != b'$' {
606            if !is_word_byte(bytes[j]) {
607                return None; // invalid tag char → not a dollar quote
608            }
609            j += 1;
610        }
611    }
612    if j >= bytes.len() || bytes[j] != b'$' {
613        return None; // no closing `$` for the opening tag
614    }
615    let tag = &bytes[i..=j]; // includes both delimiting `$`s
616    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()) // unterminated → consume to end
624}
625
626/// Split `sql` on top-level `;`, skipping semicolons that fall inside
627/// single-quoted strings, double-quoted identifiers, dollar-quoted strings,
628/// line comments (`-- … \n`), and (nested) block comments (`/* … */`).
629/// Returns trimmed, non-empty statement slices, so a trailing `;` does not
630/// produce an empty final statement.
631fn 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            // A `$` only opens a dollar-quote when it STARTS a token. PostgreSQL's
643            // lexer treats `$` as an identifier-continuation char (ident_cont =
644            // [A-Za-z_0-9$]), so `a$x$` AND `a$$b$$` lex as SINGLE identifiers,
645            // not as dollar-quote delimiters. The gate must therefore reject a `$`
646            // preceded by ANY ident_cont byte — INCLUDING another `$` (that is why
647            // `is_ident_cont`, not `is_word_byte`, is used here): otherwise the
648            // second `$` of `a$$b$$` is misread as a tag opener, `skip_dollar_quoted`
649            // runs off to EOF, and a real top-level `;` is hidden (e.g.
650            // `SELECT 1 AS a$$b$$ ; DROP TABLE t` would under-count to one statement).
651            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
673/// Does a single statement whose head is `WITH` contain a data-modifying
674/// sub-statement (INSERT/UPDATE/DELETE/MERGE) in code — i.e. OUTSIDE string
675/// literals, quoted identifiers, comments, and dollar-quoted strings? A
676/// leading-verb check misses `WITH x AS (INSERT … RETURNING *) SELECT …`,
677/// which writes, so this backstops it.
678///
679/// Only meaningful when the head is `WITH` (also `WITH RECURSIVE`); returns
680/// false otherwise. Matching is whole-word (ASCII boundaries) so `insert_col`
681/// / `deleted_at` do NOT match. Trade-off: a read-only WITH that mentions one
682/// of these words as an UNQUOTED identifier would be conservatively rejected;
683/// that favors safety and such an identifier would normally need quoting.
684fn 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            // Same token-boundary gate as `split_statements`: only treat `$` as a
698            // dollar-quote opener when it starts a token, so a `$` inside an
699            // identifier cannot swallow the rest of the statement and hide an
700            // INSERT/UPDATE/DELETE/MERGE keyword from this scan.
701            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
722/// Whole-word case-insensitive keyword match at `bytes[i..]`. Assumes the
723/// preceding byte is already known to be a word boundary; checks the trailing
724/// boundary here.
725fn 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        // SET/RESET are flagged so no single statement can flip the read-only GUC.
746        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        // A trailing `;` must NOT yield an empty final statement.
764        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        // Whitespace / empty inputs → no statements.
770        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        // '' escape keeps us inside the string across the semicolon.
778        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        // In an E'…' escape-string, `\'` is an escaped quote (NOT a close), so a
787        // `;` after it stays inside the string → ONE statement. Regressing this
788        // to over-reject (the pre-fix behavior) fails closed but breaks valid
789        // agents; verify it is admitted as a single statement.
790        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        // A REGULAR (non-E) string is standard-conforming: `\` is literal, so
799        // `'a\'` closes at the `'` and the `;` IS top-level → 2 statements.
800        // (The `E` detection must not leak into plain strings, or a real
801        // separator would be hidden — an under-count bypass.)
802        assert_eq!(
803            split_statements(r"SELECT 'a\'; DROP TABLE t"),
804            vec![r"SELECT 'a\'", "DROP TABLE t"]
805        );
806        // `someE'…'` — the `E` is the tail of an identifier, not an escape-string
807        // prefix, so backslash stays literal and the `'` after `\` closes.
808        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        // `$1` is a parameter, not a dollar quote — the `;` after it splits.
833        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        // PostgreSQL's lexer treats `$` as an identifier-continuation char, so
842        // `a$x$`, `a$$`, `a$qz$` are SINGLE identifiers — the `$` does NOT open a
843        // dollar quote. A guard that misreads it as a dollar-quote opener would
844        // swallow the real top-level `;` and the trailing write, under-counting
845        // statements and defeating the multi-statement guard.
846        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        // The `$$<tag>$$` adjacency: a `$` PRECEDED BY another `$` is still inside
859        // the identifier (ident_cont includes `$`), so `a$$b$$` is ONE identifier.
860        // The gate must use is_ident_cont (not is_word_byte, which omits `$`) or
861        // the second `$` is misread as a tag opener that runs to EOF and hides the
862        // top-level `;` (the real read_only/allow-list bypass this test guards).
863        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        // Multibyte-led alias: PG's ident_cont includes high-bit bytes, so `é$$`
872        // is ONE identifier. The gate must treat a `$` glued after a multibyte
873        // byte as still-inside-identifier, or it reopens the under-count.
874        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        // Guard must not over-fire: a genuine dollar-quote (opening `$` starts a
883        // token) still masks its inner `;` — both empty-tag and named-tag forms.
884        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        // Nested block comment: the inner `*/` must not end the outer one.
905        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        // Read-only CTE: no write keyword in code.
920        assert!(!has_data_modifying_cte(
921            "WITH x AS (SELECT 1) SELECT * FROM x"
922        ));
923        // Not a WITH head → never flagged (leading-verb guard handles it).
924        assert!(!has_data_modifying_cte("INSERT INTO t VALUES(1)"));
925        // Word-boundary: identifiers that merely contain the words don't match.
926        assert!(!has_data_modifying_cte(
927            "WITH x AS (SELECT deleted_at, insert_col FROM t) SELECT * FROM x"
928        ));
929        // The word appearing only inside a string literal doesn't match.
930        assert!(!has_data_modifying_cte(
931            "WITH x AS (SELECT 'delete me' AS s) SELECT * FROM x"
932        ));
933        // A `$` inside an identifier must NOT be misread as a dollar-quote
934        // opener; if it were, skip_dollar_quoted would consume to EOF and hide
935        // the INSERT keyword, wrongly reporting the CTE as read-only.
936        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        // The `$$<tag>$$` adjacency inside a CTE: a `$` after another `$` must not
943        // open a phantom dollar-quote that swallows the INSERT (the same
944        // under-count class as the split_statements bypass).
945        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        // Genuine dollar-quote (opening `$` starts a token) still masks the
949        // word inside it — no false positive.
950        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            // `$`-in-identifier under-count bypasses: these must still split to
962            // 2 and trip the multi-statement guard.
963            "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            // A lone RESET must not un-flip the read-only GUC either.
973            "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            // A genuine dollar-quote with an inner `;` is ONE statement — the
992            // token-boundary guard must not over-fire and split/reject it.
993            "SELECT $$a;b$$ AS s",
994            "SELECT $tag$a;b$tag$ AS s",
995            // A `$` inside an identifier is fine on its own — read-only SELECT.
996            "SELECT 1 AS a$x$",
997            // ...including the `$$`-adjacency shape: a lone `a$$b$$` is one
998            // identifier, so this is a single read-only statement, not a batch.
999            "SELECT 1 AS a$$b$$",
1000            // ...and a lone multibyte-led alias `é$$` — one identifier, admitted
1001            // (the ident_cont fix must not over-reject genuine single statements).
1002            "SELECT 1 AS é$$",
1003            // An E'…' escape-string whose escaped quote precedes a `;` is a
1004            // single read-only SELECT, not a batch — must be admitted.
1005            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        // Fully permissive contract (writes allowed) still must not let a
1017        // batch through — multi-statement defeats the allow-list model.
1018        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        // `$`-in-identifier under-count: with a permissive contract there is NO
1036        // read-only backstop, so the multi-statement guard is the only defense.
1037        // A leading SELECT passes `validate`; the guard must still reject. The
1038        // `a$$b$$` / `a$$x$$` shapes are the ones a prior gate (using is_word_byte,
1039        // which omits `$`) mis-split into ONE statement, letting DROP/TRUNCATE run.
1040        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            // Multibyte-led alias — the exact write-through the re-audit found:
1047            // no read-only backstop on this path, so the split guard is the only
1048            // defense and MUST reject.
1049            "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        // A single allowed INSERT still passes the contract.
1057        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        // A read-only contract forces read-only even when the gateway is not.
1077        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(); // read_only = true
1126        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}