Skip to main content

mz_environmentd/http/
mcp.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Model Context Protocol (MCP) HTTP handlers.
11//!
12//! Exposes Materialize data products to AI agents via JSON-RPC 2.0 over HTTP POST.
13//!
14//! ## Endpoints
15//!
16//! - `/api/mcp/agent` - User data products for customer AI agents
17//! - `/api/mcp/developer` - System catalog (`mz_*`) for troubleshooting
18//!
19//! ## Tools
20//!
21//! **Agent:** `get_data_products`, `get_data_product_details`, `read_data_product`, `query`
22//! **Developer:** `query_system_catalog`, `query`
23//!
24//! `read_data_product` and both `query` tools are dyncfg-gated.
25//!
26//! Data products are discovered via `mz_internal.mz_mcp_data_products` system view.
27
28use std::sync::Arc;
29
30use std::borrow::Cow;
31
32use anyhow::anyhow;
33use axum::Extension;
34use axum::Json;
35use axum::body::Bytes;
36use axum::extract::rejection::{JsonRejection, MissingJsonContentType};
37use axum::response::IntoResponse;
38use base64::Engine;
39use base64::engine::general_purpose::STANDARD as BASE64;
40use http::{HeaderMap, HeaderValue, StatusCode, header};
41use mz_adapter_types::dyncfgs::{
42    ENABLE_MCP_AGENT, ENABLE_MCP_AGENT_QUERY_TOOL, ENABLE_MCP_AGENT_READ_DATA_PRODUCT_TOOL,
43    ENABLE_MCP_DEVELOPER, ENABLE_MCP_DEVELOPER_QUERY_TOOL, ENABLE_MCP_PROTOCOL_2026_07_28,
44    MCP_MAX_RESPONSE_SIZE, MCP_REQUEST_TIMEOUT,
45};
46use mz_ore::cast::CastLossy;
47use mz_repr::namespaces::{self, SYSTEM_SCHEMAS};
48use mz_sql::parse::{parse_item_name_with_limit, parse_with_limit};
49use mz_sql::session::metadata::SessionMetadata;
50use mz_sql::session::vars::{APPLICATION_NAME, Var, VarInput};
51use mz_sql_parser::ast::display::{AstDisplay, escaped_string_literal};
52use mz_sql_parser::ast::visit::{self, Visit};
53use mz_sql_parser::ast::{Raw, RawItemName};
54use serde::{Deserialize, Serialize};
55use serde_json::json;
56use thiserror::Error;
57use tracing::{debug, warn};
58
59use crate::http::AuthedClient;
60use crate::http::mcp_metrics::{McpCallStatus, McpMetrics, ToolCallGuard};
61use crate::http::sql::{SqlRequest, SqlResponse, SqlResult, execute_request};
62
63// To add a new tool: add entry to tools/list, add handler function, add dispatch case.
64
65/// JSON-RPC protocol version used in all MCP requests and responses.
66const JSONRPC_VERSION: &str = "2.0";
67
68/// MCP protocol version returned in the `initialize` response.
69/// Spec: <https://modelcontextprotocol.io/specification/2025-11-25>
70const MCP_PROTOCOL_VERSION: &str = ProtocolVersion::V2025_11_25.as_str();
71
72/// The protocol revision a request is served on.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74enum ProtocolVersion {
75    /// `initialize` handshake. Also serves 2025-06-18 and earlier clients.
76    V2025_11_25,
77    /// Per-request `_meta`, no handshake.
78    V2026_07_28,
79}
80
81impl ProtocolVersion {
82    /// Picks the revision from the `MCP-Protocol-Version` header's value, not
83    /// its presence: 2025-06-18 and 2025-11-25 clients send the header too.
84    ///
85    /// Anything else falls back to 2025-11-25 on purpose, never a 400. A 400
86    /// with a 2026-07-28 error tells dual-era clients the server speaks that
87    /// revision, so they stop falling back to `initialize`.
88    ///
89    /// Any copy naming 2026-07-28 selects it. Serving 2025-11-25 there would
90    /// skip the header check while a proxy that reads that copy trusts
91    /// `Mcp-Name`. `McpHeaders::check` then refuses the repetition.
92    fn select(values: &[HeaderValue], v2026_07_28_enabled: bool) -> Self {
93        if v2026_07_28_enabled && asks_for_2026_07_28(values) {
94            Self::V2026_07_28
95        } else {
96            Self::V2025_11_25
97        }
98    }
99
100    const fn as_str(self) -> &'static str {
101        match self {
102            Self::V2025_11_25 => "2025-11-25",
103            Self::V2026_07_28 => "2026-07-28",
104        }
105    }
106
107    /// Whether this is a modern revision, the spec's term for 2026-07-28 and
108    /// later: no handshake, and each request carries its version and client
109    /// capabilities in `params._meta`, with the method and tool name mirrored in
110    /// headers.
111    fn is_modern(self) -> bool {
112        matches!(self, Self::V2026_07_28)
113    }
114}
115
116/// The body read as untyped JSON, for the 2026-07-28 checks on fields the typed
117/// parse drops. Only 2026-07-28 requests pay for the second read.
118#[derive(Debug, Default, Deserialize)]
119struct RawRequest {
120    jsonrpc: Option<serde_json::Value>,
121    #[serde(default, deserialize_with = "present_id")]
122    id: Option<serde_json::Value>,
123    method: Option<serde_json::Value>,
124    params: Option<serde_json::Value>,
125}
126
127impl RawRequest {
128    /// `None` when the body is not a JSON object.
129    fn parse(body: &[u8]) -> Option<Self> {
130        serde_json::from_slice(body).ok()
131    }
132
133    fn method(&self) -> Option<&str> {
134        self.method.as_ref()?.as_str()
135    }
136
137    fn tool(&self) -> ToolName<'_> {
138        if self.method() != Some("tools/call") {
139            return ToolName::NotToolsCall;
140        }
141        match self.params.as_ref().and_then(|params| params.get("name")) {
142            Some(serde_json::Value::String(name)) => ToolName::Name(name),
143            _ => ToolName::Missing,
144        }
145    }
146}
147
148/// The tool name a request's `Mcp-Name` header must mirror.
149#[derive(Debug, Clone, Copy)]
150enum ToolName<'a> {
151    /// Not a `tools/call`, so `Mcp-Name` is not checked.
152    NotToolsCall,
153    /// A `tools/call` that names no tool, which no header matches.
154    Missing,
155    Name(&'a str),
156}
157
158/// Runs the checks every 2026-07-28 request gets before dispatch: a JSON-RPC
159/// 2.0 envelope, the mirrored headers, and `params._meta`.
160fn check_modern_request(headers: &McpHeaders, raw: &RawRequest) -> Result<(), McpRequestError> {
161    let method = raw
162        .method()
163        .filter(|_| raw.jsonrpc.as_ref().and_then(|v| v.as_str()) == Some(JSONRPC_VERSION))
164        .ok_or_else(|| McpRequestError::InvalidRequest("not a JSON-RPC 2.0 request".into()))?;
165    headers.check(method, raw.tool())?;
166    check_request_meta(raw.params.as_ref())
167}
168
169/// Whether any `MCP-Protocol-Version` copy names 2026-07-28, also inside a
170/// comma-joined list. Whether it is served on it also depends on the flag
171/// (see [`ProtocolVersion::select`]).
172fn asks_for_2026_07_28<'a>(values: impl IntoIterator<Item = &'a HeaderValue>) -> bool {
173    // Bytes, since a copy with a non-ASCII byte elsewhere still names it.
174    let modern = ProtocolVersion::V2026_07_28.as_str().as_bytes();
175    values
176        .into_iter()
177        .flat_map(|value| value.as_bytes().split(|byte| *byte == b','))
178        .any(|version| version.trim_ascii() == modern)
179}
180
181/// Checks the `params._meta` fields every 2026-07-28 request must carry: the
182/// protocol version, matching its header, and the client capabilities.
183fn check_request_meta(params: Option<&serde_json::Value>) -> Result<(), McpRequestError> {
184    let invalid = |message: String| McpRequestError::InvalidParams(message);
185    let meta = match params.and_then(|params| params.get("_meta")) {
186        None => return Err(invalid("missing params._meta".into())),
187        Some(serde_json::Value::Object(meta)) => meta,
188        Some(_) => return Err(invalid("params._meta must be an object".into())),
189    };
190
191    let version_key = "io.modelcontextprotocol/protocolVersion";
192    let body_version = match meta.get(version_key) {
193        None => return Err(invalid(format!("missing {version_key} in params._meta"))),
194        Some(serde_json::Value::String(version)) => version,
195        Some(_) => {
196            return Err(invalid(format!(
197                "{version_key} in params._meta must be a string"
198            )));
199        }
200    };
201    // The header check already pinned the header to this version.
202    let header_version = ProtocolVersion::V2026_07_28.as_str();
203    if body_version != header_version {
204        return Err(McpRequestError::HeaderMismatch(format!(
205            "MCP-Protocol-Version header value '{header_version}' does not match body value '{body_version}'"
206        )));
207    }
208
209    let capabilities_key = "io.modelcontextprotocol/clientCapabilities";
210    match meta.get(capabilities_key) {
211        None => Err(invalid(format!(
212            "missing {capabilities_key} in params._meta"
213        ))),
214        Some(serde_json::Value::Object(_)) => Ok(()),
215        Some(_) => Err(invalid(format!(
216            "{capabilities_key} in params._meta must be an object"
217        ))),
218    }
219}
220
221/// The request headers the Streamable HTTP transport defines, with every
222/// occurrence kept: a repeated header is rejected rather than reduced to one.
223struct McpHeaders {
224    protocol_version: Vec<HeaderValue>,
225    method: Vec<HeaderValue>,
226    name: Vec<HeaderValue>,
227}
228
229impl McpHeaders {
230    fn from_map(headers: &HeaderMap) -> Self {
231        let all = |name| headers.get_all(name).iter().cloned().collect();
232        Self {
233            protocol_version: all("mcp-protocol-version"),
234            method: all("mcp-method"),
235            name: all("mcp-name"),
236        }
237    }
238
239    /// Rejects a request whose mirrored headers are missing or disagree with
240    /// the body, so a proxy routing on the headers sees what we execute.
241    fn check(&self, method: &str, tool: ToolName<'_>) -> Result<(), McpRequestError> {
242        let version = header_text(&self.protocol_version, "MCP-Protocol-Version")?;
243        let modern = ProtocolVersion::V2026_07_28.as_str();
244        if version != modern {
245            return Err(McpRequestError::HeaderMismatch(format!(
246                "MCP-Protocol-Version header value '{version}' is not '{modern}'"
247            )));
248        }
249        let header_method = header_text(&self.method, "Mcp-Method")?;
250        if header_method != method {
251            return Err(McpRequestError::HeaderMismatch(format!(
252                "Mcp-Method header value '{header_method}' does not match body value '{method}'"
253            )));
254        }
255        let tool = match tool {
256            ToolName::NotToolsCall => return Ok(()),
257            ToolName::Missing => None,
258            ToolName::Name(name) => Some(name),
259        };
260        let header_name = decode_mcp_name(header_text(&self.name, "Mcp-Name")?)?;
261        if Some(header_name.as_ref()) != tool {
262            return Err(McpRequestError::HeaderMismatch(format!(
263                "Mcp-Name header value '{header_name}' does not match body value '{}'",
264                tool.unwrap_or_default()
265            )));
266        }
267        Ok(())
268    }
269}
270
271fn header_text<'a>(values: &'a [HeaderValue], name: &str) -> Result<&'a str, McpRequestError> {
272    let value = match values {
273        [value] => value,
274        [] => {
275            return Err(McpRequestError::HeaderMismatch(format!(
276                "missing {name} header"
277            )));
278        }
279        _ => {
280            return Err(McpRequestError::HeaderMismatch(format!(
281                "repeated {name} header"
282            )));
283        }
284    };
285    value.to_str().map_err(|_| {
286        McpRequestError::HeaderMismatch(format!("{name} header has invalid characters"))
287    })
288}
289
290/// Decodes the `=?base64?...?=` form clients use for names that are not plain
291/// ASCII. Any other value is the name as is.
292fn decode_mcp_name(value: &str) -> Result<Cow<'_, str>, McpRequestError> {
293    let Some(encoded) = value
294        .strip_prefix("=?base64?")
295        .and_then(|rest| rest.strip_suffix("?="))
296    else {
297        return Ok(Cow::Borrowed(value));
298    };
299    let invalid = || McpRequestError::HeaderMismatch("Mcp-Name header has invalid Base64".into());
300    let bytes = BASE64.decode(encoded).map_err(|_| invalid())?;
301    String::from_utf8(bytes)
302        .map(Cow::Owned)
303        .map_err(|_| invalid())
304}
305
306// Discovery uses the lightweight view (no JSON schema computation).
307const DISCOVERY_QUERY: &str = "SELECT * FROM mz_internal.mz_mcp_data_products";
308const DETAILS_QUERY_PREFIX: &str =
309    "SELECT * FROM mz_internal.mz_mcp_data_product_details WHERE object_name = ";
310
311/// MCP request errors, mapped to JSON-RPC error codes.
312#[derive(Debug, Error)]
313enum McpRequestError {
314    #[error("Invalid JSON-RPC version: expected 2.0")]
315    InvalidJsonRpcVersion,
316    #[error("Method not found: {0}")]
317    MethodNotFound(String),
318    #[error("Tool not found: {0}")]
319    ToolNotFound(String),
320    #[error("Data product not found: {0}")]
321    DataProductNotFound(String),
322    #[error("Query validation failed: {0}")]
323    QueryValidationFailed(String),
324    #[error("Query execution failed: {0}")]
325    QueryExecutionFailed(String),
326    #[error("Internal error: {0}")]
327    Internal(#[from] anyhow::Error),
328    #[error("Header mismatch: {0}")]
329    HeaderMismatch(String),
330    #[error("Invalid params: {0}")]
331    InvalidParams(String),
332    #[error("Invalid request: {0}")]
333    InvalidRequest(String),
334    #[error("Parse error: {0}")]
335    ParseError(String),
336}
337
338impl McpRequestError {
339    fn error_code(&self) -> i32 {
340        match self {
341            Self::InvalidJsonRpcVersion => error_codes::INVALID_REQUEST,
342            Self::MethodNotFound(_) => error_codes::METHOD_NOT_FOUND,
343            Self::ToolNotFound(_) => error_codes::INVALID_PARAMS,
344            Self::DataProductNotFound(_) => error_codes::INVALID_PARAMS,
345            Self::QueryValidationFailed(_) => error_codes::INVALID_PARAMS,
346            Self::QueryExecutionFailed(_) | Self::Internal(_) => error_codes::INTERNAL_ERROR,
347            Self::HeaderMismatch(_) => error_codes::HEADER_MISMATCH,
348            Self::InvalidParams(_) => error_codes::INVALID_PARAMS,
349            Self::InvalidRequest(_) => error_codes::INVALID_REQUEST,
350            Self::ParseError(_) => error_codes::PARSE_ERROR,
351        }
352    }
353
354    fn error_type(&self) -> &'static str {
355        match self {
356            Self::InvalidJsonRpcVersion => "InvalidRequest",
357            Self::MethodNotFound(_) => "MethodNotFound",
358            Self::ToolNotFound(_) => "ToolNotFound",
359            Self::DataProductNotFound(_) => "DataProductNotFound",
360            Self::QueryValidationFailed(_) => "ValidationError",
361            Self::QueryExecutionFailed(_) => "ExecutionError",
362            Self::Internal(_) => "InternalError",
363            Self::HeaderMismatch(_) => "HeaderMismatch",
364            Self::InvalidParams(_) => "InvalidParams",
365            Self::InvalidRequest(_) => "InvalidRequest",
366            Self::ParseError(_) => "ParseError",
367        }
368    }
369}
370
371/// JSON-RPC 2.0 request. Requests have `id`; notifications don't.
372#[derive(Debug, Deserialize)]
373pub(crate) struct McpRequest {
374    jsonrpc: String,
375    #[serde(default, deserialize_with = "present_id")]
376    id: Option<serde_json::Value>,
377    #[serde(flatten)]
378    method: McpMethod,
379}
380
381/// Wraps a `null` id in `Some`, so it stays a request. A missing `id` is `None`
382/// through `#[serde(default)]` on the field, and only that is a notification.
383fn present_id<'de, D>(deserializer: D) -> Result<Option<serde_json::Value>, D::Error>
384where
385    D: serde::Deserializer<'de>,
386{
387    serde_json::Value::deserialize(deserializer).map(Some)
388}
389
390/// MCP request ids are strings or numbers. Any other id, `null` included, gets
391/// `-32600` with no id in the reply.
392fn is_valid_request_id(id: &serde_json::Value) -> bool {
393    matches!(
394        id,
395        serde_json::Value::String(_) | serde_json::Value::Number(_)
396    )
397}
398
399fn invalid_id() -> McpRequestError {
400    McpRequestError::InvalidRequest("id must be a string or a number".into())
401}
402
403/// MCP method variants with their associated parameters.
404#[derive(Debug, Deserialize)]
405#[serde(tag = "method", content = "params")]
406enum McpMethod {
407    /// Initialize method - params accepted but not currently used
408    #[serde(rename = "initialize")]
409    Initialize(#[allow(dead_code)] InitializeParams),
410    /// `params` is accepted and ignored: clients may attach `_meta`, and a unit
411    /// variant would fail the whole request with a non-JSON-RPC 422.
412    #[serde(rename = "tools/list")]
413    ToolsList(#[allow(dead_code)] Option<serde_json::Value>),
414    #[serde(rename = "tools/call")]
415    ToolsCall(#[serde(deserialize_with = "deserialize_tools_call")] ToolsCallParams),
416    /// Keepalive, and the post-initialize acknowledgement. Named so their
417    /// `params` deserialize and they keep their own metric label. Other methods
418    /// with `params` reach `Unknown` through [`as_unknown_method`].
419    #[serde(rename = "ping")]
420    Ping(#[allow(dead_code)] Option<serde_json::Value>),
421    #[serde(rename = "notifications/initialized")]
422    NotificationsInitialized(#[allow(dead_code)] Option<serde_json::Value>),
423    /// `params` is accepted and ignored, as on `tools/list`.
424    #[serde(rename = "server/discover")]
425    ServerDiscover(#[allow(dead_code)] Option<serde_json::Value>),
426    /// Catch-all for unrecognized methods.
427    #[serde(other)]
428    Unknown,
429}
430
431impl std::fmt::Display for McpMethod {
432    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433        match self {
434            McpMethod::Initialize(_) => write!(f, "initialize"),
435            McpMethod::ToolsList(_) => write!(f, "tools/list"),
436            McpMethod::ToolsCall(_) => write!(f, "tools/call"),
437            McpMethod::Ping(_) => write!(f, "ping"),
438            McpMethod::NotificationsInitialized(_) => write!(f, "notifications/initialized"),
439            McpMethod::ServerDiscover(_) => write!(f, "server/discover"),
440            McpMethod::Unknown => write!(f, "unknown"),
441        }
442    }
443}
444
445#[derive(Debug, Deserialize)]
446struct InitializeParams {
447    /// Protocol version from client. Not currently validated but accepted for MCP compliance.
448    #[serde(rename = "protocolVersion")]
449    #[allow(dead_code)]
450    protocol_version: String,
451    /// Client capabilities. Not currently used but accepted for MCP compliance.
452    #[serde(default)]
453    #[allow(dead_code)]
454    capabilities: serde_json::Value,
455    /// Client information (name, version). Not currently used but accepted for MCP compliance.
456    #[serde(rename = "clientInfo")]
457    #[allow(dead_code)]
458    client_info: Option<ClientInfo>,
459}
460
461#[derive(Debug, Deserialize)]
462struct ClientInfo {
463    #[allow(dead_code)]
464    name: String,
465    #[allow(dead_code)]
466    version: String,
467}
468
469/// Tool call parameters, deserialized via adjacently tagged enum.
470/// Serde maps `name` to the variant and `arguments` to the variant's data.
471#[derive(Debug, Deserialize)]
472#[serde(tag = "name", content = "arguments")]
473#[serde(rename_all = "snake_case")]
474enum ToolsCallParams {
475    // Agent endpoint tools
476    GetDataProducts(NoArguments),
477    GetDataProductDetails(GetDataProductDetailsParams),
478    ReadDataProduct(ReadDataProductParams),
479    Query(QueryParams),
480    // Developer endpoint tools
481    QuerySystemCatalog(QuerySystemCatalogParams),
482}
483
484/// Arguments of a tool that takes none. A struct rather than `()`: read from a
485/// `serde_json::Value`, `()` accepts `{}` only if serde buffers `arguments`
486/// ahead of `name`, which depends on key order (`preserve_order` keeps the
487/// client's order).
488#[derive(Debug, Deserialize)]
489#[serde(deny_unknown_fields)]
490struct NoArguments {}
491
492/// `arguments` is optional in the MCP spec, but adjacent tagging requires the
493/// content key, so a call that omits it, or sends `null`, is given `{}`.
494fn deserialize_tools_call<'de, D>(deserializer: D) -> Result<ToolsCallParams, D::Error>
495where
496    D: serde::Deserializer<'de>,
497{
498    let mut params = serde_json::Value::deserialize(deserializer)?;
499    if let serde_json::Value::Object(fields) = &mut params {
500        let arguments = fields.entry("arguments").or_insert(serde_json::Value::Null);
501        if arguments.is_null() {
502            *arguments = json!({});
503        }
504    }
505    serde_json::from_value(params).map_err(serde::de::Error::custom)
506}
507
508impl std::fmt::Display for ToolsCallParams {
509    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510        match self {
511            ToolsCallParams::GetDataProducts(_) => write!(f, "get_data_products"),
512            ToolsCallParams::GetDataProductDetails(_) => write!(f, "get_data_product_details"),
513            ToolsCallParams::ReadDataProduct(_) => write!(f, "read_data_product"),
514            ToolsCallParams::Query(_) => write!(f, "query"),
515            ToolsCallParams::QuerySystemCatalog(_) => write!(f, "query_system_catalog"),
516        }
517    }
518}
519
520#[derive(Debug, Deserialize)]
521struct GetDataProductDetailsParams {
522    name: String,
523}
524
525#[derive(Debug, Deserialize)]
526struct ReadDataProductParams {
527    name: String,
528    #[serde(default = "default_read_limit")]
529    limit: u32,
530    cluster: Option<String>,
531}
532
533/// Default row cap for `read_data_product` when the caller omits `limit`.
534const DEFAULT_READ_LIMIT: u32 = 500;
535
536fn default_read_limit() -> u32 {
537    DEFAULT_READ_LIMIT
538}
539
540#[derive(Debug, Deserialize)]
541struct QueryParams {
542    cluster: String,
543    /// Only honored on the developer endpoint. The agent endpoint's dispatch
544    /// arm drops it, since replica pinning is not part of the agent surface.
545    cluster_replica: Option<String>,
546    sql_query: String,
547}
548
549#[derive(Debug, Deserialize)]
550struct QuerySystemCatalogParams {
551    sql_query: String,
552}
553
554#[derive(Debug, Serialize)]
555struct McpResponse {
556    jsonrpc: String,
557    /// `None` when the request's id is unknown. MCP ids cannot be `null`.
558    #[serde(skip_serializing_if = "Option::is_none")]
559    id: Option<serde_json::Value>,
560    #[serde(skip_serializing_if = "Option::is_none")]
561    result: Option<McpResultBody>,
562    #[serde(skip_serializing_if = "Option::is_none")]
563    error: Option<McpError>,
564}
565
566impl McpResponse {
567    /// A successful JSON-RPC response carrying `result`.
568    fn success(id: serde_json::Value, result: McpResultBody) -> Self {
569        Self {
570            jsonrpc: JSONRPC_VERSION.to_string(),
571            id: Some(id),
572            result: Some(result),
573            error: None,
574        }
575    }
576
577    /// A JSON-RPC error response carrying `error`.
578    fn error(id: impl Into<Option<serde_json::Value>>, error: McpError) -> Self {
579        Self {
580            jsonrpc: JSONRPC_VERSION.to_string(),
581            id: id.into(),
582            result: None,
583            error: Some(error),
584        }
585    }
586}
587
588/// Typed MCP response results.
589#[derive(Debug, Serialize)]
590#[serde(untagged)]
591enum McpResult {
592    Initialize(InitializeResult),
593    Discover(DiscoverResult),
594    ToolsList(ToolsListResult),
595    ToolContent(ToolContentResult),
596}
597
598impl McpResult {
599    fn into_body(self, protocol: ProtocolVersion, endpoint_type: McpEndpointType) -> McpResultBody {
600        let common = protocol.is_modern().then(|| CommonResultFields {
601            result_type: "complete",
602            cache: self.cache_hints(),
603            meta: ResultMeta {
604                server_info: server_info(endpoint_type),
605            },
606        });
607        McpResultBody {
608            result: self,
609            common,
610        }
611    }
612
613    fn cache_hints(&self) -> Option<CacheHints> {
614        match self {
615            // `private` so a shared cache never serves it to a caller who
616            // cannot authenticate to this environment.
617            McpResult::Discover(_) | McpResult::ToolsList(_) => Some(CacheHints {
618                ttl_ms: CACHE_TTL_MS,
619                cache_scope: "private",
620            }),
621            McpResult::Initialize(_) | McpResult::ToolContent(_) => None,
622        }
623    }
624}
625
626/// How long a client may consider a cacheable result fresh, in milliseconds.
627/// Kept short so flag changes reach clients soon.
628const CACHE_TTL_MS: u64 = 60_000;
629
630/// A result as sent. `common` is `None` on 2025-11-25, whose results carry
631/// neither field.
632#[derive(Debug, Serialize)]
633struct McpResultBody {
634    #[serde(flatten)]
635    result: McpResult,
636    #[serde(flatten)]
637    common: Option<CommonResultFields>,
638}
639
640#[derive(Debug, Serialize)]
641struct CommonResultFields {
642    #[serde(rename = "resultType")]
643    result_type: &'static str,
644    #[serde(flatten)]
645    cache: Option<CacheHints>,
646    #[serde(rename = "_meta")]
647    meta: ResultMeta,
648}
649
650/// Required on cacheable results, such as `tools/list`.
651#[derive(Debug, Serialize)]
652struct CacheHints {
653    #[serde(rename = "ttlMs")]
654    ttl_ms: u64,
655    #[serde(rename = "cacheScope")]
656    cache_scope: &'static str,
657}
658
659#[derive(Debug, Serialize)]
660struct ResultMeta {
661    #[serde(rename = "io.modelcontextprotocol/serverInfo")]
662    server_info: ServerInfo,
663}
664
665#[derive(Debug, Serialize)]
666struct InitializeResult {
667    #[serde(rename = "protocolVersion")]
668    protocol_version: String,
669    capabilities: Capabilities,
670    #[serde(rename = "serverInfo")]
671    server_info: ServerInfo,
672    #[serde(skip_serializing_if = "Option::is_none")]
673    instructions: Option<String>,
674}
675
676#[derive(Debug, Serialize)]
677struct DiscoverResult {
678    #[serde(rename = "supportedVersions")]
679    supported_versions: [&'static str; 2],
680    capabilities: Capabilities,
681    #[serde(skip_serializing_if = "Option::is_none")]
682    instructions: Option<String>,
683}
684
685#[derive(Debug, Serialize)]
686struct Capabilities {
687    tools: serde_json::Value,
688}
689
690#[derive(Debug, Serialize)]
691struct ServerInfo {
692    name: String,
693    version: String,
694}
695
696#[derive(Debug, Serialize)]
697struct ToolsListResult {
698    tools: Vec<ToolDefinition>,
699}
700
701#[derive(Debug, Serialize)]
702struct ToolDefinition {
703    name: String,
704    #[serde(skip_serializing_if = "Option::is_none")]
705    title: Option<String>,
706    description: String,
707    #[serde(rename = "inputSchema")]
708    input_schema: serde_json::Value,
709    #[serde(skip_serializing_if = "Option::is_none")]
710    annotations: Option<ToolAnnotations>,
711}
712
713/// MCP 2025-11-25 tool annotations that describe tool behavior.
714/// These hints help clients make trust and safety decisions.
715#[derive(Debug, Serialize)]
716struct ToolAnnotations {
717    #[serde(rename = "readOnlyHint", skip_serializing_if = "Option::is_none")]
718    read_only_hint: Option<bool>,
719    #[serde(rename = "destructiveHint", skip_serializing_if = "Option::is_none")]
720    destructive_hint: Option<bool>,
721    #[serde(rename = "idempotentHint", skip_serializing_if = "Option::is_none")]
722    idempotent_hint: Option<bool>,
723    #[serde(rename = "openWorldHint", skip_serializing_if = "Option::is_none")]
724    open_world_hint: Option<bool>,
725}
726
727/// Annotations for all MCP tools: read-only, non-destructive, idempotent.
728const READ_ONLY_ANNOTATIONS: ToolAnnotations = ToolAnnotations {
729    read_only_hint: Some(true),
730    destructive_hint: Some(false),
731    idempotent_hint: Some(true),
732    open_world_hint: Some(false),
733};
734
735#[derive(Debug, Serialize)]
736struct ToolContentResult {
737    content: Vec<ContentBlock>,
738    /// Always `false`: tool failures surface as JSON-RPC errors instead.
739    #[serde(rename = "isError")]
740    is_error: bool,
741}
742
743#[derive(Debug, Serialize)]
744struct ContentBlock {
745    #[serde(rename = "type")]
746    content_type: String,
747    text: String,
748}
749
750/// JSON-RPC 2.0 error codes.
751mod error_codes {
752    pub const PARSE_ERROR: i32 = -32700;
753    pub const INVALID_REQUEST: i32 = -32600;
754    pub const METHOD_NOT_FOUND: i32 = -32601;
755    pub const INVALID_PARAMS: i32 = -32602;
756    pub const INTERNAL_ERROR: i32 = -32603;
757    pub const HEADER_MISMATCH: i32 = -32020;
758}
759
760#[derive(Debug, Serialize)]
761struct McpError {
762    code: i32,
763    message: String,
764    #[serde(skip_serializing_if = "Option::is_none")]
765    data: Option<serde_json::Value>,
766}
767
768impl From<McpRequestError> for McpError {
769    fn from(err: McpRequestError) -> Self {
770        McpError {
771            code: err.error_code(),
772            message: err.to_string(),
773            data: Some(json!({
774                "error_type": err.error_type(),
775            })),
776        }
777    }
778}
779
780#[derive(Debug, Clone, Copy)]
781enum McpEndpointType {
782    Agent,
783    Developer,
784}
785
786impl McpEndpointType {
787    /// Static label for metrics. Avoids per-request allocation that would
788    /// come from going through `Display`.
789    fn as_label(self) -> &'static str {
790        match self {
791            McpEndpointType::Agent => "agent",
792            McpEndpointType::Developer => "developer",
793        }
794    }
795}
796
797impl std::fmt::Display for McpEndpointType {
798    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
799        f.write_str(self.as_label())
800    }
801}
802
803/// MCP 2025-11-25 requires servers to return 405 for GET requests
804/// on endpoints that only support POST.
805pub async fn handle_mcp_method_not_allowed() -> impl IntoResponse {
806    StatusCode::METHOD_NOT_ALLOWED
807}
808
809/// Agent endpoint: exposes user data products.
810pub async fn handle_mcp_agent(
811    headers: HeaderMap,
812    Extension(allowed_origins): Extension<Arc<Vec<HeaderValue>>>,
813    Extension(metrics): Extension<McpMetrics>,
814    client: AuthedClient,
815    body: Bytes,
816) -> axum::response::Response {
817    if let Some(resp) = validate_origin(&headers, &allowed_origins) {
818        return resp;
819    }
820    handle_mcp_request(
821        client,
822        parse_mcp_request(&headers, &body),
823        McpHeaders::from_map(&headers),
824        body,
825        McpEndpointType::Agent,
826        metrics,
827    )
828    .await
829    .into_response()
830}
831
832/// Developer endpoint: exposes system catalog (mz_*) only.
833pub async fn handle_mcp_developer(
834    headers: HeaderMap,
835    Extension(allowed_origins): Extension<Arc<Vec<HeaderValue>>>,
836    Extension(metrics): Extension<McpMetrics>,
837    client: AuthedClient,
838    body: Bytes,
839) -> axum::response::Response {
840    if let Some(resp) = validate_origin(&headers, &allowed_origins) {
841        return resp;
842    }
843    handle_mcp_request(
844        client,
845        parse_mcp_request(&headers, &body),
846        McpHeaders::from_map(&headers),
847        body,
848        McpEndpointType::Developer,
849        metrics,
850    )
851    .await
852    .into_response()
853}
854
855/// Parses a request body the way axum's `Json` extractor does, so every
856/// rejection keeps its status and text. The one exception: an unknown method
857/// that carries `params` becomes `McpMethod::Unknown` instead of a 422 (see
858/// [`as_unknown_method`]). The body arrives through `Bytes`, so the route's
859/// `DefaultBodyLimit` still applies.
860fn parse_mcp_request(headers: &HeaderMap, body: &[u8]) -> Result<McpRequest, JsonRejection> {
861    if !is_json_content_type(headers) {
862        return Err(MissingJsonContentType::default().into());
863    }
864    match Json::<McpRequest>::from_bytes(body) {
865        Ok(Json(request)) => Ok(request),
866        Err(JsonRejection::JsonDataError(rejection)) => {
867            as_unknown_method(body).ok_or_else(|| rejection.into())
868        }
869        Err(rejection) => Err(rejection),
870    }
871}
872
873/// The `Content-Type` check axum's `Json` extractor runs before parsing.
874fn is_json_content_type(headers: &HeaderMap) -> bool {
875    let Some(mime) = headers
876        .get(header::CONTENT_TYPE)
877        .and_then(|value| value.to_str().ok())
878        .and_then(|value| value.parse::<mime::Mime>().ok())
879    else {
880        return false;
881    };
882    mime.type_() == "application"
883        && (mime.subtype() == "json" || mime.suffix().is_some_and(|name| name == "json"))
884}
885
886/// The request `body` would produce if `McpMethod::Unknown` accepted `params`,
887/// or `None` when its method is one we know, whose parse error must stand.
888///
889/// `#[serde(other)]` only matches a unit variant, so an unknown method with
890/// `params` fails the whole body. Every 2026-07-28 client sends `params`.
891fn as_unknown_method(body: &[u8]) -> Option<McpRequest> {
892    #[derive(Deserialize)]
893    struct Envelope {
894        jsonrpc: String,
895        #[serde(default, deserialize_with = "present_id")]
896        id: Option<serde_json::Value>,
897        method: String,
898    }
899    let envelope: Envelope = serde_json::from_slice(body).ok()?;
900    // Without `params`, a known method either parses to its own variant or
901    // fails on its missing params. Only an unknown one parses to `Unknown`.
902    let method: McpMethod = serde_json::from_value(json!({ "method": envelope.method })).ok()?;
903    matches!(method, McpMethod::Unknown).then(|| McpRequest {
904        jsonrpc: envelope.jsonrpc,
905        id: envelope.id,
906        method,
907    })
908}
909
910/// Validates the Origin header against the CORS allowlist to prevent DNS
911/// rebinding attacks (MCP spec 2025-11-25). Returns Some(403) if Origin is
912/// present but not on the allowlist. Returns None if absent (non-browser
913/// client) or allowed.
914///
915/// Note: this server-side check is required in addition to the CorsLayer.
916/// CorsLayer only controls response headers and can be bypassed when the
917/// attacker arranges same-origin DNS rebinding (no preflight fires).
918fn validate_origin(
919    headers: &HeaderMap,
920    allowed: &[HeaderValue],
921) -> Option<axum::response::Response> {
922    let origin = headers.get(http::header::ORIGIN)?;
923    if mz_http_util::origin_is_allowed(origin, allowed) {
924        return None;
925    }
926    warn!(
927        origin = ?origin,
928        "MCP request rejected: origin not in allowlist",
929    );
930    Some(StatusCode::FORBIDDEN.into_response())
931}
932
933async fn handle_mcp_request(
934    mut client: AuthedClient,
935    request: Result<McpRequest, JsonRejection>,
936    mcp_headers: McpHeaders,
937    body: Bytes,
938    endpoint_type: McpEndpointType,
939    metrics: McpMetrics,
940) -> impl IntoResponse {
941    // Only a 2026-07-28 request gets a JSON-RPC answer for a body that is not
942    // JSON or not a request. Every other request gets axum's rejection.
943    let request =
944        match request {
945            Err(
946                rejection @ (JsonRejection::JsonDataError(_) | JsonRejection::JsonSyntaxError(_)),
947            ) if asks_for_2026_07_28(&mcp_headers.protocol_version) => Err(rejection),
948            Err(rejection) => return rejection.into_response(),
949            Ok(request) => Ok(request),
950        };
951    let endpoint_label = endpoint_type.as_label();
952    let method_label = match &request {
953        Ok(request) => request.method.to_string(),
954        Err(_) => "invalid".to_string(),
955    };
956    let record_request =
957        |status: McpCallStatus| metrics.record_request(endpoint_label, &method_label, status);
958
959    // Check the per-endpoint feature flag via a catalog snapshot, similar to peek_sequencing.rs.
960    // The configured `MCP_REQUEST_TIMEOUT` lives in the snapshot we are about
961    // to fetch, so bound this phase with the compiled-in default. Without it a
962    // stalled snapshot would hang the request past any configured timeout.
963    let catalog = match tokio::time::timeout(
964        *MCP_REQUEST_TIMEOUT.default(),
965        client.client.catalog_snapshot("mcp"),
966    )
967    .await
968    {
969        Ok(catalog) => catalog,
970        Err(_elapsed) => {
971            // The flag is unknown, so the 2026-07-28 path cannot be picked.
972            if let Err(rejection) = request {
973                return rejection.into_response();
974            }
975            warn!(endpoint = %endpoint_type, "MCP catalog snapshot timed out");
976            record_request(McpCallStatus::Timeout);
977            return StatusCode::SERVICE_UNAVAILABLE.into_response();
978        }
979    };
980    let dyncfgs = catalog.system_config().dyncfgs();
981    let protocol = ProtocolVersion::select(
982        &mcp_headers.protocol_version,
983        ENABLE_MCP_PROTOCOL_2026_07_28.get(dyncfgs),
984    );
985    // With the flag off, reject parse failures before the endpoint check, as on
986    // 2025-11-25.
987    let request = match request {
988        Err(rejection) if !protocol.is_modern() => return rejection.into_response(),
989        request => request,
990    };
991    let enabled = match endpoint_type {
992        McpEndpointType::Agent => ENABLE_MCP_AGENT.get(dyncfgs),
993        McpEndpointType::Developer => ENABLE_MCP_DEVELOPER.get(dyncfgs),
994    };
995    if !enabled {
996        debug!(endpoint = %endpoint_type, "MCP endpoint disabled by feature flag");
997        record_request(McpCallStatus::EndpointDisabled);
998        return StatusCode::SERVICE_UNAVAILABLE.into_response();
999    }
1000
1001    // Per-endpoint feature flag for the `query` tool. Agent and developer have
1002    // independent rollouts; collapsing to one bool keeps the downstream
1003    // signatures unchanged since each handler invocation is already bound to a
1004    // single endpoint.
1005    let query_tool_enabled = match endpoint_type {
1006        McpEndpointType::Agent => ENABLE_MCP_AGENT_QUERY_TOOL.get(dyncfgs),
1007        McpEndpointType::Developer => ENABLE_MCP_DEVELOPER_QUERY_TOOL.get(dyncfgs),
1008    };
1009    // Only meaningful on the agent endpoint; the developer endpoint doesn't
1010    // expose `read_data_product`. Read it unconditionally so the plumbing
1011    // matches `query_tool_enabled` above.
1012    let read_data_product_tool_enabled = ENABLE_MCP_AGENT_READ_DATA_PRODUCT_TOOL.get(dyncfgs);
1013    let max_response_size = MCP_MAX_RESPONSE_SIZE.get(dyncfgs);
1014    let request_timeout = MCP_REQUEST_TIMEOUT.get(dyncfgs);
1015
1016    let request = match request {
1017        Ok(request) => request,
1018        Err(rejection) => {
1019            return answer_parse_failure(rejection, &body, &mcp_headers, &record_request);
1020        }
1021    };
1022
1023    // Tag MCP-originated sessions so they're distinguishable in
1024    // mz_session_history / mz_statement_execution_history. set_default lets a
1025    // caller still override via `?options={"application_name":"..."}`
1026    let app_name = match endpoint_type {
1027        McpEndpointType::Agent => "mz_mcp_agents",
1028        McpEndpointType::Developer => "mz_mcp_developer",
1029    };
1030    client
1031        .client
1032        .session()
1033        .vars_mut()
1034        .set_default(APPLICATION_NAME.name(), VarInput::Flat(app_name))
1035        .expect("application_name is a known session var");
1036
1037    let user = client.client.session().user().name.clone();
1038    let is_notification = request.id.is_none();
1039
1040    debug!(
1041        method = %request.method,
1042        endpoint = %endpoint_type,
1043        user = %user,
1044        is_notification = is_notification,
1045        ?protocol,
1046        "MCP request received"
1047    );
1048
1049    // No `id` means no reply, which the transport answers 202. SDK clients take
1050    // any other status as a response to parse and close on the empty body.
1051    if is_notification {
1052        // 2026-07-28 refuses a notification it cannot accept with a 400.
1053        if protocol.is_modern() && request.jsonrpc != JSONRPC_VERSION {
1054            let err = McpRequestError::InvalidRequest("not a JSON-RPC 2.0 request".into());
1055            record_request(McpCallStatus::Error(err.error_type()));
1056            let response = McpResponse::error(None, err.into());
1057            return (StatusCode::BAD_REQUEST, Json(response)).into_response();
1058        }
1059        debug!(method = %request.method, "Received notification (no response will be sent)");
1060        record_request(McpCallStatus::Ok);
1061        return StatusCode::ACCEPTED.into_response();
1062    }
1063
1064    if !request.id.as_ref().is_some_and(is_valid_request_id) {
1065        let err = invalid_id();
1066        record_request(McpCallStatus::Error(err.error_type()));
1067        let status = if protocol.is_modern() {
1068            StatusCode::BAD_REQUEST
1069        } else {
1070            StatusCode::OK
1071        };
1072        return (status, Json(McpResponse::error(None, err.into()))).into_response();
1073    }
1074
1075    let request_id = request.id.clone().unwrap_or(serde_json::Value::Null);
1076
1077    // The transport leaves header rules for notification POSTs undefined, so
1078    // only requests are checked. A failed check is transport validation, so it
1079    // gets a 400 even where its JSON-RPC code is -32602.
1080    if protocol.is_modern() {
1081        let raw = RawRequest::parse(&body).unwrap_or_default();
1082        if let Err(err) = check_modern_request(&mcp_headers, &raw) {
1083            record_request(McpCallStatus::Error(err.error_type()));
1084            let response = McpResponse::error(request_id, err.into());
1085            return (StatusCode::BAD_REQUEST, Json(response)).into_response();
1086        }
1087    }
1088
1089    // Spawn task for fault isolation, with a timeout safety net.
1090    // `abort_on_drop` propagates the timeout to the task itself; without
1091    // it the task orphans and the SQL query keeps running in the
1092    // background after the client gives up (see database-issues#9947).
1093    let metrics_inner = metrics.clone();
1094    let result = tokio::time::timeout(
1095        request_timeout,
1096        mz_ore::task::spawn(|| "mcp_request", async move {
1097            handle_mcp_request_inner(
1098                &mut client,
1099                request,
1100                protocol,
1101                endpoint_type,
1102                query_tool_enabled,
1103                read_data_product_tool_enabled,
1104                max_response_size,
1105                metrics_inner,
1106            )
1107            .await
1108        })
1109        .abort_on_drop(),
1110    )
1111    .await;
1112
1113    let (response, status_label): (McpResponse, McpCallStatus) = match result {
1114        Ok(inner) => inner,
1115        Err(_elapsed) => {
1116            warn!(
1117                endpoint = %endpoint_type,
1118                timeout = ?request_timeout,
1119                "MCP request timed out",
1120            );
1121            let response = McpResponse::error(
1122                request_id,
1123                McpRequestError::QueryExecutionFailed(format!(
1124                    "Request timed out after {} seconds.",
1125                    request_timeout.as_secs(),
1126                ))
1127                .into(),
1128            );
1129            (response, McpCallStatus::Timeout)
1130        }
1131    };
1132
1133    record_request(status_label);
1134    // 2026-07-28 answers a method it does not define with 404.
1135    let status = match &response.error {
1136        Some(error) if protocol.is_modern() && error.code == error_codes::METHOD_NOT_FOUND => {
1137            StatusCode::NOT_FOUND
1138        }
1139        _ => StatusCode::OK,
1140    };
1141    // Always a single JSON object. The transport lets the server answer with
1142    // JSON or an SSE stream, and we never stream, so `Accept` is not checked.
1143    (status, Json(response)).into_response()
1144}
1145
1146/// Answers a 2026-07-28 request whose body did not parse, in JSON-RPC. One that
1147/// passes [`check_modern_request`] gets 404 for a method 2026-07-28 does not
1148/// define, and `-32602` otherwise. Other rejections keep axum's response.
1149fn answer_parse_failure(
1150    rejection: JsonRejection,
1151    body: &[u8],
1152    headers: &McpHeaders,
1153    record_request: &dyn Fn(McpCallStatus),
1154) -> axum::response::Response {
1155    let raw = RawRequest::parse(body).unwrap_or_default();
1156    let (status, err) = match rejection {
1157        JsonRejection::JsonSyntaxError(rejection) => (
1158            StatusCode::BAD_REQUEST,
1159            McpRequestError::ParseError(rejection.body_text()),
1160        ),
1161        JsonRejection::JsonDataError(_)
1162            if raw.id.as_ref().is_some_and(|id| !is_valid_request_id(id)) =>
1163        {
1164            (StatusCode::BAD_REQUEST, invalid_id())
1165        }
1166        JsonRejection::JsonDataError(rejection) => {
1167            let message = rejection.body_text();
1168            match (raw.id.is_some(), check_modern_request(headers, &raw)) {
1169                (_, Err(err @ McpRequestError::InvalidRequest(_))) => {
1170                    (StatusCode::BAD_REQUEST, err)
1171                }
1172                // A notification has no reply, so one that cannot be accepted gets
1173                // a 400. Header rules do not apply to it.
1174                (false, _) => (
1175                    StatusCode::BAD_REQUEST,
1176                    McpRequestError::InvalidParams(message),
1177                ),
1178                (true, Err(err)) => (StatusCode::BAD_REQUEST, err),
1179                (true, Ok(())) if raw.method() == Some("initialize") => (
1180                    StatusCode::NOT_FOUND,
1181                    McpRequestError::MethodNotFound("unknown method".to_string()),
1182                ),
1183                (true, Ok(())) => (StatusCode::OK, McpRequestError::InvalidParams(message)),
1184            }
1185        }
1186        rejection => return rejection.into_response(),
1187    };
1188    record_request(McpCallStatus::Error(err.error_type()));
1189    let id = raw.id.filter(is_valid_request_id);
1190    (status, Json(McpResponse::error(id, err.into()))).into_response()
1191}
1192
1193async fn handle_mcp_request_inner(
1194    client: &mut AuthedClient,
1195    request: McpRequest,
1196    protocol: ProtocolVersion,
1197    endpoint_type: McpEndpointType,
1198    query_tool_enabled: bool,
1199    read_data_product_tool_enabled: bool,
1200    max_response_size: usize,
1201    metrics: McpMetrics,
1202) -> (McpResponse, McpCallStatus) {
1203    // Extract request ID (guaranteed to be Some since notifications are filtered earlier)
1204    let request_id = request.id.clone().unwrap_or(serde_json::Value::Null);
1205
1206    let result = handle_mcp_method(
1207        client,
1208        &request,
1209        protocol,
1210        endpoint_type,
1211        query_tool_enabled,
1212        read_data_product_tool_enabled,
1213        max_response_size,
1214        &metrics,
1215    )
1216    .await;
1217
1218    let status_label = call_status(&result);
1219
1220    let response = match result {
1221        Ok(result_value) => {
1222            McpResponse::success(request_id, result_value.into_body(protocol, endpoint_type))
1223        }
1224        Err(e) => {
1225            // Log non-trivial errors
1226            if !matches!(
1227                e,
1228                McpRequestError::MethodNotFound(_) | McpRequestError::InvalidJsonRpcVersion
1229            ) {
1230                warn!(error = %e, method = %request.method, "MCP method execution failed");
1231            }
1232            McpResponse::error(request_id, e.into())
1233        }
1234    };
1235
1236    (response, status_label)
1237}
1238
1239async fn handle_mcp_method(
1240    client: &mut AuthedClient,
1241    request: &McpRequest,
1242    protocol: ProtocolVersion,
1243    endpoint_type: McpEndpointType,
1244    query_tool_enabled: bool,
1245    read_data_product_tool_enabled: bool,
1246    max_response_size: usize,
1247    metrics: &McpMetrics,
1248) -> Result<McpResult, McpRequestError> {
1249    // Validate JSON-RPC version
1250    if request.jsonrpc != JSONRPC_VERSION {
1251        return Err(McpRequestError::InvalidJsonRpcVersion);
1252    }
1253
1254    // Handle different MCP methods using pattern matching
1255    match &request.method {
1256        // 2026-07-28 removed the handshake.
1257        McpMethod::Initialize(_) if !protocol.is_modern() => {
1258            debug!(endpoint = %endpoint_type, "Processing initialize");
1259            handle_initialize(
1260                endpoint_type,
1261                query_tool_enabled,
1262                read_data_product_tool_enabled,
1263            )
1264        }
1265        McpMethod::ToolsList(_) => {
1266            debug!(endpoint = %endpoint_type, "Processing tools/list");
1267            handle_tools_list(
1268                endpoint_type,
1269                query_tool_enabled,
1270                read_data_product_tool_enabled,
1271                max_response_size,
1272            )
1273        }
1274        McpMethod::ToolsCall(params) => {
1275            debug!(tool = %params, endpoint = %endpoint_type, "Processing tools/call");
1276            handle_tools_call(
1277                client,
1278                params,
1279                endpoint_type,
1280                query_tool_enabled,
1281                read_data_product_tool_enabled,
1282                max_response_size,
1283                metrics,
1284            )
1285            .await
1286        }
1287        // Dual-era clients probe with `server/discover` and fall back to
1288        // `initialize` when 2025-11-25 answers it with a not found error.
1289        McpMethod::ServerDiscover(_) if protocol.is_modern() => {
1290            debug!(endpoint = %endpoint_type, "Processing server/discover");
1291            handle_discover(
1292                endpoint_type,
1293                query_tool_enabled,
1294                read_data_product_tool_enabled,
1295            )
1296        }
1297        McpMethod::Initialize(_)
1298        | McpMethod::ServerDiscover(_)
1299        | McpMethod::Ping(_)
1300        | McpMethod::NotificationsInitialized(_)
1301        | McpMethod::Unknown => Err(McpRequestError::MethodNotFound(
1302            "unknown method".to_string(),
1303        )),
1304    }
1305}
1306
1307/// Instructions returned by `initialize` and `server/discover` for each
1308/// endpoint type.
1309/// These guide the AI agent on how to use the server correctly.
1310fn endpoint_instructions(
1311    endpoint_type: McpEndpointType,
1312    query_tool_enabled: bool,
1313    read_data_product_tool_enabled: bool,
1314) -> Option<String> {
1315    match endpoint_type {
1316        McpEndpointType::Agent => {
1317            // Only reference tools that are actually exposed by tools/list.
1318            // Both flags off is a valid (if unusual) config where the agent
1319            // can only discover products, not read them — we say so instead
1320            // of pointing at a hidden tool.
1321            let read_paragraph = match (read_data_product_tool_enabled, query_tool_enabled) {
1322                (true, _) => {
1323                    "`read_data_product` automatically routes the \
1324                     read to the cluster recorded in the data product catalog so indexes are used; \
1325                     you only need to set the `cluster` parameter if you intentionally want the \
1326                     read to run on a different cluster (e.g. one with larger or more replicas). \
1327                     A null `cluster` in discovery means your role lacks USAGE on the object's \
1328                     index/compute cluster; `read_data_product` without an override then reads on \
1329                     your session's default cluster (safe: only materialized views appear this way, \
1330                     and they serve from persist). "
1331                }
1332                (false, true) => {
1333                    "Use the `query` tool to read data products, passing the cluster from \
1334                     `get_data_product_details` so indexed reads hit the arrangement. If \
1335                     `get_data_product_details` returns a null `cluster`, your role lacks USAGE on \
1336                     the object's index/compute cluster; run the `query` against any cluster your \
1337                     role can use (the read still works, just without the index arrangement). "
1338                }
1339                (false, false) => {
1340                    "This server is configured for discovery only: no read tool is exposed. \
1341                     Use `get_data_products` and `get_data_product_details` to inspect what \
1342                     is available. "
1343                }
1344            };
1345            Some(format!(
1346                "You have access to Materialize data products via MCP. \
1347                 Prefer indexed objects (served from memory) over unindexed materialized views \
1348                 (read from persistent storage). {read_paragraph}\
1349                 `get_data_product_details` returns a `hydration` object with `hydrated`, \
1350                 `replica_count`, and `hydrated_replica_count` fields. Reads never return \
1351                 partial data: a read against a not-yet-hydrated product blocks until the \
1352                 dataflow catches up, and may hit the request timeout. Check `hydrated` \
1353                 before reading: if it is false and `replica_count` is greater than 0, the \
1354                 dataflow is still warming up, so wait and retry; if `replica_count` is 0 the \
1355                 cluster has no replicas and the read cannot make progress until one is added.",
1356            ))
1357        }
1358        McpEndpointType::Developer => {
1359            // Only advertise the `query` tool when it is actually exposed:
1360            // otherwise the instructions would point agents at a tool that
1361            // tools/list does not list.
1362            let query_tool_line = if query_tool_enabled {
1363                "- query: read-only SELECT/SHOW/EXPLAIN that can also reach user objects on a named cluster. Use this for EXPLAIN ANALYZE and for inspecting user objects directly.\n"
1364            } else {
1365                ""
1366            };
1367            // Same reasoning for the introspection rule: only route to the
1368            // `query` tool when it is exposed, otherwise say that
1369            // cluster-targeted introspection is unavailable here.
1370            let introspection_rule = if query_tool_enabled {
1371                "- mz_introspection relations (for example mz_dataflow_arrangement_sizes) are cluster-scoped and query_system_catalog answers about the session's default cluster: read them only through the query tool with its cluster argument (and cluster_replica on a multi-replica cluster), never through query_system_catalog"
1372            } else {
1373                "- mz_introspection relations (for example mz_dataflow_arrangement_sizes) are cluster-scoped and query_system_catalog answers about the session's default cluster only; cluster-targeted introspection is unavailable on this server (the query tool is disabled), so say so rather than reporting another cluster's numbers"
1374            };
1375            Some(format!(
1376                "You are connected to the Materialize developer MCP server for troubleshooting and observability.\n\n\
1377                 Tools:\n\
1378                 - query_system_catalog: read-only SELECT/SHOW/EXPLAIN restricted to system catalog tables (mz_*, pg_catalog, information_schema). No cluster argument; prefer this for most catalog lookups.\n\
1379                 {query_tool_line}\n\
1380                 IMPORTANT: Before writing queries, discover table schemas using the mz_ontology tables:\n\
1381                 - mz_internal.mz_ontology_entity_types: what catalog entities exist and which tables they map to\n\
1382                 - mz_internal.mz_ontology_link_types: relationships between entities (foreign keys, metrics, etc.)\n\
1383                 - mz_internal.mz_ontology_properties: column names, types, and descriptions for each entity\n\
1384                 - mz_internal.mz_ontology_semantic_types: typed ID domains (CatalogItemId, ReplicaId, etc.)\n\n\
1385                 Use these to find the correct tables, join paths, and column names instead of guessing.\n\n\
1386                 Key rules:\n\
1387                 - mz_source_statuses and mz_sink_statuses use `last_status_change_at` (NOT `updated_at`)\n\
1388                 - mz_cluster_replica_utilization only has `replica_id` — JOIN with mz_cluster_replicas and mz_clusters to get names\n\
1389                 {introspection_rule}\n\
1390                 - mz_dataflow_arrangement_sizes.id and mz_dataflows.id are dataflow ids (uint8), not catalog ids: do not JOIN them to mz_catalog.mz_objects.id (text); join through mz_introspection.mz_compute_exports (dataflow_id to export_id) instead, or match the name column, which reads Dataflow: <database>.<schema>.<object>\n\
1391                 - Use SHOW COLUMNS FROM <table> to verify column names if unsure",
1392            ))
1393        }
1394    }
1395}
1396
1397fn server_info(endpoint_type: McpEndpointType) -> ServerInfo {
1398    ServerInfo {
1399        name: format!("materialize-mcp-{}", endpoint_type),
1400        version: env!("CARGO_PKG_VERSION").to_string(),
1401    }
1402}
1403
1404fn handle_initialize(
1405    endpoint_type: McpEndpointType,
1406    query_tool_enabled: bool,
1407    read_data_product_tool_enabled: bool,
1408) -> Result<McpResult, McpRequestError> {
1409    Ok(McpResult::Initialize(InitializeResult {
1410        protocol_version: MCP_PROTOCOL_VERSION.to_string(),
1411        capabilities: Capabilities { tools: json!({}) },
1412        server_info: server_info(endpoint_type),
1413        instructions: endpoint_instructions(
1414            endpoint_type,
1415            query_tool_enabled,
1416            read_data_product_tool_enabled,
1417        ),
1418    }))
1419}
1420
1421fn handle_discover(
1422    endpoint_type: McpEndpointType,
1423    query_tool_enabled: bool,
1424    read_data_product_tool_enabled: bool,
1425) -> Result<McpResult, McpRequestError> {
1426    Ok(McpResult::Discover(DiscoverResult {
1427        supported_versions: [
1428            ProtocolVersion::V2026_07_28.as_str(),
1429            ProtocolVersion::V2025_11_25.as_str(),
1430        ],
1431        capabilities: Capabilities { tools: json!({}) },
1432        instructions: endpoint_instructions(
1433            endpoint_type,
1434            query_tool_enabled,
1435            read_data_product_tool_enabled,
1436        ),
1437    }))
1438}
1439
1440fn handle_tools_list(
1441    endpoint_type: McpEndpointType,
1442    query_tool_enabled: bool,
1443    read_data_product_tool_enabled: bool,
1444    max_response_size: usize,
1445) -> Result<McpResult, McpRequestError> {
1446    let size_hint = format!(
1447        "Response limit: {:.1} MB.",
1448        f64::cast_lossy(max_response_size) / 1_000_000.0
1449    );
1450
1451    let tools = match endpoint_type {
1452        McpEndpointType::Agent => {
1453            let mut tools = vec![
1454                ToolDefinition {
1455                    name: "get_data_products".to_string(),
1456                    title: Some("List Data Products".to_string()),
1457                    description: "Discover all available real-time data views (data products) that represent business entities like customers, orders, products, etc. Each data product provides fresh, queryable data with defined schemas. Use this first to see what data is available before querying specific information.".to_string(),
1458                    input_schema: json!({
1459                        "type": "object",
1460                        "properties": {},
1461                        "required": []
1462                    }),
1463                    annotations: Some(READ_ONLY_ANNOTATIONS),
1464                },
1465                ToolDefinition {
1466                    name: "get_data_product_details".to_string(),
1467                    title: Some("Get Data Product Details".to_string()),
1468                    description: "Get the complete schema and structure of a specific data product, plus a `hydration` object reporting whether the dataflow is ready across the cluster's replicas (`{hydrated, replica_count, hydrated_replica_count}`). This shows you exactly what fields are available, their types, and what data you can query. Reads never return partial data, so check `hydration` before reading: if `hydrated` is false and `replica_count` is greater than 0 the dataflow is still warming up (a read would block until it catches up, possibly hitting the request timeout), so wait and retry; if `replica_count` is 0 the cluster has no replicas and the read cannot make progress until one is added.".to_string(),
1469                    input_schema: json!({
1470                        "type": "object",
1471                        "properties": {
1472                            "name": {
1473                                "type": "string",
1474                                "description": "Exact name of the data product from get_data_products() list"
1475                            }
1476                        },
1477                        "required": ["name"]
1478                    }),
1479                    annotations: Some(READ_ONLY_ANNOTATIONS),
1480                },
1481            ];
1482            if read_data_product_tool_enabled {
1483                tools.push(ToolDefinition {
1484                    name: "read_data_product".to_string(),
1485                    title: Some("Read Data Product".to_string()),
1486                    description: format!("Read rows from a specific data product. Returns up to `limit` rows (default {DEFAULT_READ_LIMIT}). The data product must exist in the catalog (use get_data_products() to discover available products). Use this to retrieve actual data from a known data product. {size_hint}"),
1487                    input_schema: json!({
1488                        "type": "object",
1489                        "properties": {
1490                            "name": {
1491                                "type": "string",
1492                                "description": "Exact fully-qualified name of the data product (e.g. '\"materialize\".\"schema\".\"view_name\"')"
1493                            },
1494                            "limit": {
1495                                "type": "integer",
1496                                "description": format!("Maximum number of rows to return (default {DEFAULT_READ_LIMIT})"),
1497                                "default": DEFAULT_READ_LIMIT
1498                            },
1499                            "cluster": {
1500                                "type": "string",
1501                                "description": "Optional override. By default, the read runs on the cluster recorded in the data product catalog (where the index or materialized view dataflow lives), so indexed reads actually hit their arrangement. A null `cluster` in discovery means your role lacks USAGE on the object's index/compute cluster; without an override, the read then runs on your session's default cluster (safe: only materialized views appear this way, and they serve from persist). Set this only to intentionally run the same read on a different cluster — e.g. one with more or larger replicas, or to compare cost/latency."
1502                            }
1503                        },
1504                        "required": ["name"]
1505                    }),
1506                    annotations: Some(READ_ONLY_ANNOTATIONS),
1507                });
1508            }
1509            if query_tool_enabled {
1510                tools.push(ToolDefinition {
1511                    name: "query".to_string(),
1512                    title: Some("Query Data Products".to_string()),
1513                    description: format!("Execute SQL queries against real-time data products to retrieve current business information. Use standard PostgreSQL syntax. You can JOIN multiple data products together, but ONLY if they are all hosted on the same cluster. Always specify the cluster parameter from the data product details. This provides fresh, up-to-date results from materialized views. {size_hint}"),
1514                    input_schema: json!({
1515                        "type": "object",
1516                        "properties": {
1517                            "cluster": {
1518                                "type": "string",
1519                                "description": "Exact cluster name from the data product details - required for query execution"
1520                            },
1521                            "sql_query": {
1522                                "type": "string",
1523                                "description": "PostgreSQL-compatible SELECT statement to retrieve data. Use the fully qualified data product name exactly as provided (with double quotes). You can JOIN multiple data products, but only those on the same cluster."
1524                            }
1525                        },
1526                        "required": ["cluster", "sql_query"]
1527                    }),
1528                    annotations: Some(READ_ONLY_ANNOTATIONS),
1529                });
1530            }
1531            tools
1532        }
1533        McpEndpointType::Developer => {
1534            let mut tools = vec![ToolDefinition {
1535                name: "query_system_catalog".to_string(),
1536                title: Some("Query System Catalog".to_string()),
1537                description: concat!(
1538                    "Query Materialize system catalog tables for troubleshooting and observability. ",
1539                    "Only mz_*, pg_catalog, and information_schema tables are accessible. ",
1540                    "Use the mz_internal.mz_ontology_* tables to discover tables, columns, and join paths before writing queries.",
1541                ).to_owned() + &format!(" {size_hint}"),
1542                input_schema: json!({
1543                    "type": "object",
1544                    "properties": {
1545                        "sql_query": {
1546                            "type": "string",
1547                            "description": "PostgreSQL-compatible SELECT, SHOW, or EXPLAIN query referencing mz_*, pg_catalog, or information_schema tables"
1548                        }
1549                    },
1550                    "required": ["sql_query"]
1551                }),
1552                annotations: Some(READ_ONLY_ANNOTATIONS),
1553            }];
1554            if query_tool_enabled {
1555                tools.push(ToolDefinition {
1556                    name: "query".to_string(),
1557                    title: Some("Query".to_string()),
1558                    description: format!(
1559                        "Execute a read-only SQL query (SELECT, SHOW, or EXPLAIN) against any object the role can access, including system catalog and user objects. Requires a cluster, which is what enables EXPLAIN ANALYZE and queries against indexed user objects. For pure system catalog lookups that do not need a cluster, prefer `query_system_catalog`. {size_hint}",
1560                    ),
1561                    input_schema: json!({
1562                        "type": "object",
1563                        "properties": {
1564                            "cluster": {
1565                                "type": "string",
1566                                "description": "Exact cluster name the query should run on. Required: EXPLAIN ANALYZE and queries against indexed user objects need a specific cluster to execute on."
1567                            },
1568                            "cluster_replica": {
1569                                "type": "string",
1570                                "description": "Optional replica name (e.g. 'r1') to target one replica of the cluster. Required for EXPLAIN ANALYZE on clusters with more than one replica. Find replica names in mz_catalog.mz_cluster_replicas."
1571                            },
1572                            "sql_query": {
1573                                "type": "string",
1574                                "description": "PostgreSQL-compatible SELECT, SHOW, or EXPLAIN statement. Multi-statement queries are rejected."
1575                            }
1576                        },
1577                        "required": ["cluster", "sql_query"]
1578                    }),
1579                    annotations: Some(READ_ONLY_ANNOTATIONS),
1580                });
1581            }
1582            tools
1583        }
1584    };
1585
1586    Ok(McpResult::ToolsList(ToolsListResult { tools }))
1587}
1588
1589async fn handle_tools_call(
1590    client: &mut AuthedClient,
1591    params: &ToolsCallParams,
1592    endpoint_type: McpEndpointType,
1593    query_tool_enabled: bool,
1594    read_data_product_tool_enabled: bool,
1595    max_response_size: usize,
1596    metrics: &McpMetrics,
1597) -> Result<McpResult, McpRequestError> {
1598    // Drop-recording so metrics survive task cancellation.
1599    let mut guard = ToolCallGuard::new(metrics, endpoint_type.as_label(), params.to_string());
1600
1601    let result = match (endpoint_type, params) {
1602        (McpEndpointType::Agent, ToolsCallParams::GetDataProducts(_)) => {
1603            get_data_products(client, max_response_size).await
1604        }
1605        (McpEndpointType::Agent, ToolsCallParams::GetDataProductDetails(p)) => {
1606            get_data_product_details(client, &p.name, max_response_size).await
1607        }
1608        (McpEndpointType::Agent, ToolsCallParams::ReadDataProduct(_))
1609            if !read_data_product_tool_enabled =>
1610        {
1611            Err(McpRequestError::ToolNotFound(
1612                "read_data_product tool is not available. Use the query tool to read data products."
1613                    .to_string(),
1614            ))
1615        }
1616        (McpEndpointType::Agent, ToolsCallParams::ReadDataProduct(p)) => {
1617            read_data_product(
1618                client,
1619                &p.name,
1620                p.limit,
1621                p.cluster.as_deref(),
1622                max_response_size,
1623            )
1624            .await
1625        }
1626        (McpEndpointType::Agent, ToolsCallParams::Query(_)) if !query_tool_enabled => {
1627            Err(McpRequestError::ToolNotFound(
1628                "query tool is not available. Use get_data_products, get_data_product_details, and read_data_product instead.".to_string(),
1629            ))
1630        }
1631        (McpEndpointType::Agent, ToolsCallParams::Query(p)) => {
1632            // Replica pinning is deliberately not part of the agent surface:
1633            // drop `cluster_replica` even if a client supplies it.
1634            execute_query(client, &p.cluster, None, &p.sql_query, max_response_size).await
1635        }
1636        (McpEndpointType::Developer, ToolsCallParams::QuerySystemCatalog(p)) => {
1637            query_system_catalog(client, &p.sql_query, max_response_size).await
1638        }
1639        (McpEndpointType::Developer, ToolsCallParams::Query(_)) if !query_tool_enabled => {
1640            Err(McpRequestError::ToolNotFound(
1641                "query tool is not available. Use query_system_catalog instead.".to_string(),
1642            ))
1643        }
1644        (McpEndpointType::Developer, ToolsCallParams::Query(p)) => {
1645            execute_query(
1646                client,
1647                &p.cluster,
1648                p.cluster_replica.as_deref(),
1649                &p.sql_query,
1650                max_response_size,
1651            )
1652            .await
1653        }
1654        // Tool called on wrong endpoint
1655        (endpoint, tool) => Err(McpRequestError::ToolNotFound(format!(
1656            "{} is not available on {} endpoint",
1657            tool, endpoint
1658        ))),
1659    };
1660
1661    guard.set_status(call_status(&result));
1662
1663    result
1664}
1665
1666/// Maps a handler result to its metric [`McpCallStatus`]. Errors carry the
1667/// closed `error_type()` label; anything else is `Ok`.
1668fn call_status<T>(result: &Result<T, McpRequestError>) -> McpCallStatus {
1669    match result {
1670        Ok(_) => McpCallStatus::Ok,
1671        Err(e) => McpCallStatus::Error(e.error_type()),
1672    }
1673}
1674
1675/// Execute SQL via `execute_request` from sql.rs.
1676async fn execute_sql(
1677    client: &mut AuthedClient,
1678    query: &str,
1679) -> Result<Vec<Box<serde_json::value::RawValue>>, McpRequestError> {
1680    let mut response = SqlResponse::new();
1681
1682    execute_request(
1683        client,
1684        SqlRequest::Simple {
1685            query: mz_ore::sql::Sql::trusted_external_request(query.to_string()),
1686        },
1687        &mut response,
1688    )
1689    .await
1690    .map_err(|e| McpRequestError::QueryExecutionFailed(e.to_string()))?;
1691
1692    select_single_rows(response.results)
1693}
1694
1695/// Returns the rows of the single row-returning statement in a response.
1696///
1697/// A read's framing statements (`BEGIN`, `SET`, `COMMIT`) report `Ok`, so only
1698/// the user's statement returns rows. Surfaces the first error, and a second
1699/// row-returning statement is an error rather than a dropped result.
1700fn select_single_rows(
1701    results: Vec<SqlResult>,
1702) -> Result<Vec<Box<serde_json::value::RawValue>>, McpRequestError> {
1703    let mut rows = None;
1704    for result in results {
1705        match result {
1706            SqlResult::Rows { rows: r, .. } => {
1707                if rows.is_some() {
1708                    return Err(McpRequestError::Internal(anyhow!(
1709                        "MCP query returned multiple row-producing statements"
1710                    )));
1711                }
1712                rows = Some(r);
1713            }
1714            SqlResult::Err { error, .. } => {
1715                return Err(McpRequestError::QueryExecutionFailed(error.message));
1716            }
1717            SqlResult::Ok { .. } => continue,
1718        }
1719    }
1720
1721    rows.ok_or_else(|| {
1722        McpRequestError::QueryExecutionFailed("Query did not return any results".to_string())
1723    })
1724}
1725
1726/// Serialize rows to JSON and enforce the response size cap.
1727///
1728/// If the serialized response exceeds `max_size` bytes, returns an error
1729/// telling the agent to narrow its query. This mirrors how the HTTP SQL
1730/// endpoint handles `max_result_size` in sql.rs — fail cleanly rather
1731/// than silently truncating.
1732fn format_rows_response(
1733    rows: Vec<Box<serde_json::value::RawValue>>,
1734    max_size: usize,
1735) -> Result<McpResult, McpRequestError> {
1736    // Each row is already-serialized compact JSON. `RawValue` re-serializes
1737    // verbatim, so the outer array is pretty-printed while rows stay compact.
1738    let text =
1739        serde_json::to_string_pretty(&rows).map_err(|e| McpRequestError::Internal(anyhow!(e)))?;
1740
1741    if text.len() > max_size {
1742        return Err(McpRequestError::QueryExecutionFailed(format!(
1743            "Response size ({} bytes) exceeds the {} byte limit. \
1744             Use LIMIT or WHERE to narrow your query.",
1745            text.len(),
1746            max_size,
1747        )));
1748    }
1749
1750    Ok(McpResult::ToolContent(ToolContentResult {
1751        content: vec![ContentBlock {
1752            content_type: "text".to_string(),
1753            text,
1754        }],
1755        is_error: false,
1756    }))
1757}
1758
1759async fn get_data_products(
1760    client: &mut AuthedClient,
1761    max_response_size: usize,
1762) -> Result<McpResult, McpRequestError> {
1763    debug!("Executing get_data_products");
1764    let rows = execute_sql(client, DISCOVERY_QUERY).await?;
1765    debug!("get_data_products returned {} rows", rows.len());
1766
1767    format_rows_response(rows, max_response_size)
1768}
1769
1770async fn get_data_product_details(
1771    client: &mut AuthedClient,
1772    name: &str,
1773    max_response_size: usize,
1774) -> Result<McpResult, McpRequestError> {
1775    debug!(name = %name, "Executing get_data_product_details");
1776
1777    let query = format!("{}{}", DETAILS_QUERY_PREFIX, escaped_string_literal(name));
1778
1779    let rows = execute_sql(client, &query).await?;
1780
1781    if rows.is_empty() {
1782        return Err(McpRequestError::DataProductNotFound(name.to_string()));
1783    }
1784
1785    format_rows_response(rows, max_response_size)
1786}
1787
1788/// Parses a data product name and returns it safely quoted for SQL interpolation.
1789///
1790/// Uses the SQL parser to validate the name as an `UnresolvedItemName`, then
1791/// formats it with `FormatMode::Stable` so every identifier part is
1792/// double-quoted with proper escaping. This prevents SQL injection regardless
1793/// of the input.
1794fn safe_data_product_name(name: &str) -> Result<String, McpRequestError> {
1795    let name = name.trim();
1796    if name.is_empty() {
1797        return Err(McpRequestError::QueryValidationFailed(
1798            "Data product name cannot be empty".to_string(),
1799        ));
1800    }
1801
1802    // `parse_item_name_with_limit` enforces the 1 MB guard on the raw input
1803    // (DEX-64) before lexing.
1804    let parsed = parse_item_name_with_limit(name)
1805        .map_err(McpRequestError::QueryValidationFailed)?
1806        .map_err(|_| {
1807            McpRequestError::QueryValidationFailed(format!(
1808                "Invalid data product name: {}. Expected a valid object name, \
1809                 e.g. '\"database\".\"schema\".\"name\"' or 'my_view'",
1810                name
1811            ))
1812        })?;
1813
1814    // Stable formatting forces all identifiers to be double-quoted,
1815    // so SQL keywords and special characters cannot escape.
1816    Ok(parsed.to_ast_string_stable())
1817}
1818
1819/// Read rows from a data product. Issues a single read-only query.
1820///
1821/// The read routes to the cluster recorded in `mz_mcp_data_products.cluster`
1822/// when the role has USAGE on it, so reads of indexed objects hit the index's
1823/// in-memory arrangement. That column is null when the role lacks USAGE on the
1824/// object's cluster (DEX-66); in that case, and absent an override, the read
1825/// runs on the session's default (serving) cluster instead. Only materialized
1826/// views can appear this way, since plain views without at least one usable
1827/// index cluster are excluded from `mz_mcp_data_products` entirely; the
1828/// fallback is therefore safe (materialized views serve from persist without
1829/// recompute).
1830///
1831/// `cluster_override` forces the read onto a named cluster instead — useful for
1832/// a differently-sized or differently-replicated cluster.
1833///
1834/// The name is expected to come from `get_data_products()` /
1835/// `get_data_product_details()`. The query runs inside a READ ONLY
1836/// transaction, preventing mutations.
1837async fn read_data_product(
1838    client: &mut AuthedClient,
1839    name: &str,
1840    limit: u32,
1841    cluster_override: Option<&str>,
1842    max_response_size: usize,
1843) -> Result<McpResult, McpRequestError> {
1844    debug!(name = %name, limit = limit, cluster_override = ?cluster_override, "Executing read_data_product");
1845
1846    // Parse and safely quote the name for SQL interpolation.
1847    let safe_name = safe_data_product_name(name)?;
1848
1849    // Existence check and cluster routing. `mz_mcp_data_products.cluster` is
1850    // non-null only when the role has USAGE on that cluster, so any value here
1851    // is safe to route to. `NULLS LAST` prefers such a usable cluster when an
1852    // object appears both with a usable cluster and as null (an object indexed
1853    // on several clusters, some of which the role cannot use).
1854    let lookup_query = format!(
1855        "SELECT dp.cluster FROM mz_internal.mz_mcp_data_products dp \
1856         WHERE dp.object_name = {} \
1857         ORDER BY dp.cluster NULLS LAST \
1858         LIMIT 1",
1859        escaped_string_literal(name)
1860    );
1861    let lookup_rows = execute_sql(client, &lookup_query).await?;
1862    if lookup_rows.is_empty() {
1863        return Err(McpRequestError::DataProductNotFound(name.to_string()));
1864    }
1865    // Rows are stored as pre-serialized JSON arrays, so parse the single-cell
1866    // lookup row to read `dp.cluster`.
1867    let catalog_cluster: Option<String> = lookup_rows
1868        .first()
1869        .and_then(|row| serde_json::from_str::<Vec<serde_json::Value>>(row.get()).ok())
1870        .and_then(|row| row.into_iter().next())
1871        .and_then(|v| v.as_str().map(|s| s.to_string()));
1872
1873    // An override wins. Otherwise route to the catalog cluster when the role
1874    // can use it (non-null); when it is null the role lacks USAGE on the
1875    // object's cluster, so leave the cluster unset and read on the session's
1876    // default (serving) cluster. That still works: materialized views serve
1877    // from persist and views recompute, just without index benefit.
1878    let target_cluster: Option<&str> = cluster_override.or(catalog_cluster.as_deref());
1879
1880    // No row cap is applied here: the response is bounded by the size cap
1881    // enforced in format_rows_response (MCP_MAX_RESPONSE_SIZE), and by
1882    // max_result_size at the adapter layer. Mirrors the SQL HTTP endpoint,
1883    // which also leans on a size cap rather than a row cap.
1884    let read_query = build_read_query(&safe_name, limit, target_cluster);
1885
1886    let rows = execute_sql(client, &read_query).await?;
1887
1888    format_rows_response(rows, max_response_size)
1889}
1890
1891/// Builds the SQL the agent runs for `read_data_product`.
1892///
1893/// `safe_name` must already be the validated, quoted form produced by
1894/// [`safe_data_product_name`]. When `target_cluster` is `Some`, it is escaped
1895/// as a SQL string literal and wrapped in `SET CLUSTER` inside the `BEGIN READ
1896/// ONLY` transaction so the cluster choice is scoped to this read and does not
1897/// leak into the session. When it is `None`, no `SET CLUSTER` is emitted and
1898/// the read runs on the session's default (serving) cluster.
1899fn build_read_query(safe_name: &str, limit: u32, target_cluster: Option<&str>) -> String {
1900    let body = format!("SELECT * FROM {safe_name} LIMIT {limit}");
1901    match target_cluster {
1902        Some(cluster) => read_only_txn(
1903            &format!("SET CLUSTER = {}", escaped_string_literal(cluster)),
1904            &body,
1905        ),
1906        None => format!("BEGIN READ ONLY; {body}\n; COMMIT;"),
1907    }
1908}
1909
1910/// Wraps `body` in a `BEGIN READ ONLY; <set_clause>; <body>; COMMIT;` frame so
1911/// `set_clause` is scoped to this read and does not leak into the session.
1912///
1913/// NOTE: the newline before `; COMMIT;` stops a trailing `--` comment in `body`
1914/// from swallowing the `COMMIT`.
1915fn read_only_txn(set_clause: &str, body: &str) -> String {
1916    format!("BEGIN READ ONLY; {set_clause}; {body}\n; COMMIT;")
1917}
1918
1919/// Validates query is a single SELECT, SHOW, or EXPLAIN statement.
1920fn validate_readonly_query(sql: &str) -> Result<(), McpRequestError> {
1921    let sql = sql.trim();
1922    if sql.is_empty() {
1923        return Err(McpRequestError::QueryValidationFailed(
1924            "Empty query".to_string(),
1925        ));
1926    }
1927
1928    // Parse the SQL to get AST. `parse_with_limit` rejects inputs larger
1929    // than `MAX_STATEMENT_BATCH_SIZE` before lexing so the MCP endpoint
1930    // enforces the same 1 MB guard as the SQL HTTP path (DEX-64).
1931    let stmts = parse_with_limit(sql)
1932        .map_err(McpRequestError::QueryValidationFailed)?
1933        .map_err(|e| {
1934            McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
1935        })?;
1936
1937    // Only allow a single statement
1938    if stmts.len() != 1 {
1939        return Err(McpRequestError::QueryValidationFailed(format!(
1940            "Only one query allowed at a time. Found {} statements.",
1941            stmts.len()
1942        )));
1943    }
1944
1945    // Allowlist: SELECT, SHOW, and every read-only EXPLAIN variant. EXPLAIN
1946    // expands to six distinct Statement variants in the parser (ExplainPlan
1947    // covers only the most common one). Listing them out exhaustively beats
1948    // matching by string prefix so a new write-capable EXPLAIN variant — were
1949    // one ever added — would have to be considered here.
1950    let stmt = &stmts[0];
1951    use mz_sql_parser::ast::Statement;
1952
1953    match &stmt.ast {
1954        Statement::Select(_)
1955        | Statement::Show(_)
1956        | Statement::ExplainPlan(_)
1957        | Statement::ExplainPushdown(_)
1958        | Statement::ExplainTimestamp(_)
1959        | Statement::ExplainSinkSchema(_)
1960        | Statement::ExplainAnalyzeObject(_)
1961        | Statement::ExplainAnalyzeCluster(_) => Ok(()),
1962        _ => Err(McpRequestError::QueryValidationFailed(
1963            "Only SELECT, SHOW, and EXPLAIN statements are allowed".to_string(),
1964        )),
1965    }
1966}
1967
1968async fn execute_query(
1969    client: &mut AuthedClient,
1970    cluster: &str,
1971    cluster_replica: Option<&str>,
1972    sql_query: &str,
1973    max_response_size: usize,
1974) -> Result<McpResult, McpRequestError> {
1975    debug!(cluster = %cluster, cluster_replica = ?cluster_replica, "Executing user query");
1976
1977    validate_readonly_query(sql_query)?;
1978    validate_cluster_replica(cluster_replica)?;
1979
1980    // READ ONLY prevents mutations; SET CLUSTER (and, when requested,
1981    // SET CLUSTER_REPLICA) scope the placement to this read.
1982    let combined_query = read_only_txn(&query_set_clause(cluster, cluster_replica), sql_query);
1983
1984    let rows = execute_sql(client, &combined_query).await?;
1985
1986    format_rows_response(rows, max_response_size)
1987}
1988
1989/// Builds the `SET` clause for `execute_query`: always `SET CLUSTER`, plus
1990/// `SET CLUSTER_REPLICA` when a replica is requested (e.g. for
1991/// `EXPLAIN ANALYZE` on a cluster with multiple replicas). Both names pass
1992/// through `escaped_string_literal` since they are interpolated into SQL
1993/// string literals.
1994fn query_set_clause(cluster: &str, cluster_replica: Option<&str>) -> String {
1995    let mut set_clause = format!("SET CLUSTER = {}", escaped_string_literal(cluster));
1996    if let Some(replica) = cluster_replica {
1997        set_clause.push_str(&format!(
1998            "; SET CLUSTER_REPLICA = {}",
1999            escaped_string_literal(replica)
2000        ));
2001    }
2002    set_clause
2003}
2004
2005/// Rejects an empty or whitespace-only `cluster_replica`. Such a name would
2006/// otherwise produce `SET CLUSTER_REPLICA = ''`, which fails deep in the engine
2007/// as a generic execution error rather than a clean validation error. `None`
2008/// (no replica pin requested) is always valid.
2009fn validate_cluster_replica(cluster_replica: Option<&str>) -> Result<(), McpRequestError> {
2010    if let Some(replica) = cluster_replica {
2011        if replica.trim().is_empty() {
2012            return Err(McpRequestError::QueryValidationFailed(
2013                "cluster_replica must not be empty or whitespace-only".to_string(),
2014            ));
2015        }
2016    }
2017    Ok(())
2018}
2019
2020async fn query_system_catalog(
2021    client: &mut AuthedClient,
2022    sql_query: &str,
2023    max_response_size: usize,
2024) -> Result<McpResult, McpRequestError> {
2025    debug!("Executing query_system_catalog");
2026
2027    // First validate it's a read-only query
2028    validate_readonly_query(sql_query)?;
2029
2030    // Then validate that query only references mz_* tables by parsing the SQL
2031    validate_system_catalog_query(sql_query)?;
2032
2033    // Wrap the query in a READ ONLY transaction with a tight search_path
2034    // restricted to system schemas. This prevents unqualified `mz_*` references
2035    // from resolving to user-created objects (e.g. a view `public.mz_leak`) via
2036    // the session's search_path (mirrors the `BEGIN READ ONLY; SET ...` pattern
2037    // used by the agent `query` tool).
2038    let combined_query = read_only_txn(
2039        "SET search_path = mz_catalog, mz_internal, pg_catalog, information_schema",
2040        sql_query,
2041    );
2042
2043    let rows = execute_sql(client, &combined_query).await?;
2044
2045    format_rows_response(rows, max_response_size)
2046}
2047
2048/// Collects table references from SQL AST with their schema qualification.
2049struct TableReferenceCollector {
2050    /// Stores (schema, table_name) tuples. Schema is None if unqualified.
2051    tables: Vec<(Option<String>, String)>,
2052    /// CTE names to exclude from validation (they're not real tables)
2053    cte_names: std::collections::BTreeSet<String>,
2054    /// Item names written as `[<id> AS <name>]`. The planner resolves these by
2055    /// ID and ignores the name, so the name cannot be checked.
2056    id_references: Vec<String>,
2057}
2058
2059impl TableReferenceCollector {
2060    fn new() -> Self {
2061        Self {
2062            tables: Vec::new(),
2063            cte_names: std::collections::BTreeSet::new(),
2064            id_references: Vec::new(),
2065        }
2066    }
2067
2068    fn record_table(&mut self, name: &RawItemName) {
2069        let parts = &name.name().0;
2070        let Some(last) = parts.last() else {
2071            return;
2072        };
2073        let table_name = last.as_str().to_lowercase();
2074
2075        // Only an unqualified name can refer to a CTE. `public.t` names a
2076        // catalog item even when a CTE `t` is in scope.
2077        if parts.len() == 1 && self.cte_names.contains(&table_name) {
2078            return;
2079        }
2080
2081        // Extract schema if qualified (e.g., mz_catalog.mz_tables)
2082        let schema = (parts.len() >= 2).then(|| parts[parts.len() - 2].as_str().to_lowercase());
2083        self.tables.push((schema, table_name));
2084    }
2085}
2086
2087impl<'ast> Visit<'ast, Raw> for TableReferenceCollector {
2088    fn visit_cte_block(&mut self, block: &'ast mz_sql_parser::ast::CteBlock<Raw>) {
2089        // Bindings are registered before any body is visited, because a
2090        // WITH MUTUALLY RECURSIVE body can reference any binding in its block.
2091        self.cte_names.extend(
2092            block
2093                .bound_identifiers()
2094                .map(|ident| ident.as_str().to_lowercase()),
2095        );
2096        visit::visit_cte_block(self, block);
2097    }
2098
2099    fn visit_table_factor(&mut self, table_factor: &'ast mz_sql_parser::ast::TableFactor<Raw>) {
2100        // Only visit actual table references in FROM/JOIN clauses, not function names
2101        if let mz_sql_parser::ast::TableFactor::Table { name, .. } = table_factor {
2102            self.record_table(name);
2103        }
2104        visit::visit_table_factor(self, table_factor);
2105    }
2106
2107    fn visit_set_expr(&mut self, set_expr: &'ast mz_sql_parser::ast::SetExpr<Raw>) {
2108        // `TABLE t` parses to a set expression, not a table factor.
2109        if let mz_sql_parser::ast::SetExpr::Table(name) = set_expr {
2110            self.record_table(name);
2111        }
2112        visit::visit_set_expr(self, set_expr);
2113    }
2114
2115    fn visit_item_name(&mut self, name: &'ast RawItemName) {
2116        if let RawItemName::Id(..) = name {
2117            self.id_references.push(name.to_ast_string_simple());
2118        }
2119    }
2120}
2121
2122/// Validates that a query only references system catalog tables.
2123///
2124/// For SELECT statements, all table references must be in system schemas
2125/// (from `SYSTEM_SCHEMAS`, excluding `mz_unsafe`), and at least one system
2126/// table must be referenced (constant queries like `SELECT 1` are rejected
2127/// to prevent misuse of the developer endpoint for arbitrary computation).
2128/// SHOW and EXPLAIN statements are allowed without table references.
2129fn validate_system_catalog_query(sql: &str) -> Result<(), McpRequestError> {
2130    // Parse the SQL to validate it. `parse_with_limit` enforces the 1 MB
2131    // guard shared with the SQL HTTP path (DEX-64).
2132    let stmts = parse_with_limit(sql)
2133        .map_err(McpRequestError::QueryValidationFailed)?
2134        .map_err(|e| {
2135            McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
2136        })?;
2137
2138    if stmts.is_empty() {
2139        return Err(McpRequestError::QueryValidationFailed(
2140            "Empty query".to_string(),
2141        ));
2142    }
2143
2144    // Walk the AST to collect all table references
2145    let mut collector = TableReferenceCollector::new();
2146    for stmt in &stmts {
2147        collector.visit_statement(&stmt.ast);
2148    }
2149
2150    if !collector.id_references.is_empty() {
2151        return Err(McpRequestError::QueryValidationFailed(format!(
2152            "Query references objects by ID: {}. Reference system catalog tables by name.",
2153            collector.id_references.join(", ")
2154        )));
2155    }
2156
2157    // Use the canonical system schema list, excluding mz_unsafe which contains
2158    // internal-only objects that should not be exposed to MCP clients.
2159    let is_allowed_schema =
2160        |s: &str| SYSTEM_SCHEMAS.contains(&s) && s != namespaces::MZ_UNSAFE_SCHEMA;
2161
2162    // Helper to check if a table reference is allowed. Unqualified references
2163    // are accepted when they carry an unambiguous system prefix (`mz_`/`pg_`);
2164    // execution pins `search_path` to system schemas (see `query_system_catalog`),
2165    // so a user view like `public.mz_leak` cannot be reached by an unqualified name.
2166    let is_system_table = |(schema, table_name): &(Option<String>, String)| match schema {
2167        Some(s) => is_allowed_schema(s.as_str()),
2168        None => table_name.starts_with("mz_") || table_name.starts_with("pg_"),
2169    };
2170
2171    // Check that all table references are system tables
2172    let non_system_tables: Vec<String> = collector
2173        .tables
2174        .iter()
2175        .filter(|t| !is_system_table(t))
2176        .map(|(schema, table)| match schema {
2177            Some(s) => format!("{}.{}", s, table),
2178            None => table.clone(),
2179        })
2180        .collect();
2181
2182    if !non_system_tables.is_empty() {
2183        return Err(McpRequestError::QueryValidationFailed(format!(
2184            "Query references non-system tables: {}. Only system catalog tables (mz_*, pg_catalog, information_schema) are allowed.",
2185            non_system_tables.join(", ")
2186        )));
2187    }
2188
2189    // SHOW and EXPLAIN statements don't reference tables in the AST, but are safe
2190    // read-only operations. Only require system table references for SELECT.
2191    use mz_sql_parser::ast::Statement;
2192    let is_select = stmts.iter().any(|s| matches!(&s.ast, Statement::Select(_)));
2193
2194    if is_select && (collector.tables.is_empty() || !collector.tables.iter().any(is_system_table)) {
2195        return Err(McpRequestError::QueryValidationFailed(
2196            "Query must reference at least one system catalog table".to_string(),
2197        ));
2198    }
2199
2200    Ok(())
2201}
2202
2203#[cfg(test)]
2204mod tests {
2205    use super::*;
2206    use crate::http::sql::{Description, SqlError};
2207
2208    /// Serializes each row to a compact JSON array, matching how the HTTP SQL
2209    /// layer stores rows.
2210    fn raw_rows(rows: Vec<Vec<serde_json::Value>>) -> Vec<Box<serde_json::value::RawValue>> {
2211        rows.iter()
2212            .map(|r| serde_json::value::to_raw_value(r).unwrap())
2213            .collect()
2214    }
2215
2216    fn rows_result(rows: Vec<Vec<serde_json::Value>>) -> SqlResult {
2217        SqlResult::Rows {
2218            tag: String::new(),
2219            rows: raw_rows(rows),
2220            desc: Description { columns: vec![] },
2221            notices: vec![],
2222        }
2223    }
2224
2225    fn ok_result() -> SqlResult {
2226        SqlResult::Ok {
2227            ok: String::new(),
2228            notices: vec![],
2229            parameters: vec![],
2230            execution_time: None,
2231        }
2232    }
2233
2234    fn err_result(message: &str) -> SqlResult {
2235        SqlResult::Err {
2236            error: SqlError {
2237                message: message.to_string(),
2238                code: String::new(),
2239                detail: None,
2240                hint: None,
2241                position: None,
2242            },
2243            notices: vec![],
2244        }
2245    }
2246
2247    /// The row-returning statement's rows are returned, ignoring the `Ok`
2248    /// framing statements around it.
2249    #[mz_ore::test]
2250    fn test_select_single_rows_extracts_rows() {
2251        let rows = vec![vec![serde_json::json!(1)]];
2252        let results = vec![
2253            ok_result(),
2254            ok_result(),
2255            rows_result(rows.clone()),
2256            ok_result(),
2257        ];
2258        let got = select_single_rows(results).unwrap();
2259        let got: Vec<&str> = got.iter().map(|r| r.get()).collect();
2260        assert_eq!(got, vec!["[1]"]);
2261    }
2262
2263    /// A response with no row-returning statement is an error.
2264    #[mz_ore::test]
2265    fn test_select_single_rows_requires_rows() {
2266        let err = select_single_rows(vec![ok_result(), ok_result()]).unwrap_err();
2267        assert!(
2268            matches!(err, McpRequestError::QueryExecutionFailed(_)),
2269            "{err:?}"
2270        );
2271    }
2272
2273    /// The invariant is enforced: a second row-returning statement is an
2274    /// internal error rather than a silently dropped result.
2275    #[mz_ore::test]
2276    fn test_select_single_rows_rejects_multiple() {
2277        let results = vec![rows_result(vec![]), rows_result(vec![])];
2278        let err = select_single_rows(results).unwrap_err();
2279        assert!(matches!(err, McpRequestError::Internal(_)), "{err:?}");
2280    }
2281
2282    /// A statement error is surfaced.
2283    #[mz_ore::test]
2284    fn test_select_single_rows_surfaces_error() {
2285        let err = select_single_rows(vec![ok_result(), err_result("boom")]).unwrap_err();
2286        match err {
2287            McpRequestError::QueryExecutionFailed(msg) => assert_eq!(msg, "boom"),
2288            other => panic!("unexpected error: {other:?}"),
2289        }
2290    }
2291
2292    /// The DNS-rebinding defense: a disallowed `Origin` is rejected with 403,
2293    /// an allowed one passes, and a missing one passes (non-browser clients).
2294    #[mz_ore::test]
2295    fn test_validate_origin() {
2296        let allowed = [HeaderValue::from_static("https://good.example")];
2297
2298        assert!(validate_origin(&HeaderMap::new(), &allowed).is_none());
2299
2300        let mut ok = HeaderMap::new();
2301        ok.insert(http::header::ORIGIN, allowed[0].clone());
2302        assert!(validate_origin(&ok, &allowed).is_none());
2303
2304        let mut bad = HeaderMap::new();
2305        bad.insert(
2306            http::header::ORIGIN,
2307            HeaderValue::from_static("https://evil.example"),
2308        );
2309        let rejected = validate_origin(&bad, &allowed);
2310        assert_eq!(
2311            rejected
2312                .expect("disallowed origin must be rejected")
2313                .status(),
2314            StatusCode::FORBIDDEN,
2315        );
2316    }
2317
2318    /// The two constructors set the JSON-RPC version and put the payload in
2319    /// the right one of the mutually-exclusive `result` / `error` fields.
2320    #[mz_ore::test]
2321    fn test_mcp_response_constructors() {
2322        let id = serde_json::json!(1);
2323
2324        let ok = McpResponse::success(
2325            id.clone(),
2326            McpResult::ToolContent(ToolContentResult {
2327                content: vec![],
2328                is_error: false,
2329            })
2330            .into_body(ProtocolVersion::V2025_11_25, McpEndpointType::Developer),
2331        );
2332        assert_eq!(ok.jsonrpc, JSONRPC_VERSION);
2333        assert!(ok.result.is_some());
2334        assert!(ok.error.is_none());
2335
2336        let err = McpResponse::error(id, McpRequestError::ToolNotFound("t".to_string()).into());
2337        assert_eq!(err.jsonrpc, JSONRPC_VERSION);
2338        assert!(err.result.is_none());
2339        assert!(err.error.is_some());
2340    }
2341
2342    #[mz_ore::test]
2343    fn test_validate_readonly_query_select() {
2344        assert!(validate_readonly_query("SELECT * FROM mz_tables").is_ok());
2345        assert!(validate_readonly_query("SELECT 1 + 2").is_ok());
2346        assert!(validate_readonly_query("  SELECT 1  ").is_ok());
2347    }
2348
2349    #[mz_ore::test]
2350    fn test_validate_readonly_query_subqueries() {
2351        // Simple subquery in WHERE clause
2352        assert!(
2353            validate_readonly_query(
2354                "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
2355            )
2356            .is_ok()
2357        );
2358
2359        // Subquery in FROM clause
2360        assert!(
2361            validate_readonly_query(
2362                "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
2363            )
2364            .is_ok()
2365        );
2366
2367        // Correlated subquery
2368        assert!(validate_readonly_query(
2369            "SELECT * FROM mz_tables t WHERE EXISTS (SELECT 1 FROM mz_columns c WHERE c.id = t.id)"
2370        )
2371        .is_ok());
2372
2373        // Nested subqueries
2374        assert!(validate_readonly_query(
2375            "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns WHERE type IN (SELECT name FROM mz_types))"
2376        )
2377        .is_ok());
2378
2379        // Subquery with aggregation
2380        assert!(
2381            validate_readonly_query(
2382                "SELECT * FROM mz_tables WHERE id = (SELECT MAX(id) FROM mz_columns)"
2383            )
2384            .is_ok()
2385        );
2386    }
2387
2388    #[mz_ore::test]
2389    fn test_validate_readonly_query_show() {
2390        assert!(validate_readonly_query("SHOW CLUSTERS").is_ok());
2391        assert!(validate_readonly_query("SHOW TABLES").is_ok());
2392    }
2393
2394    #[mz_ore::test]
2395    fn test_validate_readonly_query_explain() {
2396        // Every read-only EXPLAIN variant must be accepted. Each line
2397        // corresponds to a distinct Statement::Explain* arm of the parser; if
2398        // one of them is dropped from the validator the test that lost its
2399        // arm will fail, naming the variant.
2400        assert!(validate_readonly_query("EXPLAIN SELECT 1").is_ok());
2401        assert!(
2402            validate_readonly_query("EXPLAIN FILTER PUSHDOWN FOR SELECT * FROM mz_tables").is_ok()
2403        );
2404        assert!(validate_readonly_query("EXPLAIN TIMESTAMP FOR SELECT 1").is_ok());
2405        assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR INDEX foo").is_ok());
2406        assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR MATERIALIZED VIEW mv").is_ok());
2407        assert!(validate_readonly_query("EXPLAIN ANALYZE CLUSTER MEMORY").is_ok());
2408    }
2409
2410    #[mz_ore::test]
2411    fn test_validate_readonly_query_rejects_writes() {
2412        assert!(validate_readonly_query("INSERT INTO t VALUES (1)").is_err());
2413        assert!(validate_readonly_query("UPDATE t SET a = 1").is_err());
2414        assert!(validate_readonly_query("DELETE FROM t").is_err());
2415        assert!(validate_readonly_query("CREATE TABLE t (a INT)").is_err());
2416        assert!(validate_readonly_query("DROP TABLE t").is_err());
2417    }
2418
2419    #[mz_ore::test]
2420    fn test_validate_readonly_query_rejects_multiple() {
2421        assert!(validate_readonly_query("SELECT 1; SELECT 2").is_err());
2422    }
2423
2424    #[mz_ore::test]
2425    fn test_validate_readonly_query_rejects_empty() {
2426        assert!(validate_readonly_query("").is_err());
2427        assert!(validate_readonly_query("   ").is_err());
2428    }
2429
2430    /// Regression test for DEX-64: without the 1 MB parser guard, the MCP
2431    /// validators would happily lex and parse multi-megabyte input. Both
2432    /// `validate_readonly_query` and `validate_system_catalog_query` now go
2433    /// through `parse_with_limit`, so an oversized batch is rejected with the
2434    /// same "statement batch size cannot exceed" message the SQL HTTP path
2435    /// emits.
2436    #[mz_ore::test]
2437    fn test_validate_readonly_query_enforces_size_limit() {
2438        use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
2439        // Just over the limit so the guard is the *only* thing that rejects.
2440        let oversized: String =
2441            "SELECT 1;".repeat((MAX_STATEMENT_BATCH_SIZE / "SELECT 1;".len()) + 1);
2442        let err = validate_readonly_query(&oversized).expect_err("should be rejected");
2443        let msg = err.to_string();
2444        assert!(
2445            msg.contains("statement batch size cannot exceed"),
2446            "expected size-guard error, got: {msg}"
2447        );
2448    }
2449
2450    #[mz_ore::test]
2451    fn test_validate_system_catalog_query_enforces_size_limit() {
2452        use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
2453        let stmt = "SELECT * FROM mz_tables;";
2454        let oversized: String = stmt.repeat((MAX_STATEMENT_BATCH_SIZE / stmt.len()) + 1);
2455        let err = validate_system_catalog_query(&oversized).expect_err("should be rejected");
2456        let msg = err.to_string();
2457        assert!(
2458            msg.contains("statement batch size cannot exceed"),
2459            "expected size-guard error, got: {msg}"
2460        );
2461    }
2462
2463    #[mz_ore::test]
2464    fn test_validate_system_catalog_query_accepts_mz_tables() {
2465        assert!(validate_system_catalog_query("SELECT * FROM mz_tables").is_ok());
2466        assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_comments").is_ok());
2467        assert!(
2468            validate_system_catalog_query(
2469                "SELECT * FROM mz_tables t JOIN mz_columns c ON t.id = c.id"
2470            )
2471            .is_ok()
2472        );
2473    }
2474
2475    #[mz_ore::test]
2476    fn test_validate_system_catalog_query_subqueries() {
2477        // Subquery with mz_* tables
2478        assert!(
2479            validate_system_catalog_query(
2480                "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
2481            )
2482            .is_ok()
2483        );
2484
2485        // Nested subqueries with mz_* tables
2486        assert!(validate_system_catalog_query(
2487            "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM mz_columns WHERE type IN (SELECT id FROM mz_types))"
2488        )
2489        .is_ok());
2490
2491        // Subquery in FROM clause
2492        assert!(
2493            validate_system_catalog_query(
2494                "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
2495            )
2496            .is_ok()
2497        );
2498
2499        // Reject subqueries that reference non-mz_* tables
2500        assert!(
2501            validate_system_catalog_query(
2502                "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM user_data)"
2503            )
2504            .is_err()
2505        );
2506
2507        // Reject mixed references in nested subqueries
2508        assert!(validate_system_catalog_query(
2509            "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM (SELECT id FROM user_table) AS t)"
2510        )
2511        .is_err());
2512    }
2513
2514    #[mz_ore::test]
2515    fn test_validate_system_catalog_query_rejects_user_tables() {
2516        assert!(validate_system_catalog_query("SELECT * FROM user_data").is_err());
2517        assert!(validate_system_catalog_query("SELECT * FROM my_table").is_err());
2518        // Security: reject queries that mention mz_ in a non-table context
2519        assert!(
2520            validate_system_catalog_query("SELECT * FROM user_data WHERE 'mz_' IS NOT NULL")
2521                .is_err()
2522        );
2523    }
2524
2525    #[mz_ore::test]
2526    fn test_validate_system_catalog_query_allows_functions() {
2527        // Function names should not be treated as table references
2528        assert!(
2529            validate_system_catalog_query(
2530                "SELECT date_part('year', now())::int4 AS y FROM mz_tables LIMIT 1"
2531            )
2532            .is_ok()
2533        );
2534        assert!(validate_system_catalog_query("SELECT length(name) FROM mz_tables").is_ok());
2535        assert!(
2536            validate_system_catalog_query(
2537                "SELECT count(*) FROM mz_sources WHERE now() > created_at"
2538            )
2539            .is_ok()
2540        );
2541    }
2542
2543    #[mz_ore::test]
2544    fn test_validate_system_catalog_query_unqualified_pg_catalog() {
2545        // Unqualified `pg_`-prefixed names are as unambiguously system as `mz_`
2546        // ones, and resolve safely under the pinned search_path.
2547        assert!(validate_system_catalog_query("SELECT * FROM pg_class").is_ok());
2548        assert!(validate_system_catalog_query("SELECT nspname FROM pg_namespace").is_ok());
2549    }
2550
2551    #[mz_ore::test]
2552    fn test_validate_system_catalog_query_schema_qualified() {
2553        // Qualified with allowed schemas should work
2554        assert!(validate_system_catalog_query("SELECT * FROM mz_catalog.mz_tables").is_ok());
2555        assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_sessions").is_ok());
2556        assert!(validate_system_catalog_query("SELECT * FROM pg_catalog.pg_type").is_ok());
2557        assert!(validate_system_catalog_query("SELECT * FROM information_schema.tables").is_ok());
2558
2559        // Qualified with disallowed schema should fail
2560        assert!(validate_system_catalog_query("SELECT * FROM public.user_table").is_err());
2561        assert!(validate_system_catalog_query("SELECT * FROM myschema.mytable").is_err());
2562
2563        // mz_unsafe is a system schema but explicitly blocked for MCP
2564        assert!(
2565            validate_system_catalog_query("SELECT * FROM mz_unsafe.mz_some_table").is_err(),
2566            "mz_unsafe schema should be blocked even though it is a system schema"
2567        );
2568
2569        // Mixed: system and user schemas should fail
2570        assert!(
2571            validate_system_catalog_query(
2572                "SELECT * FROM mz_catalog.mz_tables JOIN public.user_data ON true"
2573            )
2574            .is_err()
2575        );
2576    }
2577
2578    #[mz_ore::test]
2579    fn test_validate_system_catalog_query_rejects_hidden_user_references() {
2580        for sql in [
2581            "SELECT * FROM mz_catalog.mz_tables UNION ALL TABLE public.user_data",
2582            "WITH user_data AS (SELECT 1) \
2583             SELECT * FROM public.user_data, mz_catalog.mz_tables",
2584            "SELECT * FROM [u1 AS mz_catalog.mz_tables]",
2585        ] {
2586            assert!(
2587                validate_system_catalog_query(sql).is_err(),
2588                "should reject {sql}"
2589            );
2590        }
2591    }
2592
2593    #[mz_ore::test]
2594    fn test_validate_system_catalog_query_accepts_table_and_mutual_recursion() {
2595        for sql in [
2596            "TABLE mz_catalog.mz_tables",
2597            "WITH MUTUALLY RECURSIVE \
2598               t (id text) AS (TABLE u), \
2599               u (id text) AS (SELECT id FROM mz_catalog.mz_tables) \
2600             SELECT * FROM t",
2601        ] {
2602            assert!(
2603                validate_system_catalog_query(sql).is_ok(),
2604                "should accept {sql}"
2605            );
2606        }
2607    }
2608
2609    #[mz_ore::test]
2610    fn test_validate_system_catalog_query_adversarial_cases() {
2611        // Try to sneak in user table via CTE
2612        assert!(
2613            validate_system_catalog_query(
2614                "WITH user_cte AS (SELECT * FROM user_data) \
2615                 SELECT * FROM mz_tables, user_cte"
2616            )
2617            .is_err(),
2618            "Should reject CTE referencing user table"
2619        );
2620
2621        // Complex multi-level CTE with user table buried deep
2622        assert!(
2623            validate_system_catalog_query(
2624                "WITH \
2625                   cte1 AS (SELECT * FROM mz_tables), \
2626                   cte2 AS (SELECT * FROM cte1), \
2627                   cte3 AS (SELECT * FROM user_data) \
2628                 SELECT * FROM cte2"
2629            )
2630            .is_err(),
2631            "Should reject CTE chain with user table"
2632        );
2633
2634        // Multiple joins - user table in the middle
2635        assert!(
2636            validate_system_catalog_query(
2637                "SELECT * FROM mz_tables t1 \
2638                 JOIN user_data u ON t1.id = u.id \
2639                 JOIN mz_sources s ON t1.id = s.id"
2640            )
2641            .is_err(),
2642            "Should reject multi-join with user table"
2643        );
2644
2645        // LEFT JOIN trying to hide user table
2646        assert!(
2647            validate_system_catalog_query(
2648                "SELECT * FROM mz_tables t \
2649                 LEFT JOIN user_data u ON t.id = u.table_id \
2650                 WHERE u.id IS NULL"
2651            )
2652            .is_err(),
2653            "Should reject LEFT JOIN with user table"
2654        );
2655
2656        // Nested subquery with user table in FROM
2657        assert!(
2658            validate_system_catalog_query(
2659                "SELECT * FROM mz_tables WHERE id IN \
2660                 (SELECT table_id FROM (SELECT * FROM user_data) AS u)"
2661            )
2662            .is_err(),
2663            "Should reject nested subquery with user table"
2664        );
2665
2666        // UNION trying to mix system and user data
2667        assert!(
2668            validate_system_catalog_query(
2669                "SELECT name FROM mz_tables \
2670                 UNION \
2671                 SELECT name FROM user_data"
2672            )
2673            .is_err(),
2674            "Should reject UNION with user table"
2675        );
2676
2677        // UNION ALL variation
2678        assert!(
2679            validate_system_catalog_query(
2680                "SELECT id FROM mz_sources \
2681                 UNION ALL \
2682                 SELECT id FROM products"
2683            )
2684            .is_err(),
2685            "Should reject UNION ALL with user table"
2686        );
2687
2688        // Cross join with user table
2689        assert!(
2690            validate_system_catalog_query("SELECT * FROM mz_tables CROSS JOIN user_data").is_err(),
2691            "Should reject CROSS JOIN with user table"
2692        );
2693
2694        // Subquery in SELECT clause referencing user table
2695        assert!(
2696            validate_system_catalog_query(
2697                "SELECT t.*, (SELECT COUNT(*) FROM user_data) AS cnt FROM mz_tables t"
2698            )
2699            .is_err(),
2700            "Should reject subquery in SELECT with user table"
2701        );
2702
2703        // Try to use a schema name that looks similar to allowed ones
2704        assert!(
2705            validate_system_catalog_query("SELECT * FROM mz_catalogg.fake_table").is_err(),
2706            "Should reject typo-squatting schema name"
2707        );
2708        assert!(
2709            validate_system_catalog_query("SELECT * FROM mz_catalog_hack.fake_table").is_err(),
2710            "Should reject fake schema with mz_catalog prefix"
2711        );
2712
2713        // Lateral join with user table
2714        assert!(
2715            validate_system_catalog_query(
2716                "SELECT * FROM mz_tables t, LATERAL (SELECT * FROM user_data WHERE id = t.id) u"
2717            )
2718            .is_err(),
2719            "Should reject LATERAL join with user table"
2720        );
2721
2722        // Valid complex query - all system tables
2723        assert!(
2724            validate_system_catalog_query(
2725                "WITH \
2726                   tables AS (SELECT * FROM mz_tables), \
2727                   sources AS (SELECT * FROM mz_sources) \
2728                 SELECT t.name, s.name \
2729                 FROM tables t \
2730                 JOIN sources s ON t.id = s.id \
2731                 WHERE t.id IN (SELECT id FROM mz_columns)"
2732            )
2733            .is_ok(),
2734            "Should allow complex query with only system tables"
2735        );
2736
2737        // Valid UNION of system tables
2738        assert!(
2739            validate_system_catalog_query(
2740                "SELECT name FROM mz_tables \
2741                 UNION \
2742                 SELECT name FROM mz_sources"
2743            )
2744            .is_ok(),
2745            "Should allow UNION of system tables"
2746        );
2747    }
2748
2749    #[mz_ore::test]
2750    fn test_validate_system_catalog_query_rejects_constant_queries() {
2751        // SELECT without any table reference should be rejected — the developer
2752        // endpoint is for system catalog queries, not arbitrary computation.
2753        assert!(
2754            validate_system_catalog_query("SELECT 1").is_err(),
2755            "Should reject constant SELECT with no table references"
2756        );
2757        assert!(
2758            validate_system_catalog_query("SELECT 1 + 2, 'hello'").is_err(),
2759            "Should reject constant expression SELECT"
2760        );
2761        assert!(
2762            validate_system_catalog_query("SELECT now()").is_err(),
2763            "Should reject function-only SELECT with no table references"
2764        );
2765    }
2766
2767    #[mz_ore::test]
2768    fn test_validate_system_catalog_query_rejects_mixed_tables() {
2769        assert!(
2770            validate_system_catalog_query(
2771                "SELECT * FROM mz_tables t JOIN user_data u ON t.id = u.table_id"
2772            )
2773            .is_err()
2774        );
2775    }
2776
2777    #[mz_ore::test]
2778    fn test_validate_system_catalog_query_allows_show() {
2779        // SHOW queries don't reference tables in the AST but are safe read-only ops
2780        assert!(
2781            validate_system_catalog_query("SHOW TABLES FROM mz_internal").is_ok(),
2782            "SHOW TABLES FROM mz_internal should be allowed"
2783        );
2784        assert!(
2785            validate_system_catalog_query("SHOW TABLES FROM mz_catalog").is_ok(),
2786            "SHOW TABLES FROM mz_catalog should be allowed"
2787        );
2788        assert!(
2789            validate_system_catalog_query("SHOW CLUSTERS").is_ok(),
2790            "SHOW CLUSTERS should be allowed"
2791        );
2792        assert!(
2793            validate_system_catalog_query("SHOW SOURCES").is_ok(),
2794            "SHOW SOURCES should be allowed"
2795        );
2796        assert!(
2797            validate_system_catalog_query("SHOW TABLES").is_ok(),
2798            "SHOW TABLES should be allowed"
2799        );
2800    }
2801
2802    #[mz_ore::test]
2803    fn test_validate_system_catalog_query_allows_explain() {
2804        assert!(
2805            validate_system_catalog_query("EXPLAIN SELECT * FROM mz_tables").is_ok(),
2806            "EXPLAIN of system table query should be allowed"
2807        );
2808        assert!(
2809            validate_system_catalog_query("EXPLAIN SELECT 1").is_ok(),
2810            "EXPLAIN SELECT 1 should be allowed"
2811        );
2812    }
2813
2814    // ── Query tool feature flag tests ──────────────────────────────────────
2815
2816    #[mz_ore::test(tokio::test)]
2817    async fn test_tools_list_agent_query_tool_disabled() {
2818        let result = handle_tools_list(McpEndpointType::Agent, false, true, 1_000_000).unwrap();
2819        let McpResult::ToolsList(list) = result else {
2820            panic!("Expected ToolsList result");
2821        };
2822        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2823        assert!(
2824            tool_names.contains(&"get_data_products"),
2825            "get_data_products should always be present"
2826        );
2827        assert!(
2828            tool_names.contains(&"get_data_product_details"),
2829            "get_data_product_details should always be present"
2830        );
2831        assert!(
2832            tool_names.contains(&"read_data_product"),
2833            "read_data_product should be present when its flag is on"
2834        );
2835        assert!(
2836            !tool_names.contains(&"query"),
2837            "query tool should be hidden when disabled"
2838        );
2839    }
2840
2841    #[mz_ore::test(tokio::test)]
2842    async fn test_tools_list_agent_query_tool_enabled() {
2843        let result = handle_tools_list(McpEndpointType::Agent, true, true, 1_000_000).unwrap();
2844        let McpResult::ToolsList(list) = result else {
2845            panic!("Expected ToolsList result");
2846        };
2847        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2848        assert!(
2849            tool_names.contains(&"get_data_products"),
2850            "get_data_products should always be present"
2851        );
2852        assert!(
2853            tool_names.contains(&"get_data_product_details"),
2854            "get_data_product_details should always be present"
2855        );
2856        assert!(
2857            tool_names.contains(&"read_data_product"),
2858            "read_data_product should be present when its flag is on"
2859        );
2860        assert!(
2861            tool_names.contains(&"query"),
2862            "query tool should be present when enabled"
2863        );
2864    }
2865
2866    #[mz_ore::test(tokio::test)]
2867    async fn test_tools_list_agent_read_data_product_tool_disabled() {
2868        let result = handle_tools_list(McpEndpointType::Agent, true, false, 1_000_000).unwrap();
2869        let McpResult::ToolsList(list) = result else {
2870            panic!("Expected ToolsList result");
2871        };
2872        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2873        assert!(
2874            tool_names.contains(&"get_data_products"),
2875            "get_data_products should always be present"
2876        );
2877        assert!(
2878            tool_names.contains(&"get_data_product_details"),
2879            "get_data_product_details should always be present"
2880        );
2881        assert!(
2882            !tool_names.contains(&"read_data_product"),
2883            "read_data_product should be hidden when disabled"
2884        );
2885        assert!(
2886            tool_names.contains(&"query"),
2887            "query tool should remain present when enabled"
2888        );
2889    }
2890
2891    /// Both read tools off is a valid (if unusual) config where the agent
2892    /// is discovery-only. Pin the behavior: only get_data_products and
2893    /// get_data_product_details are advertised, and the initialize
2894    /// instructions do not tell the agent to use a tool that isn't listed.
2895    #[mz_ore::test(tokio::test)]
2896    async fn test_tools_list_agent_both_read_tools_disabled() {
2897        let result = handle_tools_list(McpEndpointType::Agent, false, false, 1_000_000).unwrap();
2898        let McpResult::ToolsList(list) = result else {
2899            panic!("Expected ToolsList result");
2900        };
2901        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2902        assert_eq!(
2903            tool_names
2904                .iter()
2905                .copied()
2906                .collect::<std::collections::BTreeSet<_>>(),
2907            ["get_data_product_details", "get_data_products"]
2908                .into_iter()
2909                .collect(),
2910            "only discovery tools should be advertised when both read flags are off",
2911        );
2912
2913        let instructions = endpoint_instructions(McpEndpointType::Agent, false, false)
2914            .expect("agent instructions must be present");
2915        assert!(
2916            !instructions.contains("Use the `query` tool"),
2917            "instructions must not point at query when it is hidden: {instructions}",
2918        );
2919        assert!(
2920            !instructions.contains("`read_data_product` automatically"),
2921            "instructions must not point at read_data_product when it is hidden: {instructions}",
2922        );
2923        assert!(
2924            instructions.contains("discovery only"),
2925            "instructions must tell the agent it is discovery-only: {instructions}",
2926        );
2927    }
2928
2929    #[mz_ore::test(tokio::test)]
2930    async fn test_tools_list_developer_query_tool_disabled() {
2931        // Developer endpoint doesn't expose read_data_product; the flag is
2932        // orthogonal, so pass whichever value.
2933        let result = handle_tools_list(McpEndpointType::Developer, false, true, 1_000_000).unwrap();
2934        let McpResult::ToolsList(list) = result else {
2935            panic!("Expected ToolsList result");
2936        };
2937        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2938        assert!(
2939            tool_names.contains(&"query_system_catalog"),
2940            "query_system_catalog should always be present on developer"
2941        );
2942        assert!(
2943            !tool_names.contains(&"query"),
2944            "query tool should be hidden when disabled"
2945        );
2946    }
2947
2948    #[mz_ore::test(tokio::test)]
2949    async fn test_tools_list_developer_query_tool_enabled() {
2950        let result = handle_tools_list(McpEndpointType::Developer, true, true, 1_000_000).unwrap();
2951        let McpResult::ToolsList(list) = result else {
2952            panic!("Expected ToolsList result");
2953        };
2954        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2955        assert!(
2956            tool_names.contains(&"query_system_catalog"),
2957            "query_system_catalog should always be present on developer"
2958        );
2959        assert!(
2960            tool_names.contains(&"query"),
2961            "query tool should be present on developer when enabled"
2962        );
2963
2964        let instructions = endpoint_instructions(McpEndpointType::Developer, true, true)
2965            .expect("developer instructions must be present");
2966        assert!(
2967            instructions.contains("through the query tool with its cluster argument"),
2968            "instructions must route mz_introspection through query: {instructions}",
2969        );
2970    }
2971
2972    #[mz_ore::test]
2973    fn test_developer_instructions_query_tool_disabled() {
2974        let instructions = endpoint_instructions(McpEndpointType::Developer, false, true)
2975            .expect("developer instructions must be present");
2976        assert!(
2977            instructions.contains("cluster-targeted introspection is unavailable")
2978                && !instructions.contains("through the query tool"),
2979            "instructions must not route to the hidden query tool: {instructions}",
2980        );
2981    }
2982
2983    /// Clients attach `_meta` to `tools/list`. Rejecting it fails the whole
2984    /// request with a 422, which the client reads as the server being down.
2985    #[mz_ore::test]
2986    fn test_tools_list_accepts_optional_params() {
2987        for body in [
2988            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
2989            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":null}"#,
2990            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#,
2991            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"progressToken":0}}}"#,
2992        ] {
2993            let req: McpRequest = serde_json::from_str(body)
2994                .unwrap_or_else(|e| panic!("tools/list must deserialize: {body}: {e}"));
2995            assert!(
2996                matches!(req.method, McpMethod::ToolsList(_)),
2997                "expected ToolsList for {body}"
2998            );
2999        }
3000    }
3001
3002    #[mz_ore::test]
3003    fn test_protocol_version_select() {
3004        use ProtocolVersion::*;
3005        for (header, enabled, want) in [
3006            (&[][..], true, V2025_11_25),
3007            (&["2025-11-25"], true, V2025_11_25),
3008            (&["2025-06-18"], true, V2025_11_25),
3009            (&["2026-07-28"], true, V2026_07_28),
3010            (&["2026-07-28"], false, V2025_11_25),
3011            (&["not-a-version"], true, V2025_11_25),
3012            (&["2026-07-28X"], true, V2025_11_25),
3013            (&["2025-11-25", "2026-07-28"], true, V2026_07_28),
3014            (&["2025-11-25, 2026-07-28"], true, V2026_07_28),
3015            (&["2025-11-25", "2026-07-28"], false, V2025_11_25),
3016        ] {
3017            let values: Vec<_> = header.iter().map(|v| HeaderValue::from_static(v)).collect();
3018            assert_eq!(
3019                ProtocolVersion::select(&values, enabled),
3020                want,
3021                "for {header:?} with the flag {enabled}"
3022            );
3023        }
3024        let non_ascii = HeaderValue::from_bytes(b"2026-07-28\xff").expect("valid header bytes");
3025        assert_eq!(ProtocolVersion::select(&[non_ascii], true), V2025_11_25);
3026        let non_ascii_list =
3027            HeaderValue::from_bytes(b"\xff, 2026-07-28").expect("valid header bytes");
3028        assert_eq!(
3029            ProtocolVersion::select(&[non_ascii_list], true),
3030            V2026_07_28
3031        );
3032    }
3033
3034    fn json_headers() -> HeaderMap {
3035        HeaderMap::from_iter([(
3036            header::CONTENT_TYPE,
3037            HeaderValue::from_static("application/json"),
3038        )])
3039    }
3040
3041    #[mz_ore::test]
3042    fn test_parse_mcp_request_unknown_method_with_params() {
3043        let req = parse_mcp_request(
3044            &json_headers(),
3045            br#"{"jsonrpc":"2.0","id":7,"method":"server/other","params":{"_meta":{}}}"#,
3046        )
3047        .expect("unknown method with params must parse");
3048        assert!(matches!(req.method, McpMethod::Unknown));
3049        assert_eq!(req.id, Some(json!(7)));
3050
3051        let req = parse_mcp_request(
3052            &json_headers(),
3053            br#"{"jsonrpc":"2.0","method":"notifications/other","params":{}}"#,
3054        )
3055        .expect("unknown notification with params must parse");
3056        assert!(matches!(req.method, McpMethod::Unknown));
3057        assert_eq!(req.id, None);
3058    }
3059
3060    #[mz_ore::test]
3061    fn test_parse_mcp_request_keeps_other_rejections() {
3062        for (headers, body, want) in [
3063            (
3064                json_headers(),
3065                &br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nonexistent_tool","arguments":{}}}"#[..],
3066                StatusCode::UNPROCESSABLE_ENTITY,
3067            ),
3068            (
3069                json_headers(),
3070                br#"{"id":1,"method":"server/other","params":{}}"#,
3071                StatusCode::UNPROCESSABLE_ENTITY,
3072            ),
3073            (json_headers(), b"{not json", StatusCode::BAD_REQUEST),
3074            (
3075                HeaderMap::new(),
3076                br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
3077                StatusCode::UNSUPPORTED_MEDIA_TYPE,
3078            ),
3079        ] {
3080            let rejection = parse_mcp_request(&headers, body).expect_err("must be rejected");
3081            let body = String::from_utf8_lossy(body);
3082            assert_eq!(rejection.status(), want, "for {body}");
3083        }
3084    }
3085
3086    fn headers(method: &'static str, name: Option<&'static str>) -> McpHeaders {
3087        McpHeaders {
3088            protocol_version: vec![HeaderValue::from_static(
3089                ProtocolVersion::V2026_07_28.as_str(),
3090            )],
3091            method: vec![HeaderValue::from_static(method)],
3092            name: name.into_iter().map(HeaderValue::from_static).collect(),
3093        }
3094    }
3095
3096    #[mz_ore::test]
3097    fn test_header_check_tool_names() {
3098        let call = headers("tools/call", Some("query"));
3099        assert!(call.check("tools/call", ToolName::Name("query")).is_ok());
3100        assert!(call.check("tools/call", ToolName::Missing).is_err());
3101        assert!(call.check("tools/list", ToolName::NotToolsCall).is_err());
3102        assert!(
3103            headers("server/other", None)
3104                .check("server/other", ToolName::NotToolsCall)
3105                .is_ok()
3106        );
3107    }
3108
3109    /// Bodies the datadriven harness cannot send: not a JSON object, not JSON,
3110    /// or an object without a `method`.
3111    #[mz_ore::test(tokio::test)]
3112    async fn test_answer_parse_failure() {
3113        let no_method = r#"{"jsonrpc":"2.0","id":4,"params":{}}"#;
3114        for (body, want_code, want_id) in [
3115            ("[]", error_codes::INVALID_REQUEST, None),
3116            ("null", error_codes::INVALID_REQUEST, None),
3117            ("{not json", error_codes::PARSE_ERROR, None),
3118            (no_method, error_codes::INVALID_REQUEST, Some(json!(4))),
3119        ] {
3120            let failure = parse_mcp_request(&json_headers(), body.as_bytes())
3121                .expect_err("not a request we parse");
3122            let headers = headers("tools/list", None);
3123            let resp = answer_parse_failure(failure, body.as_bytes(), &headers, &|_| {});
3124            assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "status for {body}");
3125            let resp: serde_json::Value = serde_json::from_slice(
3126                &axum::body::to_bytes(resp.into_body(), usize::MAX)
3127                    .await
3128                    .expect("body"),
3129            )
3130            .expect("JSON body");
3131            assert_eq!(resp["error"]["code"], json!(want_code), "code for {body}");
3132            assert_eq!(resp.get("id").cloned(), want_id, "id for {body}");
3133        }
3134    }
3135
3136    /// `ping` and `notifications/initialized` are named variants so their
3137    /// `params` deserialize. A `_meta` on the acknowledgement would otherwise
3138    /// fail the body with a 422 in the middle of the handshake.
3139    #[mz_ore::test]
3140    fn test_named_methods_accept_optional_params() {
3141        for (body, want_ping) in [
3142            (r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#, true),
3143            (
3144                r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}"#,
3145                true,
3146            ),
3147            (
3148                r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{"_meta":{"progressToken":0}}}"#,
3149                true,
3150            ),
3151            (
3152                r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
3153                false,
3154            ),
3155            (
3156                r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{"_meta":{"x":1}}}"#,
3157                false,
3158            ),
3159        ] {
3160            let req: McpRequest = serde_json::from_str(body)
3161                .unwrap_or_else(|e| panic!("must deserialize: {body}: {e}"));
3162            if want_ping {
3163                assert!(matches!(req.method, McpMethod::Ping(_)), "for {body}");
3164            } else {
3165                assert!(
3166                    matches!(req.method, McpMethod::NotificationsInitialized(_)),
3167                    "for {body}"
3168                );
3169            }
3170        }
3171    }
3172
3173    #[mz_ore::test]
3174    fn test_tools_call_arguments_optional() {
3175        for body in [
3176            r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_data_products"}}"#,
3177            r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_data_products","arguments":{}}}"#,
3178            r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_data_products","arguments":null}}"#,
3179        ] {
3180            let req: McpRequest = serde_json::from_str(body)
3181                .unwrap_or_else(|e| panic!("must deserialize: {body}: {e}"));
3182            assert!(
3183                matches!(
3184                    req.method,
3185                    McpMethod::ToolsCall(ToolsCallParams::GetDataProducts(NoArguments {}))
3186                ),
3187                "for {body}"
3188            );
3189        }
3190
3191        // A tool that needs arguments still fails, now on the missing field itself.
3192        let err = serde_json::from_str::<McpRequest>(
3193            r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"query_system_catalog"}}"#,
3194        )
3195        .expect_err("query_system_catalog requires sql_query");
3196        assert!(err.to_string().contains("sql_query"), "{err}");
3197    }
3198
3199    // ── Response size cap tests ────────────────────────────────────────
3200
3201    #[mz_ore::test]
3202    fn test_format_rows_response_within_limit() {
3203        let rows = vec![vec![json!("a"), json!(1)], vec![json!("b"), json!(2)]];
3204        let result = format_rows_response(raw_rows(rows), 1_000_000).unwrap();
3205        let McpResult::ToolContent(content) = result else {
3206            panic!("Expected ToolContent");
3207        };
3208        assert_eq!(content.content.len(), 1);
3209        assert!(content.content[0].text.contains("\"a\""));
3210        assert!(content.content[0].text.contains("\"b\""));
3211    }
3212
3213    #[mz_ore::test]
3214    fn test_format_rows_response_errors_when_over_limit() {
3215        let rows: Vec<Vec<serde_json::Value>> = (0..100)
3216            .map(|i| vec![json!(format!("row_{}", i)), json!(i)])
3217            .collect();
3218        let err = format_rows_response(raw_rows(rows), 500).unwrap_err();
3219        let msg = err.to_string();
3220        assert!(
3221            msg.contains("exceeds the 500 byte limit"),
3222            "Error should mention the size limit, got: {msg}"
3223        );
3224        assert!(
3225            msg.contains("Use LIMIT or WHERE"),
3226            "Error should suggest narrowing the query, got: {msg}"
3227        );
3228    }
3229
3230    #[mz_ore::test]
3231    fn test_format_rows_response_empty_rows() {
3232        let rows: Vec<Vec<serde_json::Value>> = vec![];
3233        let result = format_rows_response(raw_rows(rows), 1000).unwrap();
3234        let McpResult::ToolContent(content) = result else {
3235            panic!("Expected ToolContent");
3236        };
3237        assert_eq!(content.content.len(), 1);
3238        assert_eq!(content.content[0].text, "[]");
3239    }
3240
3241    // ── Data product name validation tests ─────────────────────────────
3242
3243    #[mz_ore::test]
3244    fn test_safe_data_product_name_valid() {
3245        // Fully qualified quoted identifiers
3246        assert_eq!(
3247            safe_data_product_name(r#""materialize"."public"."my_view""#).unwrap(),
3248            r#""materialize"."public"."my_view""#
3249        );
3250        // Two-part name
3251        assert_eq!(
3252            safe_data_product_name(r#""public"."my_view""#).unwrap(),
3253            r#""public"."my_view""#
3254        );
3255        // Unquoted name gets quoted in stable mode
3256        assert_eq!(safe_data_product_name("my_view").unwrap(), r#""my_view""#);
3257    }
3258
3259    #[mz_ore::test]
3260    fn test_safe_data_product_name_rejects_empty() {
3261        assert!(safe_data_product_name("").is_err());
3262        assert!(safe_data_product_name("   ").is_err());
3263    }
3264
3265    /// DEX-64: `parse_item_name` is unbounded on its own; the MCP path
3266    /// switches to `parse_item_name_with_limit` so a pathological name (e.g.
3267    /// millions of `(` characters) is rejected before lexing.
3268    #[mz_ore::test]
3269    fn test_safe_data_product_name_enforces_size_limit() {
3270        use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
3271        let oversized: String = "(".repeat(MAX_STATEMENT_BATCH_SIZE + 1);
3272        let err = safe_data_product_name(&oversized).expect_err("should be rejected");
3273        let msg = err.to_string();
3274        assert!(
3275            msg.contains("statement batch size cannot exceed"),
3276            "expected size-guard error, got: {msg}"
3277        );
3278    }
3279
3280    #[mz_ore::test]
3281    fn test_safe_data_product_name_rejects_sql_injection() {
3282        // Attempted injection via semicolon
3283        assert!(safe_data_product_name("my_view; DROP TABLE users").is_err());
3284        // Attempted injection via subquery
3285        assert!(safe_data_product_name("my_view UNION SELECT * FROM secrets").is_err());
3286        // Multiple table references via comma
3287        assert!(safe_data_product_name("my_view, secrets").is_err());
3288        // SQL keywords after name are rejected by the parser
3289        assert!(safe_data_product_name("my_view WHERE 1=1 --").is_err());
3290    }
3291
3292    /// A trailing `--` comment in the body must not swallow the `; COMMIT;`.
3293    #[mz_ore::test]
3294    fn test_read_only_txn_comment_cannot_swallow_commit() {
3295        let sql = read_only_txn("SET CLUSTER = 'c'", "SELECT 1 --");
3296        assert!(
3297            sql.contains("\n; COMMIT;"),
3298            "COMMIT must sit on its own line: {sql}",
3299        );
3300    }
3301
3302    // ── query_set_clause tests (QAR-136) ───────────────────────────────
3303
3304    /// Without a replica, only `SET CLUSTER` is emitted.
3305    #[mz_ore::test]
3306    fn test_query_set_clause_without_replica() {
3307        let clause = query_set_clause("prod_cluster", None);
3308        assert_eq!(clause, "SET CLUSTER = 'prod_cluster'");
3309    }
3310
3311    /// With a replica, `SET CLUSTER_REPLICA` follows `SET CLUSTER`, scoping
3312    /// the read to one replica (what makes EXPLAIN ANALYZE usable on
3313    /// clusters with multiple replicas).
3314    #[mz_ore::test]
3315    fn test_query_set_clause_with_replica() {
3316        let clause = query_set_clause("prod_cluster", Some("r1"));
3317        assert_eq!(
3318            clause,
3319            "SET CLUSTER = 'prod_cluster'; SET CLUSTER_REPLICA = 'r1'"
3320        );
3321    }
3322
3323    /// Replica names are interpolated into a SQL string literal, so they get
3324    /// the same escaping treatment as cluster names. Defends against
3325    /// injection via adversarial replica names.
3326    #[mz_ore::test]
3327    fn test_query_set_clause_escapes_replica_name() {
3328        let clause = query_set_clause("c", Some("evil'; DROP TABLE secrets; --"));
3329        assert_eq!(
3330            clause,
3331            "SET CLUSTER = 'c'; SET CLUSTER_REPLICA = 'evil''; DROP TABLE secrets; --'"
3332        );
3333    }
3334
3335    /// A `None` replica (no pinning requested) is always valid, and a normal
3336    /// replica name passes validation.
3337    #[mz_ore::test]
3338    fn test_validate_cluster_replica_accepts_none_and_names() {
3339        assert!(validate_cluster_replica(None).is_ok());
3340        assert!(validate_cluster_replica(Some("r1")).is_ok());
3341    }
3342
3343    /// An empty or whitespace-only replica name is rejected as a validation
3344    /// error up front, rather than producing `SET CLUSTER_REPLICA = ''` that
3345    /// fails deep in the engine as a generic execution error.
3346    #[mz_ore::test]
3347    fn test_validate_cluster_replica_rejects_empty() {
3348        for name in ["", "   ", "\t\n"] {
3349            assert!(
3350                matches!(
3351                    validate_cluster_replica(Some(name)),
3352                    Err(McpRequestError::QueryValidationFailed(_))
3353                ),
3354                "expected validation error for {name:?}",
3355            );
3356        }
3357    }
3358
3359    // ── build_read_query tests (DEX-27) ────────────────────────────────
3360
3361    /// The read is wrapped in a `BEGIN READ ONLY` transaction so the
3362    /// `SET CLUSTER` scope is bounded to this read and does not leak
3363    /// into the rest of the session.
3364    #[mz_ore::test]
3365    fn test_build_read_query_with_cluster() {
3366        let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, Some("prod_cluster"));
3367        assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
3368        assert!(sql.contains("SET CLUSTER = 'prod_cluster'"), "{sql}");
3369        assert!(
3370            sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
3371            "{sql}",
3372        );
3373        assert!(sql.contains("COMMIT"), "{sql}");
3374    }
3375
3376    /// With no cluster (the role lacks USAGE on the object's cluster, DEX-66),
3377    /// the read omits `SET CLUSTER` and runs on the session's serving cluster.
3378    #[mz_ore::test]
3379    fn test_build_read_query_without_cluster() {
3380        let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, None);
3381        assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
3382        assert!(!sql.contains("SET CLUSTER"), "{sql}");
3383        assert!(
3384            sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
3385            "{sql}",
3386        );
3387        assert!(sql.contains("COMMIT"), "{sql}");
3388    }
3389
3390    /// Cluster names with single quotes and backslashes must be escaped
3391    /// the same way as the `name` argument, since they end up interpolated
3392    /// into a SQL string literal. Defends against catalog-injection or
3393    /// adversarial cluster names.
3394    #[mz_ore::test]
3395    fn test_build_read_query_escapes_cluster_name() {
3396        let sql = build_read_query(
3397            "\"db\".\"sch\".\"v\"",
3398            10,
3399            Some("evil'; DROP TABLE secrets; --"),
3400        );
3401        // The single quote in `evil'` must be doubled inside the literal.
3402        assert!(
3403            sql.contains("SET CLUSTER = 'evil''; DROP TABLE secrets; --'"),
3404            "single quote should be doubled inside the literal: {sql}",
3405        );
3406        // The injected `DROP TABLE` must remain inside the literal — i.e.
3407        // there is no second statement that escapes the SET CLUSTER call.
3408        assert_eq!(
3409            sql.matches("SET CLUSTER").count(),
3410            1,
3411            "exactly one SET CLUSTER statement: {sql}",
3412        );
3413        assert_eq!(
3414            sql.matches("DROP TABLE").count(),
3415            1,
3416            "DROP TABLE should appear once, inside the quoted literal: {sql}",
3417        );
3418    }
3419
3420    #[mz_ore::test]
3421    fn test_mcp_error_codes() {
3422        assert_eq!(
3423            McpRequestError::InvalidJsonRpcVersion.error_code(),
3424            error_codes::INVALID_REQUEST
3425        );
3426        assert_eq!(
3427            McpRequestError::MethodNotFound("test".to_string()).error_code(),
3428            error_codes::METHOD_NOT_FOUND
3429        );
3430        assert_eq!(
3431            McpRequestError::QueryExecutionFailed("test".to_string()).error_code(),
3432            error_codes::INTERNAL_ERROR
3433        );
3434        assert_eq!(
3435            McpRequestError::HeaderMismatch("test".to_string()).error_code(),
3436            error_codes::HEADER_MISMATCH
3437        );
3438    }
3439
3440    #[mz_ore::test]
3441    fn test_request_meta_check() {
3442        let params = |body: &str| {
3443            RawRequest::parse(body.as_bytes())
3444                .expect("an object")
3445                .params
3446        };
3447        let check =
3448            |body: &str| check_request_meta(params(body).as_ref()).map_err(|e| e.error_code());
3449        let with_meta = |meta: &str| {
3450            format!(
3451                r#"{{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{{"_meta":{meta}}}}}"#
3452            )
3453        };
3454        assert_eq!(
3455            check(&with_meta(
3456                r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}"#
3457            )),
3458            Ok(())
3459        );
3460        for (meta, want) in [
3461            (
3462                r#"{"io.modelcontextprotocol/clientCapabilities":{}}"#,
3463                error_codes::INVALID_PARAMS,
3464            ),
3465            (
3466                r#"{"io.modelcontextprotocol/protocolVersion":7,"io.modelcontextprotocol/clientCapabilities":{}}"#,
3467                error_codes::INVALID_PARAMS,
3468            ),
3469            (
3470                r#"{"io.modelcontextprotocol/protocolVersion":"2025-11-25","io.modelcontextprotocol/clientCapabilities":{}}"#,
3471                error_codes::HEADER_MISMATCH,
3472            ),
3473            (
3474                r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}"#,
3475                error_codes::INVALID_PARAMS,
3476            ),
3477            (
3478                r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":[]}"#,
3479                error_codes::INVALID_PARAMS,
3480            ),
3481        ] {
3482            assert_eq!(check(&with_meta(meta)), Err(want), "for {meta}");
3483        }
3484        let message = |body: &str| {
3485            check_request_meta(params(body).as_ref())
3486                .expect_err("must be rejected")
3487                .to_string()
3488        };
3489        assert!(
3490            message(&with_meta(
3491                r#"{"io.modelcontextprotocol/protocolVersion":7}"#
3492            ))
3493            .ends_with("must be a string")
3494        );
3495        assert!(message(&with_meta("[]")).ends_with("params._meta must be an object"));
3496        assert_eq!(
3497            check(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#),
3498            Err(error_codes::INVALID_PARAMS)
3499        );
3500    }
3501
3502    #[mz_ore::test]
3503    fn test_header_check_rejects_non_ascii() {
3504        let headers = McpHeaders {
3505            protocol_version: vec![HeaderValue::from_static(
3506                ProtocolVersion::V2026_07_28.as_str(),
3507            )],
3508            method: vec![HeaderValue::from_bytes(b"tools/list\xff").expect("valid header bytes")],
3509            name: vec![],
3510        };
3511        let err = headers
3512            .check("tools/list", ToolName::NotToolsCall)
3513            .expect_err("non-ASCII Mcp-Method");
3514        assert_eq!(err.error_code(), error_codes::HEADER_MISMATCH);
3515    }
3516}