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 frontend_peek.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}
2055
2056impl TableReferenceCollector {
2057    fn new() -> Self {
2058        Self {
2059            tables: Vec::new(),
2060            cte_names: std::collections::BTreeSet::new(),
2061        }
2062    }
2063}
2064
2065impl<'ast> Visit<'ast, Raw> for TableReferenceCollector {
2066    fn visit_cte(&mut self, cte: &'ast mz_sql_parser::ast::Cte<Raw>) {
2067        // Track CTE names so we don't treat them as table references
2068        self.cte_names
2069            .insert(cte.alias.name.as_str().to_lowercase());
2070        visit::visit_cte(self, cte);
2071    }
2072
2073    fn visit_table_factor(&mut self, table_factor: &'ast mz_sql_parser::ast::TableFactor<Raw>) {
2074        // Only visit actual table references in FROM/JOIN clauses, not function names
2075        if let mz_sql_parser::ast::TableFactor::Table { name, .. } = table_factor {
2076            match name {
2077                RawItemName::Name(n) | RawItemName::Id(_, n, _) => {
2078                    let parts = &n.0;
2079                    if !parts.is_empty() {
2080                        let table_name = parts.last().unwrap().as_str().to_lowercase();
2081
2082                        // Skip if this is a CTE reference, not a real table
2083                        if self.cte_names.contains(&table_name) {
2084                            visit::visit_table_factor(self, table_factor);
2085                            return;
2086                        }
2087
2088                        // Extract schema if qualified (e.g., mz_catalog.mz_tables)
2089                        let schema = if parts.len() >= 2 {
2090                            Some(parts[parts.len() - 2].as_str().to_lowercase())
2091                        } else {
2092                            None
2093                        };
2094                        self.tables.push((schema, table_name));
2095                    }
2096                }
2097            }
2098        }
2099        visit::visit_table_factor(self, table_factor);
2100    }
2101}
2102
2103/// Validates that a query only references system catalog tables.
2104///
2105/// For SELECT statements, all table references must be in system schemas
2106/// (from `SYSTEM_SCHEMAS`, excluding `mz_unsafe`), and at least one system
2107/// table must be referenced (constant queries like `SELECT 1` are rejected
2108/// to prevent misuse of the developer endpoint for arbitrary computation).
2109/// SHOW and EXPLAIN statements are allowed without table references.
2110fn validate_system_catalog_query(sql: &str) -> Result<(), McpRequestError> {
2111    // Parse the SQL to validate it. `parse_with_limit` enforces the 1 MB
2112    // guard shared with the SQL HTTP path (DEX-64).
2113    let stmts = parse_with_limit(sql)
2114        .map_err(McpRequestError::QueryValidationFailed)?
2115        .map_err(|e| {
2116            McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
2117        })?;
2118
2119    if stmts.is_empty() {
2120        return Err(McpRequestError::QueryValidationFailed(
2121            "Empty query".to_string(),
2122        ));
2123    }
2124
2125    // Walk the AST to collect all table references
2126    let mut collector = TableReferenceCollector::new();
2127    for stmt in &stmts {
2128        collector.visit_statement(&stmt.ast);
2129    }
2130
2131    // Use the canonical system schema list, excluding mz_unsafe which contains
2132    // internal-only objects that should not be exposed to MCP clients.
2133    let is_allowed_schema =
2134        |s: &str| SYSTEM_SCHEMAS.contains(&s) && s != namespaces::MZ_UNSAFE_SCHEMA;
2135
2136    // Helper to check if a table reference is allowed. Unqualified references
2137    // are accepted when they carry an unambiguous system prefix (`mz_`/`pg_`);
2138    // execution pins `search_path` to system schemas (see `query_system_catalog`),
2139    // so a user view like `public.mz_leak` cannot be reached by an unqualified name.
2140    let is_system_table = |(schema, table_name): &(Option<String>, String)| match schema {
2141        Some(s) => is_allowed_schema(s.as_str()),
2142        None => table_name.starts_with("mz_") || table_name.starts_with("pg_"),
2143    };
2144
2145    // Check that all table references are system tables
2146    let non_system_tables: Vec<String> = collector
2147        .tables
2148        .iter()
2149        .filter(|t| !is_system_table(t))
2150        .map(|(schema, table)| match schema {
2151            Some(s) => format!("{}.{}", s, table),
2152            None => table.clone(),
2153        })
2154        .collect();
2155
2156    if !non_system_tables.is_empty() {
2157        return Err(McpRequestError::QueryValidationFailed(format!(
2158            "Query references non-system tables: {}. Only system catalog tables (mz_*, pg_catalog, information_schema) are allowed.",
2159            non_system_tables.join(", ")
2160        )));
2161    }
2162
2163    // SHOW and EXPLAIN statements don't reference tables in the AST, but are safe
2164    // read-only operations. Only require system table references for SELECT.
2165    use mz_sql_parser::ast::Statement;
2166    let is_select = stmts.iter().any(|s| matches!(&s.ast, Statement::Select(_)));
2167
2168    if is_select && (collector.tables.is_empty() || !collector.tables.iter().any(is_system_table)) {
2169        return Err(McpRequestError::QueryValidationFailed(
2170            "Query must reference at least one system catalog table".to_string(),
2171        ));
2172    }
2173
2174    Ok(())
2175}
2176
2177#[cfg(test)]
2178mod tests {
2179    use super::*;
2180    use crate::http::sql::{Description, SqlError};
2181
2182    /// Serializes each row to a compact JSON array, matching how the HTTP SQL
2183    /// layer stores rows.
2184    fn raw_rows(rows: Vec<Vec<serde_json::Value>>) -> Vec<Box<serde_json::value::RawValue>> {
2185        rows.iter()
2186            .map(|r| serde_json::value::to_raw_value(r).unwrap())
2187            .collect()
2188    }
2189
2190    fn rows_result(rows: Vec<Vec<serde_json::Value>>) -> SqlResult {
2191        SqlResult::Rows {
2192            tag: String::new(),
2193            rows: raw_rows(rows),
2194            desc: Description { columns: vec![] },
2195            notices: vec![],
2196        }
2197    }
2198
2199    fn ok_result() -> SqlResult {
2200        SqlResult::Ok {
2201            ok: String::new(),
2202            notices: vec![],
2203            parameters: vec![],
2204        }
2205    }
2206
2207    fn err_result(message: &str) -> SqlResult {
2208        SqlResult::Err {
2209            error: SqlError {
2210                message: message.to_string(),
2211                code: String::new(),
2212                detail: None,
2213                hint: None,
2214                position: None,
2215            },
2216            notices: vec![],
2217        }
2218    }
2219
2220    /// The row-returning statement's rows are returned, ignoring the `Ok`
2221    /// framing statements around it.
2222    #[mz_ore::test]
2223    fn test_select_single_rows_extracts_rows() {
2224        let rows = vec![vec![serde_json::json!(1)]];
2225        let results = vec![
2226            ok_result(),
2227            ok_result(),
2228            rows_result(rows.clone()),
2229            ok_result(),
2230        ];
2231        let got = select_single_rows(results).unwrap();
2232        let got: Vec<&str> = got.iter().map(|r| r.get()).collect();
2233        assert_eq!(got, vec!["[1]"]);
2234    }
2235
2236    /// A response with no row-returning statement is an error.
2237    #[mz_ore::test]
2238    fn test_select_single_rows_requires_rows() {
2239        let err = select_single_rows(vec![ok_result(), ok_result()]).unwrap_err();
2240        assert!(
2241            matches!(err, McpRequestError::QueryExecutionFailed(_)),
2242            "{err:?}"
2243        );
2244    }
2245
2246    /// The invariant is enforced: a second row-returning statement is an
2247    /// internal error rather than a silently dropped result.
2248    #[mz_ore::test]
2249    fn test_select_single_rows_rejects_multiple() {
2250        let results = vec![rows_result(vec![]), rows_result(vec![])];
2251        let err = select_single_rows(results).unwrap_err();
2252        assert!(matches!(err, McpRequestError::Internal(_)), "{err:?}");
2253    }
2254
2255    /// A statement error is surfaced.
2256    #[mz_ore::test]
2257    fn test_select_single_rows_surfaces_error() {
2258        let err = select_single_rows(vec![ok_result(), err_result("boom")]).unwrap_err();
2259        match err {
2260            McpRequestError::QueryExecutionFailed(msg) => assert_eq!(msg, "boom"),
2261            other => panic!("unexpected error: {other:?}"),
2262        }
2263    }
2264
2265    /// The DNS-rebinding defense: a disallowed `Origin` is rejected with 403,
2266    /// an allowed one passes, and a missing one passes (non-browser clients).
2267    #[mz_ore::test]
2268    fn test_validate_origin() {
2269        let allowed = [HeaderValue::from_static("https://good.example")];
2270
2271        assert!(validate_origin(&HeaderMap::new(), &allowed).is_none());
2272
2273        let mut ok = HeaderMap::new();
2274        ok.insert(http::header::ORIGIN, allowed[0].clone());
2275        assert!(validate_origin(&ok, &allowed).is_none());
2276
2277        let mut bad = HeaderMap::new();
2278        bad.insert(
2279            http::header::ORIGIN,
2280            HeaderValue::from_static("https://evil.example"),
2281        );
2282        let rejected = validate_origin(&bad, &allowed);
2283        assert_eq!(
2284            rejected
2285                .expect("disallowed origin must be rejected")
2286                .status(),
2287            StatusCode::FORBIDDEN,
2288        );
2289    }
2290
2291    /// The two constructors set the JSON-RPC version and put the payload in
2292    /// the right one of the mutually-exclusive `result` / `error` fields.
2293    #[mz_ore::test]
2294    fn test_mcp_response_constructors() {
2295        let id = serde_json::json!(1);
2296
2297        let ok = McpResponse::success(
2298            id.clone(),
2299            McpResult::ToolContent(ToolContentResult {
2300                content: vec![],
2301                is_error: false,
2302            })
2303            .into_body(ProtocolVersion::V2025_11_25, McpEndpointType::Developer),
2304        );
2305        assert_eq!(ok.jsonrpc, JSONRPC_VERSION);
2306        assert!(ok.result.is_some());
2307        assert!(ok.error.is_none());
2308
2309        let err = McpResponse::error(id, McpRequestError::ToolNotFound("t".to_string()).into());
2310        assert_eq!(err.jsonrpc, JSONRPC_VERSION);
2311        assert!(err.result.is_none());
2312        assert!(err.error.is_some());
2313    }
2314
2315    #[mz_ore::test]
2316    fn test_validate_readonly_query_select() {
2317        assert!(validate_readonly_query("SELECT * FROM mz_tables").is_ok());
2318        assert!(validate_readonly_query("SELECT 1 + 2").is_ok());
2319        assert!(validate_readonly_query("  SELECT 1  ").is_ok());
2320    }
2321
2322    #[mz_ore::test]
2323    fn test_validate_readonly_query_subqueries() {
2324        // Simple subquery in WHERE clause
2325        assert!(
2326            validate_readonly_query(
2327                "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
2328            )
2329            .is_ok()
2330        );
2331
2332        // Subquery in FROM clause
2333        assert!(
2334            validate_readonly_query(
2335                "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
2336            )
2337            .is_ok()
2338        );
2339
2340        // Correlated subquery
2341        assert!(validate_readonly_query(
2342            "SELECT * FROM mz_tables t WHERE EXISTS (SELECT 1 FROM mz_columns c WHERE c.id = t.id)"
2343        )
2344        .is_ok());
2345
2346        // Nested subqueries
2347        assert!(validate_readonly_query(
2348            "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns WHERE type IN (SELECT name FROM mz_types))"
2349        )
2350        .is_ok());
2351
2352        // Subquery with aggregation
2353        assert!(
2354            validate_readonly_query(
2355                "SELECT * FROM mz_tables WHERE id = (SELECT MAX(id) FROM mz_columns)"
2356            )
2357            .is_ok()
2358        );
2359    }
2360
2361    #[mz_ore::test]
2362    fn test_validate_readonly_query_show() {
2363        assert!(validate_readonly_query("SHOW CLUSTERS").is_ok());
2364        assert!(validate_readonly_query("SHOW TABLES").is_ok());
2365    }
2366
2367    #[mz_ore::test]
2368    fn test_validate_readonly_query_explain() {
2369        // Every read-only EXPLAIN variant must be accepted. Each line
2370        // corresponds to a distinct Statement::Explain* arm of the parser; if
2371        // one of them is dropped from the validator the test that lost its
2372        // arm will fail, naming the variant.
2373        assert!(validate_readonly_query("EXPLAIN SELECT 1").is_ok());
2374        assert!(
2375            validate_readonly_query("EXPLAIN FILTER PUSHDOWN FOR SELECT * FROM mz_tables").is_ok()
2376        );
2377        assert!(validate_readonly_query("EXPLAIN TIMESTAMP FOR SELECT 1").is_ok());
2378        assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR INDEX foo").is_ok());
2379        assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR MATERIALIZED VIEW mv").is_ok());
2380        assert!(validate_readonly_query("EXPLAIN ANALYZE CLUSTER MEMORY").is_ok());
2381    }
2382
2383    #[mz_ore::test]
2384    fn test_validate_readonly_query_rejects_writes() {
2385        assert!(validate_readonly_query("INSERT INTO t VALUES (1)").is_err());
2386        assert!(validate_readonly_query("UPDATE t SET a = 1").is_err());
2387        assert!(validate_readonly_query("DELETE FROM t").is_err());
2388        assert!(validate_readonly_query("CREATE TABLE t (a INT)").is_err());
2389        assert!(validate_readonly_query("DROP TABLE t").is_err());
2390    }
2391
2392    #[mz_ore::test]
2393    fn test_validate_readonly_query_rejects_multiple() {
2394        assert!(validate_readonly_query("SELECT 1; SELECT 2").is_err());
2395    }
2396
2397    #[mz_ore::test]
2398    fn test_validate_readonly_query_rejects_empty() {
2399        assert!(validate_readonly_query("").is_err());
2400        assert!(validate_readonly_query("   ").is_err());
2401    }
2402
2403    /// Regression test for DEX-64: without the 1 MB parser guard, the MCP
2404    /// validators would happily lex and parse multi-megabyte input. Both
2405    /// `validate_readonly_query` and `validate_system_catalog_query` now go
2406    /// through `parse_with_limit`, so an oversized batch is rejected with the
2407    /// same "statement batch size cannot exceed" message the SQL HTTP path
2408    /// emits.
2409    #[mz_ore::test]
2410    fn test_validate_readonly_query_enforces_size_limit() {
2411        use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
2412        // Just over the limit so the guard is the *only* thing that rejects.
2413        let oversized: String =
2414            "SELECT 1;".repeat((MAX_STATEMENT_BATCH_SIZE / "SELECT 1;".len()) + 1);
2415        let err = validate_readonly_query(&oversized).expect_err("should be rejected");
2416        let msg = err.to_string();
2417        assert!(
2418            msg.contains("statement batch size cannot exceed"),
2419            "expected size-guard error, got: {msg}"
2420        );
2421    }
2422
2423    #[mz_ore::test]
2424    fn test_validate_system_catalog_query_enforces_size_limit() {
2425        use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
2426        let stmt = "SELECT * FROM mz_tables;";
2427        let oversized: String = stmt.repeat((MAX_STATEMENT_BATCH_SIZE / stmt.len()) + 1);
2428        let err = validate_system_catalog_query(&oversized).expect_err("should be rejected");
2429        let msg = err.to_string();
2430        assert!(
2431            msg.contains("statement batch size cannot exceed"),
2432            "expected size-guard error, got: {msg}"
2433        );
2434    }
2435
2436    #[mz_ore::test]
2437    fn test_validate_system_catalog_query_accepts_mz_tables() {
2438        assert!(validate_system_catalog_query("SELECT * FROM mz_tables").is_ok());
2439        assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_comments").is_ok());
2440        assert!(
2441            validate_system_catalog_query(
2442                "SELECT * FROM mz_tables t JOIN mz_columns c ON t.id = c.id"
2443            )
2444            .is_ok()
2445        );
2446    }
2447
2448    #[mz_ore::test]
2449    fn test_validate_system_catalog_query_subqueries() {
2450        // Subquery with mz_* tables
2451        assert!(
2452            validate_system_catalog_query(
2453                "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
2454            )
2455            .is_ok()
2456        );
2457
2458        // Nested subqueries with mz_* tables
2459        assert!(validate_system_catalog_query(
2460            "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM mz_columns WHERE type IN (SELECT id FROM mz_types))"
2461        )
2462        .is_ok());
2463
2464        // Subquery in FROM clause
2465        assert!(
2466            validate_system_catalog_query(
2467                "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
2468            )
2469            .is_ok()
2470        );
2471
2472        // Reject subqueries that reference non-mz_* tables
2473        assert!(
2474            validate_system_catalog_query(
2475                "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM user_data)"
2476            )
2477            .is_err()
2478        );
2479
2480        // Reject mixed references in nested subqueries
2481        assert!(validate_system_catalog_query(
2482            "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM (SELECT id FROM user_table) AS t)"
2483        )
2484        .is_err());
2485    }
2486
2487    #[mz_ore::test]
2488    fn test_validate_system_catalog_query_rejects_user_tables() {
2489        assert!(validate_system_catalog_query("SELECT * FROM user_data").is_err());
2490        assert!(validate_system_catalog_query("SELECT * FROM my_table").is_err());
2491        // Security: reject queries that mention mz_ in a non-table context
2492        assert!(
2493            validate_system_catalog_query("SELECT * FROM user_data WHERE 'mz_' IS NOT NULL")
2494                .is_err()
2495        );
2496    }
2497
2498    #[mz_ore::test]
2499    fn test_validate_system_catalog_query_allows_functions() {
2500        // Function names should not be treated as table references
2501        assert!(
2502            validate_system_catalog_query(
2503                "SELECT date_part('year', now())::int4 AS y FROM mz_tables LIMIT 1"
2504            )
2505            .is_ok()
2506        );
2507        assert!(validate_system_catalog_query("SELECT length(name) FROM mz_tables").is_ok());
2508        assert!(
2509            validate_system_catalog_query(
2510                "SELECT count(*) FROM mz_sources WHERE now() > created_at"
2511            )
2512            .is_ok()
2513        );
2514    }
2515
2516    #[mz_ore::test]
2517    fn test_validate_system_catalog_query_unqualified_pg_catalog() {
2518        // Unqualified `pg_`-prefixed names are as unambiguously system as `mz_`
2519        // ones, and resolve safely under the pinned search_path.
2520        assert!(validate_system_catalog_query("SELECT * FROM pg_class").is_ok());
2521        assert!(validate_system_catalog_query("SELECT nspname FROM pg_namespace").is_ok());
2522    }
2523
2524    #[mz_ore::test]
2525    fn test_validate_system_catalog_query_schema_qualified() {
2526        // Qualified with allowed schemas should work
2527        assert!(validate_system_catalog_query("SELECT * FROM mz_catalog.mz_tables").is_ok());
2528        assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_sessions").is_ok());
2529        assert!(validate_system_catalog_query("SELECT * FROM pg_catalog.pg_type").is_ok());
2530        assert!(validate_system_catalog_query("SELECT * FROM information_schema.tables").is_ok());
2531
2532        // Qualified with disallowed schema should fail
2533        assert!(validate_system_catalog_query("SELECT * FROM public.user_table").is_err());
2534        assert!(validate_system_catalog_query("SELECT * FROM myschema.mytable").is_err());
2535
2536        // mz_unsafe is a system schema but explicitly blocked for MCP
2537        assert!(
2538            validate_system_catalog_query("SELECT * FROM mz_unsafe.mz_some_table").is_err(),
2539            "mz_unsafe schema should be blocked even though it is a system schema"
2540        );
2541
2542        // Mixed: system and user schemas should fail
2543        assert!(
2544            validate_system_catalog_query(
2545                "SELECT * FROM mz_catalog.mz_tables JOIN public.user_data ON true"
2546            )
2547            .is_err()
2548        );
2549    }
2550
2551    #[mz_ore::test]
2552    fn test_validate_system_catalog_query_adversarial_cases() {
2553        // Try to sneak in user table via CTE
2554        assert!(
2555            validate_system_catalog_query(
2556                "WITH user_cte AS (SELECT * FROM user_data) \
2557                 SELECT * FROM mz_tables, user_cte"
2558            )
2559            .is_err(),
2560            "Should reject CTE referencing user table"
2561        );
2562
2563        // Complex multi-level CTE with user table buried deep
2564        assert!(
2565            validate_system_catalog_query(
2566                "WITH \
2567                   cte1 AS (SELECT * FROM mz_tables), \
2568                   cte2 AS (SELECT * FROM cte1), \
2569                   cte3 AS (SELECT * FROM user_data) \
2570                 SELECT * FROM cte2"
2571            )
2572            .is_err(),
2573            "Should reject CTE chain with user table"
2574        );
2575
2576        // Multiple joins - user table in the middle
2577        assert!(
2578            validate_system_catalog_query(
2579                "SELECT * FROM mz_tables t1 \
2580                 JOIN user_data u ON t1.id = u.id \
2581                 JOIN mz_sources s ON t1.id = s.id"
2582            )
2583            .is_err(),
2584            "Should reject multi-join with user table"
2585        );
2586
2587        // LEFT JOIN trying to hide user table
2588        assert!(
2589            validate_system_catalog_query(
2590                "SELECT * FROM mz_tables t \
2591                 LEFT JOIN user_data u ON t.id = u.table_id \
2592                 WHERE u.id IS NULL"
2593            )
2594            .is_err(),
2595            "Should reject LEFT JOIN with user table"
2596        );
2597
2598        // Nested subquery with user table in FROM
2599        assert!(
2600            validate_system_catalog_query(
2601                "SELECT * FROM mz_tables WHERE id IN \
2602                 (SELECT table_id FROM (SELECT * FROM user_data) AS u)"
2603            )
2604            .is_err(),
2605            "Should reject nested subquery with user table"
2606        );
2607
2608        // UNION trying to mix system and user data
2609        assert!(
2610            validate_system_catalog_query(
2611                "SELECT name FROM mz_tables \
2612                 UNION \
2613                 SELECT name FROM user_data"
2614            )
2615            .is_err(),
2616            "Should reject UNION with user table"
2617        );
2618
2619        // UNION ALL variation
2620        assert!(
2621            validate_system_catalog_query(
2622                "SELECT id FROM mz_sources \
2623                 UNION ALL \
2624                 SELECT id FROM products"
2625            )
2626            .is_err(),
2627            "Should reject UNION ALL with user table"
2628        );
2629
2630        // Cross join with user table
2631        assert!(
2632            validate_system_catalog_query("SELECT * FROM mz_tables CROSS JOIN user_data").is_err(),
2633            "Should reject CROSS JOIN with user table"
2634        );
2635
2636        // Subquery in SELECT clause referencing user table
2637        assert!(
2638            validate_system_catalog_query(
2639                "SELECT t.*, (SELECT COUNT(*) FROM user_data) AS cnt FROM mz_tables t"
2640            )
2641            .is_err(),
2642            "Should reject subquery in SELECT with user table"
2643        );
2644
2645        // Try to use a schema name that looks similar to allowed ones
2646        assert!(
2647            validate_system_catalog_query("SELECT * FROM mz_catalogg.fake_table").is_err(),
2648            "Should reject typo-squatting schema name"
2649        );
2650        assert!(
2651            validate_system_catalog_query("SELECT * FROM mz_catalog_hack.fake_table").is_err(),
2652            "Should reject fake schema with mz_catalog prefix"
2653        );
2654
2655        // Lateral join with user table
2656        assert!(
2657            validate_system_catalog_query(
2658                "SELECT * FROM mz_tables t, LATERAL (SELECT * FROM user_data WHERE id = t.id) u"
2659            )
2660            .is_err(),
2661            "Should reject LATERAL join with user table"
2662        );
2663
2664        // Valid complex query - all system tables
2665        assert!(
2666            validate_system_catalog_query(
2667                "WITH \
2668                   tables AS (SELECT * FROM mz_tables), \
2669                   sources AS (SELECT * FROM mz_sources) \
2670                 SELECT t.name, s.name \
2671                 FROM tables t \
2672                 JOIN sources s ON t.id = s.id \
2673                 WHERE t.id IN (SELECT id FROM mz_columns)"
2674            )
2675            .is_ok(),
2676            "Should allow complex query with only system tables"
2677        );
2678
2679        // Valid UNION of system tables
2680        assert!(
2681            validate_system_catalog_query(
2682                "SELECT name FROM mz_tables \
2683                 UNION \
2684                 SELECT name FROM mz_sources"
2685            )
2686            .is_ok(),
2687            "Should allow UNION of system tables"
2688        );
2689    }
2690
2691    #[mz_ore::test]
2692    fn test_validate_system_catalog_query_rejects_constant_queries() {
2693        // SELECT without any table reference should be rejected — the developer
2694        // endpoint is for system catalog queries, not arbitrary computation.
2695        assert!(
2696            validate_system_catalog_query("SELECT 1").is_err(),
2697            "Should reject constant SELECT with no table references"
2698        );
2699        assert!(
2700            validate_system_catalog_query("SELECT 1 + 2, 'hello'").is_err(),
2701            "Should reject constant expression SELECT"
2702        );
2703        assert!(
2704            validate_system_catalog_query("SELECT now()").is_err(),
2705            "Should reject function-only SELECT with no table references"
2706        );
2707    }
2708
2709    #[mz_ore::test]
2710    fn test_validate_system_catalog_query_rejects_mixed_tables() {
2711        assert!(
2712            validate_system_catalog_query(
2713                "SELECT * FROM mz_tables t JOIN user_data u ON t.id = u.table_id"
2714            )
2715            .is_err()
2716        );
2717    }
2718
2719    #[mz_ore::test]
2720    fn test_validate_system_catalog_query_allows_show() {
2721        // SHOW queries don't reference tables in the AST but are safe read-only ops
2722        assert!(
2723            validate_system_catalog_query("SHOW TABLES FROM mz_internal").is_ok(),
2724            "SHOW TABLES FROM mz_internal should be allowed"
2725        );
2726        assert!(
2727            validate_system_catalog_query("SHOW TABLES FROM mz_catalog").is_ok(),
2728            "SHOW TABLES FROM mz_catalog should be allowed"
2729        );
2730        assert!(
2731            validate_system_catalog_query("SHOW CLUSTERS").is_ok(),
2732            "SHOW CLUSTERS should be allowed"
2733        );
2734        assert!(
2735            validate_system_catalog_query("SHOW SOURCES").is_ok(),
2736            "SHOW SOURCES should be allowed"
2737        );
2738        assert!(
2739            validate_system_catalog_query("SHOW TABLES").is_ok(),
2740            "SHOW TABLES should be allowed"
2741        );
2742    }
2743
2744    #[mz_ore::test]
2745    fn test_validate_system_catalog_query_allows_explain() {
2746        assert!(
2747            validate_system_catalog_query("EXPLAIN SELECT * FROM mz_tables").is_ok(),
2748            "EXPLAIN of system table query should be allowed"
2749        );
2750        assert!(
2751            validate_system_catalog_query("EXPLAIN SELECT 1").is_ok(),
2752            "EXPLAIN SELECT 1 should be allowed"
2753        );
2754    }
2755
2756    // ── Query tool feature flag tests ──────────────────────────────────────
2757
2758    #[mz_ore::test(tokio::test)]
2759    async fn test_tools_list_agent_query_tool_disabled() {
2760        let result = handle_tools_list(McpEndpointType::Agent, false, true, 1_000_000).unwrap();
2761        let McpResult::ToolsList(list) = result else {
2762            panic!("Expected ToolsList result");
2763        };
2764        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2765        assert!(
2766            tool_names.contains(&"get_data_products"),
2767            "get_data_products should always be present"
2768        );
2769        assert!(
2770            tool_names.contains(&"get_data_product_details"),
2771            "get_data_product_details should always be present"
2772        );
2773        assert!(
2774            tool_names.contains(&"read_data_product"),
2775            "read_data_product should be present when its flag is on"
2776        );
2777        assert!(
2778            !tool_names.contains(&"query"),
2779            "query tool should be hidden when disabled"
2780        );
2781    }
2782
2783    #[mz_ore::test(tokio::test)]
2784    async fn test_tools_list_agent_query_tool_enabled() {
2785        let result = handle_tools_list(McpEndpointType::Agent, true, true, 1_000_000).unwrap();
2786        let McpResult::ToolsList(list) = result else {
2787            panic!("Expected ToolsList result");
2788        };
2789        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2790        assert!(
2791            tool_names.contains(&"get_data_products"),
2792            "get_data_products should always be present"
2793        );
2794        assert!(
2795            tool_names.contains(&"get_data_product_details"),
2796            "get_data_product_details should always be present"
2797        );
2798        assert!(
2799            tool_names.contains(&"read_data_product"),
2800            "read_data_product should be present when its flag is on"
2801        );
2802        assert!(
2803            tool_names.contains(&"query"),
2804            "query tool should be present when enabled"
2805        );
2806    }
2807
2808    #[mz_ore::test(tokio::test)]
2809    async fn test_tools_list_agent_read_data_product_tool_disabled() {
2810        let result = handle_tools_list(McpEndpointType::Agent, true, false, 1_000_000).unwrap();
2811        let McpResult::ToolsList(list) = result else {
2812            panic!("Expected ToolsList result");
2813        };
2814        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2815        assert!(
2816            tool_names.contains(&"get_data_products"),
2817            "get_data_products should always be present"
2818        );
2819        assert!(
2820            tool_names.contains(&"get_data_product_details"),
2821            "get_data_product_details should always be present"
2822        );
2823        assert!(
2824            !tool_names.contains(&"read_data_product"),
2825            "read_data_product should be hidden when disabled"
2826        );
2827        assert!(
2828            tool_names.contains(&"query"),
2829            "query tool should remain present when enabled"
2830        );
2831    }
2832
2833    /// Both read tools off is a valid (if unusual) config where the agent
2834    /// is discovery-only. Pin the behavior: only get_data_products and
2835    /// get_data_product_details are advertised, and the initialize
2836    /// instructions do not tell the agent to use a tool that isn't listed.
2837    #[mz_ore::test(tokio::test)]
2838    async fn test_tools_list_agent_both_read_tools_disabled() {
2839        let result = handle_tools_list(McpEndpointType::Agent, false, false, 1_000_000).unwrap();
2840        let McpResult::ToolsList(list) = result else {
2841            panic!("Expected ToolsList result");
2842        };
2843        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2844        assert_eq!(
2845            tool_names
2846                .iter()
2847                .copied()
2848                .collect::<std::collections::BTreeSet<_>>(),
2849            ["get_data_product_details", "get_data_products"]
2850                .into_iter()
2851                .collect(),
2852            "only discovery tools should be advertised when both read flags are off",
2853        );
2854
2855        let instructions = endpoint_instructions(McpEndpointType::Agent, false, false)
2856            .expect("agent instructions must be present");
2857        assert!(
2858            !instructions.contains("Use the `query` tool"),
2859            "instructions must not point at query when it is hidden: {instructions}",
2860        );
2861        assert!(
2862            !instructions.contains("`read_data_product` automatically"),
2863            "instructions must not point at read_data_product when it is hidden: {instructions}",
2864        );
2865        assert!(
2866            instructions.contains("discovery only"),
2867            "instructions must tell the agent it is discovery-only: {instructions}",
2868        );
2869    }
2870
2871    #[mz_ore::test(tokio::test)]
2872    async fn test_tools_list_developer_query_tool_disabled() {
2873        // Developer endpoint doesn't expose read_data_product; the flag is
2874        // orthogonal, so pass whichever value.
2875        let result = handle_tools_list(McpEndpointType::Developer, false, true, 1_000_000).unwrap();
2876        let McpResult::ToolsList(list) = result else {
2877            panic!("Expected ToolsList result");
2878        };
2879        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2880        assert!(
2881            tool_names.contains(&"query_system_catalog"),
2882            "query_system_catalog should always be present on developer"
2883        );
2884        assert!(
2885            !tool_names.contains(&"query"),
2886            "query tool should be hidden when disabled"
2887        );
2888    }
2889
2890    #[mz_ore::test(tokio::test)]
2891    async fn test_tools_list_developer_query_tool_enabled() {
2892        let result = handle_tools_list(McpEndpointType::Developer, true, true, 1_000_000).unwrap();
2893        let McpResult::ToolsList(list) = result else {
2894            panic!("Expected ToolsList result");
2895        };
2896        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2897        assert!(
2898            tool_names.contains(&"query_system_catalog"),
2899            "query_system_catalog should always be present on developer"
2900        );
2901        assert!(
2902            tool_names.contains(&"query"),
2903            "query tool should be present on developer when enabled"
2904        );
2905
2906        let instructions = endpoint_instructions(McpEndpointType::Developer, true, true)
2907            .expect("developer instructions must be present");
2908        assert!(
2909            instructions.contains("through the query tool with its cluster argument"),
2910            "instructions must route mz_introspection through query: {instructions}",
2911        );
2912    }
2913
2914    #[mz_ore::test]
2915    fn test_developer_instructions_query_tool_disabled() {
2916        let instructions = endpoint_instructions(McpEndpointType::Developer, false, true)
2917            .expect("developer instructions must be present");
2918        assert!(
2919            instructions.contains("cluster-targeted introspection is unavailable")
2920                && !instructions.contains("through the query tool"),
2921            "instructions must not route to the hidden query tool: {instructions}",
2922        );
2923    }
2924
2925    /// Clients attach `_meta` to `tools/list`. Rejecting it fails the whole
2926    /// request with a 422, which the client reads as the server being down.
2927    #[mz_ore::test]
2928    fn test_tools_list_accepts_optional_params() {
2929        for body in [
2930            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
2931            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":null}"#,
2932            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#,
2933            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"progressToken":0}}}"#,
2934        ] {
2935            let req: McpRequest = serde_json::from_str(body)
2936                .unwrap_or_else(|e| panic!("tools/list must deserialize: {body}: {e}"));
2937            assert!(
2938                matches!(req.method, McpMethod::ToolsList(_)),
2939                "expected ToolsList for {body}"
2940            );
2941        }
2942    }
2943
2944    #[mz_ore::test]
2945    fn test_protocol_version_select() {
2946        use ProtocolVersion::*;
2947        for (header, enabled, want) in [
2948            (&[][..], true, V2025_11_25),
2949            (&["2025-11-25"], true, V2025_11_25),
2950            (&["2025-06-18"], true, V2025_11_25),
2951            (&["2026-07-28"], true, V2026_07_28),
2952            (&["2026-07-28"], false, V2025_11_25),
2953            (&["not-a-version"], true, V2025_11_25),
2954            (&["2026-07-28X"], true, V2025_11_25),
2955            (&["2025-11-25", "2026-07-28"], true, V2026_07_28),
2956            (&["2025-11-25, 2026-07-28"], true, V2026_07_28),
2957            (&["2025-11-25", "2026-07-28"], false, V2025_11_25),
2958        ] {
2959            let values: Vec<_> = header.iter().map(|v| HeaderValue::from_static(v)).collect();
2960            assert_eq!(
2961                ProtocolVersion::select(&values, enabled),
2962                want,
2963                "for {header:?} with the flag {enabled}"
2964            );
2965        }
2966        let non_ascii = HeaderValue::from_bytes(b"2026-07-28\xff").expect("valid header bytes");
2967        assert_eq!(ProtocolVersion::select(&[non_ascii], true), V2025_11_25);
2968        let non_ascii_list =
2969            HeaderValue::from_bytes(b"\xff, 2026-07-28").expect("valid header bytes");
2970        assert_eq!(
2971            ProtocolVersion::select(&[non_ascii_list], true),
2972            V2026_07_28
2973        );
2974    }
2975
2976    fn json_headers() -> HeaderMap {
2977        HeaderMap::from_iter([(
2978            header::CONTENT_TYPE,
2979            HeaderValue::from_static("application/json"),
2980        )])
2981    }
2982
2983    #[mz_ore::test]
2984    fn test_parse_mcp_request_unknown_method_with_params() {
2985        let req = parse_mcp_request(
2986            &json_headers(),
2987            br#"{"jsonrpc":"2.0","id":7,"method":"server/other","params":{"_meta":{}}}"#,
2988        )
2989        .expect("unknown method with params must parse");
2990        assert!(matches!(req.method, McpMethod::Unknown));
2991        assert_eq!(req.id, Some(json!(7)));
2992
2993        let req = parse_mcp_request(
2994            &json_headers(),
2995            br#"{"jsonrpc":"2.0","method":"notifications/other","params":{}}"#,
2996        )
2997        .expect("unknown notification with params must parse");
2998        assert!(matches!(req.method, McpMethod::Unknown));
2999        assert_eq!(req.id, None);
3000    }
3001
3002    #[mz_ore::test]
3003    fn test_parse_mcp_request_keeps_other_rejections() {
3004        for (headers, body, want) in [
3005            (
3006                json_headers(),
3007                &br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nonexistent_tool","arguments":{}}}"#[..],
3008                StatusCode::UNPROCESSABLE_ENTITY,
3009            ),
3010            (
3011                json_headers(),
3012                br#"{"id":1,"method":"server/other","params":{}}"#,
3013                StatusCode::UNPROCESSABLE_ENTITY,
3014            ),
3015            (json_headers(), b"{not json", StatusCode::BAD_REQUEST),
3016            (
3017                HeaderMap::new(),
3018                br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
3019                StatusCode::UNSUPPORTED_MEDIA_TYPE,
3020            ),
3021        ] {
3022            let rejection = parse_mcp_request(&headers, body).expect_err("must be rejected");
3023            let body = String::from_utf8_lossy(body);
3024            assert_eq!(rejection.status(), want, "for {body}");
3025        }
3026    }
3027
3028    fn headers(method: &'static str, name: Option<&'static str>) -> McpHeaders {
3029        McpHeaders {
3030            protocol_version: vec![HeaderValue::from_static(
3031                ProtocolVersion::V2026_07_28.as_str(),
3032            )],
3033            method: vec![HeaderValue::from_static(method)],
3034            name: name.into_iter().map(HeaderValue::from_static).collect(),
3035        }
3036    }
3037
3038    #[mz_ore::test]
3039    fn test_header_check_tool_names() {
3040        let call = headers("tools/call", Some("query"));
3041        assert!(call.check("tools/call", ToolName::Name("query")).is_ok());
3042        assert!(call.check("tools/call", ToolName::Missing).is_err());
3043        assert!(call.check("tools/list", ToolName::NotToolsCall).is_err());
3044        assert!(
3045            headers("server/other", None)
3046                .check("server/other", ToolName::NotToolsCall)
3047                .is_ok()
3048        );
3049    }
3050
3051    /// Bodies the datadriven harness cannot send: not a JSON object, not JSON,
3052    /// or an object without a `method`.
3053    #[mz_ore::test(tokio::test)]
3054    async fn test_answer_parse_failure() {
3055        let no_method = r#"{"jsonrpc":"2.0","id":4,"params":{}}"#;
3056        for (body, want_code, want_id) in [
3057            ("[]", error_codes::INVALID_REQUEST, None),
3058            ("null", error_codes::INVALID_REQUEST, None),
3059            ("{not json", error_codes::PARSE_ERROR, None),
3060            (no_method, error_codes::INVALID_REQUEST, Some(json!(4))),
3061        ] {
3062            let failure = parse_mcp_request(&json_headers(), body.as_bytes())
3063                .expect_err("not a request we parse");
3064            let headers = headers("tools/list", None);
3065            let resp = answer_parse_failure(failure, body.as_bytes(), &headers, &|_| {});
3066            assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "status for {body}");
3067            let resp: serde_json::Value = serde_json::from_slice(
3068                &axum::body::to_bytes(resp.into_body(), usize::MAX)
3069                    .await
3070                    .expect("body"),
3071            )
3072            .expect("JSON body");
3073            assert_eq!(resp["error"]["code"], json!(want_code), "code for {body}");
3074            assert_eq!(resp.get("id").cloned(), want_id, "id for {body}");
3075        }
3076    }
3077
3078    /// `ping` and `notifications/initialized` are named variants so their
3079    /// `params` deserialize. A `_meta` on the acknowledgement would otherwise
3080    /// fail the body with a 422 in the middle of the handshake.
3081    #[mz_ore::test]
3082    fn test_named_methods_accept_optional_params() {
3083        for (body, want_ping) in [
3084            (r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#, true),
3085            (
3086                r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}"#,
3087                true,
3088            ),
3089            (
3090                r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{"_meta":{"progressToken":0}}}"#,
3091                true,
3092            ),
3093            (
3094                r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
3095                false,
3096            ),
3097            (
3098                r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{"_meta":{"x":1}}}"#,
3099                false,
3100            ),
3101        ] {
3102            let req: McpRequest = serde_json::from_str(body)
3103                .unwrap_or_else(|e| panic!("must deserialize: {body}: {e}"));
3104            if want_ping {
3105                assert!(matches!(req.method, McpMethod::Ping(_)), "for {body}");
3106            } else {
3107                assert!(
3108                    matches!(req.method, McpMethod::NotificationsInitialized(_)),
3109                    "for {body}"
3110                );
3111            }
3112        }
3113    }
3114
3115    #[mz_ore::test]
3116    fn test_tools_call_arguments_optional() {
3117        for body in [
3118            r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_data_products"}}"#,
3119            r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_data_products","arguments":{}}}"#,
3120            r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_data_products","arguments":null}}"#,
3121        ] {
3122            let req: McpRequest = serde_json::from_str(body)
3123                .unwrap_or_else(|e| panic!("must deserialize: {body}: {e}"));
3124            assert!(
3125                matches!(
3126                    req.method,
3127                    McpMethod::ToolsCall(ToolsCallParams::GetDataProducts(NoArguments {}))
3128                ),
3129                "for {body}"
3130            );
3131        }
3132
3133        // A tool that needs arguments still fails, now on the missing field itself.
3134        let err = serde_json::from_str::<McpRequest>(
3135            r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"query_system_catalog"}}"#,
3136        )
3137        .expect_err("query_system_catalog requires sql_query");
3138        assert!(err.to_string().contains("sql_query"), "{err}");
3139    }
3140
3141    // ── Response size cap tests ────────────────────────────────────────
3142
3143    #[mz_ore::test]
3144    fn test_format_rows_response_within_limit() {
3145        let rows = vec![vec![json!("a"), json!(1)], vec![json!("b"), json!(2)]];
3146        let result = format_rows_response(raw_rows(rows), 1_000_000).unwrap();
3147        let McpResult::ToolContent(content) = result else {
3148            panic!("Expected ToolContent");
3149        };
3150        assert_eq!(content.content.len(), 1);
3151        assert!(content.content[0].text.contains("\"a\""));
3152        assert!(content.content[0].text.contains("\"b\""));
3153    }
3154
3155    #[mz_ore::test]
3156    fn test_format_rows_response_errors_when_over_limit() {
3157        let rows: Vec<Vec<serde_json::Value>> = (0..100)
3158            .map(|i| vec![json!(format!("row_{}", i)), json!(i)])
3159            .collect();
3160        let err = format_rows_response(raw_rows(rows), 500).unwrap_err();
3161        let msg = err.to_string();
3162        assert!(
3163            msg.contains("exceeds the 500 byte limit"),
3164            "Error should mention the size limit, got: {msg}"
3165        );
3166        assert!(
3167            msg.contains("Use LIMIT or WHERE"),
3168            "Error should suggest narrowing the query, got: {msg}"
3169        );
3170    }
3171
3172    #[mz_ore::test]
3173    fn test_format_rows_response_empty_rows() {
3174        let rows: Vec<Vec<serde_json::Value>> = vec![];
3175        let result = format_rows_response(raw_rows(rows), 1000).unwrap();
3176        let McpResult::ToolContent(content) = result else {
3177            panic!("Expected ToolContent");
3178        };
3179        assert_eq!(content.content.len(), 1);
3180        assert_eq!(content.content[0].text, "[]");
3181    }
3182
3183    // ── Data product name validation tests ─────────────────────────────
3184
3185    #[mz_ore::test]
3186    fn test_safe_data_product_name_valid() {
3187        // Fully qualified quoted identifiers
3188        assert_eq!(
3189            safe_data_product_name(r#""materialize"."public"."my_view""#).unwrap(),
3190            r#""materialize"."public"."my_view""#
3191        );
3192        // Two-part name
3193        assert_eq!(
3194            safe_data_product_name(r#""public"."my_view""#).unwrap(),
3195            r#""public"."my_view""#
3196        );
3197        // Unquoted name gets quoted in stable mode
3198        assert_eq!(safe_data_product_name("my_view").unwrap(), r#""my_view""#);
3199    }
3200
3201    #[mz_ore::test]
3202    fn test_safe_data_product_name_rejects_empty() {
3203        assert!(safe_data_product_name("").is_err());
3204        assert!(safe_data_product_name("   ").is_err());
3205    }
3206
3207    /// DEX-64: `parse_item_name` is unbounded on its own; the MCP path
3208    /// switches to `parse_item_name_with_limit` so a pathological name (e.g.
3209    /// millions of `(` characters) is rejected before lexing.
3210    #[mz_ore::test]
3211    fn test_safe_data_product_name_enforces_size_limit() {
3212        use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
3213        let oversized: String = "(".repeat(MAX_STATEMENT_BATCH_SIZE + 1);
3214        let err = safe_data_product_name(&oversized).expect_err("should be rejected");
3215        let msg = err.to_string();
3216        assert!(
3217            msg.contains("statement batch size cannot exceed"),
3218            "expected size-guard error, got: {msg}"
3219        );
3220    }
3221
3222    #[mz_ore::test]
3223    fn test_safe_data_product_name_rejects_sql_injection() {
3224        // Attempted injection via semicolon
3225        assert!(safe_data_product_name("my_view; DROP TABLE users").is_err());
3226        // Attempted injection via subquery
3227        assert!(safe_data_product_name("my_view UNION SELECT * FROM secrets").is_err());
3228        // Multiple table references via comma
3229        assert!(safe_data_product_name("my_view, secrets").is_err());
3230        // SQL keywords after name are rejected by the parser
3231        assert!(safe_data_product_name("my_view WHERE 1=1 --").is_err());
3232    }
3233
3234    /// A trailing `--` comment in the body must not swallow the `; COMMIT;`.
3235    #[mz_ore::test]
3236    fn test_read_only_txn_comment_cannot_swallow_commit() {
3237        let sql = read_only_txn("SET CLUSTER = 'c'", "SELECT 1 --");
3238        assert!(
3239            sql.contains("\n; COMMIT;"),
3240            "COMMIT must sit on its own line: {sql}",
3241        );
3242    }
3243
3244    // ── query_set_clause tests (QAR-136) ───────────────────────────────
3245
3246    /// Without a replica, only `SET CLUSTER` is emitted.
3247    #[mz_ore::test]
3248    fn test_query_set_clause_without_replica() {
3249        let clause = query_set_clause("prod_cluster", None);
3250        assert_eq!(clause, "SET CLUSTER = 'prod_cluster'");
3251    }
3252
3253    /// With a replica, `SET CLUSTER_REPLICA` follows `SET CLUSTER`, scoping
3254    /// the read to one replica (what makes EXPLAIN ANALYZE usable on
3255    /// clusters with multiple replicas).
3256    #[mz_ore::test]
3257    fn test_query_set_clause_with_replica() {
3258        let clause = query_set_clause("prod_cluster", Some("r1"));
3259        assert_eq!(
3260            clause,
3261            "SET CLUSTER = 'prod_cluster'; SET CLUSTER_REPLICA = 'r1'"
3262        );
3263    }
3264
3265    /// Replica names are interpolated into a SQL string literal, so they get
3266    /// the same escaping treatment as cluster names. Defends against
3267    /// injection via adversarial replica names.
3268    #[mz_ore::test]
3269    fn test_query_set_clause_escapes_replica_name() {
3270        let clause = query_set_clause("c", Some("evil'; DROP TABLE secrets; --"));
3271        assert_eq!(
3272            clause,
3273            "SET CLUSTER = 'c'; SET CLUSTER_REPLICA = 'evil''; DROP TABLE secrets; --'"
3274        );
3275    }
3276
3277    /// A `None` replica (no pinning requested) is always valid, and a normal
3278    /// replica name passes validation.
3279    #[mz_ore::test]
3280    fn test_validate_cluster_replica_accepts_none_and_names() {
3281        assert!(validate_cluster_replica(None).is_ok());
3282        assert!(validate_cluster_replica(Some("r1")).is_ok());
3283    }
3284
3285    /// An empty or whitespace-only replica name is rejected as a validation
3286    /// error up front, rather than producing `SET CLUSTER_REPLICA = ''` that
3287    /// fails deep in the engine as a generic execution error.
3288    #[mz_ore::test]
3289    fn test_validate_cluster_replica_rejects_empty() {
3290        for name in ["", "   ", "\t\n"] {
3291            assert!(
3292                matches!(
3293                    validate_cluster_replica(Some(name)),
3294                    Err(McpRequestError::QueryValidationFailed(_))
3295                ),
3296                "expected validation error for {name:?}",
3297            );
3298        }
3299    }
3300
3301    // ── build_read_query tests (DEX-27) ────────────────────────────────
3302
3303    /// The read is wrapped in a `BEGIN READ ONLY` transaction so the
3304    /// `SET CLUSTER` scope is bounded to this read and does not leak
3305    /// into the rest of the session.
3306    #[mz_ore::test]
3307    fn test_build_read_query_with_cluster() {
3308        let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, Some("prod_cluster"));
3309        assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
3310        assert!(sql.contains("SET CLUSTER = 'prod_cluster'"), "{sql}");
3311        assert!(
3312            sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
3313            "{sql}",
3314        );
3315        assert!(sql.contains("COMMIT"), "{sql}");
3316    }
3317
3318    /// With no cluster (the role lacks USAGE on the object's cluster, DEX-66),
3319    /// the read omits `SET CLUSTER` and runs on the session's serving cluster.
3320    #[mz_ore::test]
3321    fn test_build_read_query_without_cluster() {
3322        let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, None);
3323        assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
3324        assert!(!sql.contains("SET CLUSTER"), "{sql}");
3325        assert!(
3326            sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
3327            "{sql}",
3328        );
3329        assert!(sql.contains("COMMIT"), "{sql}");
3330    }
3331
3332    /// Cluster names with single quotes and backslashes must be escaped
3333    /// the same way as the `name` argument, since they end up interpolated
3334    /// into a SQL string literal. Defends against catalog-injection or
3335    /// adversarial cluster names.
3336    #[mz_ore::test]
3337    fn test_build_read_query_escapes_cluster_name() {
3338        let sql = build_read_query(
3339            "\"db\".\"sch\".\"v\"",
3340            10,
3341            Some("evil'; DROP TABLE secrets; --"),
3342        );
3343        // The single quote in `evil'` must be doubled inside the literal.
3344        assert!(
3345            sql.contains("SET CLUSTER = 'evil''; DROP TABLE secrets; --'"),
3346            "single quote should be doubled inside the literal: {sql}",
3347        );
3348        // The injected `DROP TABLE` must remain inside the literal — i.e.
3349        // there is no second statement that escapes the SET CLUSTER call.
3350        assert_eq!(
3351            sql.matches("SET CLUSTER").count(),
3352            1,
3353            "exactly one SET CLUSTER statement: {sql}",
3354        );
3355        assert_eq!(
3356            sql.matches("DROP TABLE").count(),
3357            1,
3358            "DROP TABLE should appear once, inside the quoted literal: {sql}",
3359        );
3360    }
3361
3362    #[mz_ore::test]
3363    fn test_mcp_error_codes() {
3364        assert_eq!(
3365            McpRequestError::InvalidJsonRpcVersion.error_code(),
3366            error_codes::INVALID_REQUEST
3367        );
3368        assert_eq!(
3369            McpRequestError::MethodNotFound("test".to_string()).error_code(),
3370            error_codes::METHOD_NOT_FOUND
3371        );
3372        assert_eq!(
3373            McpRequestError::QueryExecutionFailed("test".to_string()).error_code(),
3374            error_codes::INTERNAL_ERROR
3375        );
3376        assert_eq!(
3377            McpRequestError::HeaderMismatch("test".to_string()).error_code(),
3378            error_codes::HEADER_MISMATCH
3379        );
3380    }
3381
3382    #[mz_ore::test]
3383    fn test_request_meta_check() {
3384        let params = |body: &str| {
3385            RawRequest::parse(body.as_bytes())
3386                .expect("an object")
3387                .params
3388        };
3389        let check =
3390            |body: &str| check_request_meta(params(body).as_ref()).map_err(|e| e.error_code());
3391        let with_meta = |meta: &str| {
3392            format!(
3393                r#"{{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{{"_meta":{meta}}}}}"#
3394            )
3395        };
3396        assert_eq!(
3397            check(&with_meta(
3398                r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}"#
3399            )),
3400            Ok(())
3401        );
3402        for (meta, want) in [
3403            (
3404                r#"{"io.modelcontextprotocol/clientCapabilities":{}}"#,
3405                error_codes::INVALID_PARAMS,
3406            ),
3407            (
3408                r#"{"io.modelcontextprotocol/protocolVersion":7,"io.modelcontextprotocol/clientCapabilities":{}}"#,
3409                error_codes::INVALID_PARAMS,
3410            ),
3411            (
3412                r#"{"io.modelcontextprotocol/protocolVersion":"2025-11-25","io.modelcontextprotocol/clientCapabilities":{}}"#,
3413                error_codes::HEADER_MISMATCH,
3414            ),
3415            (
3416                r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}"#,
3417                error_codes::INVALID_PARAMS,
3418            ),
3419            (
3420                r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":[]}"#,
3421                error_codes::INVALID_PARAMS,
3422            ),
3423        ] {
3424            assert_eq!(check(&with_meta(meta)), Err(want), "for {meta}");
3425        }
3426        let message = |body: &str| {
3427            check_request_meta(params(body).as_ref())
3428                .expect_err("must be rejected")
3429                .to_string()
3430        };
3431        assert!(
3432            message(&with_meta(
3433                r#"{"io.modelcontextprotocol/protocolVersion":7}"#
3434            ))
3435            .ends_with("must be a string")
3436        );
3437        assert!(message(&with_meta("[]")).ends_with("params._meta must be an object"));
3438        assert_eq!(
3439            check(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#),
3440            Err(error_codes::INVALID_PARAMS)
3441        );
3442    }
3443
3444    #[mz_ore::test]
3445    fn test_header_check_rejects_non_ascii() {
3446        let headers = McpHeaders {
3447            protocol_version: vec![HeaderValue::from_static(
3448                ProtocolVersion::V2026_07_28.as_str(),
3449            )],
3450            method: vec![HeaderValue::from_bytes(b"tools/list\xff").expect("valid header bytes")],
3451            name: vec![],
3452        };
3453        let err = headers
3454            .check("tools/list", ToolName::NotToolsCall)
3455            .expect_err("non-ASCII Mcp-Method");
3456        assert_eq!(err.error_code(), error_codes::HEADER_MISMATCH);
3457    }
3458}