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 anyhow::anyhow;
31use axum::Extension;
32use axum::Json;
33use axum::response::IntoResponse;
34use http::{HeaderMap, HeaderValue, StatusCode};
35use mz_adapter_types::dyncfgs::{
36    ENABLE_MCP_AGENT, ENABLE_MCP_AGENT_QUERY_TOOL, ENABLE_MCP_AGENT_READ_DATA_PRODUCT_TOOL,
37    ENABLE_MCP_DEVELOPER, ENABLE_MCP_DEVELOPER_QUERY_TOOL, MCP_MAX_RESPONSE_SIZE,
38    MCP_REQUEST_TIMEOUT,
39};
40use mz_ore::cast::CastLossy;
41use mz_repr::namespaces::{self, SYSTEM_SCHEMAS};
42use mz_sql::parse::{parse_item_name_with_limit, parse_with_limit};
43use mz_sql::session::metadata::SessionMetadata;
44use mz_sql::session::vars::{APPLICATION_NAME, Var, VarInput};
45use mz_sql_parser::ast::display::{AstDisplay, escaped_string_literal};
46use mz_sql_parser::ast::visit::{self, Visit};
47use mz_sql_parser::ast::{Raw, RawItemName};
48use serde::{Deserialize, Serialize};
49use serde_json::json;
50use thiserror::Error;
51use tracing::{debug, warn};
52
53use crate::http::AuthedClient;
54use crate::http::mcp_metrics::{McpCallStatus, McpMetrics, ToolCallGuard};
55use crate::http::sql::{SqlRequest, SqlResponse, SqlResult, execute_request};
56
57// To add a new tool: add entry to tools/list, add handler function, add dispatch case.
58
59/// JSON-RPC protocol version used in all MCP requests and responses.
60const JSONRPC_VERSION: &str = "2.0";
61
62/// MCP protocol version returned in the `initialize` response.
63/// Spec: <https://modelcontextprotocol.io/specification/2025-11-25>
64const MCP_PROTOCOL_VERSION: &str = "2025-11-25";
65
66// Discovery uses the lightweight view (no JSON schema computation).
67const DISCOVERY_QUERY: &str = "SELECT * FROM mz_internal.mz_mcp_data_products";
68const DETAILS_QUERY_PREFIX: &str =
69    "SELECT * FROM mz_internal.mz_mcp_data_product_details WHERE object_name = ";
70
71/// MCP request errors, mapped to JSON-RPC error codes.
72#[derive(Debug, Error)]
73enum McpRequestError {
74    #[error("Invalid JSON-RPC version: expected 2.0")]
75    InvalidJsonRpcVersion,
76    #[error("Method not found: {0}")]
77    MethodNotFound(String),
78    #[error("Tool not found: {0}")]
79    ToolNotFound(String),
80    #[error("Data product not found: {0}")]
81    DataProductNotFound(String),
82    #[error("Query validation failed: {0}")]
83    QueryValidationFailed(String),
84    #[error("Query execution failed: {0}")]
85    QueryExecutionFailed(String),
86    #[error("Internal error: {0}")]
87    Internal(#[from] anyhow::Error),
88}
89
90impl McpRequestError {
91    fn error_code(&self) -> i32 {
92        match self {
93            Self::InvalidJsonRpcVersion => error_codes::INVALID_REQUEST,
94            Self::MethodNotFound(_) => error_codes::METHOD_NOT_FOUND,
95            Self::ToolNotFound(_) => error_codes::INVALID_PARAMS,
96            Self::DataProductNotFound(_) => error_codes::INVALID_PARAMS,
97            Self::QueryValidationFailed(_) => error_codes::INVALID_PARAMS,
98            Self::QueryExecutionFailed(_) | Self::Internal(_) => error_codes::INTERNAL_ERROR,
99        }
100    }
101
102    fn error_type(&self) -> &'static str {
103        match self {
104            Self::InvalidJsonRpcVersion => "InvalidRequest",
105            Self::MethodNotFound(_) => "MethodNotFound",
106            Self::ToolNotFound(_) => "ToolNotFound",
107            Self::DataProductNotFound(_) => "DataProductNotFound",
108            Self::QueryValidationFailed(_) => "ValidationError",
109            Self::QueryExecutionFailed(_) => "ExecutionError",
110            Self::Internal(_) => "InternalError",
111        }
112    }
113}
114
115/// JSON-RPC 2.0 request. Requests have `id`; notifications don't.
116#[derive(Debug, Deserialize)]
117pub(crate) struct McpRequest {
118    jsonrpc: String,
119    id: Option<serde_json::Value>,
120    #[serde(flatten)]
121    method: McpMethod,
122}
123
124/// MCP method variants with their associated parameters.
125#[derive(Debug, Deserialize)]
126#[serde(tag = "method", content = "params")]
127enum McpMethod {
128    /// Initialize method - params accepted but not currently used
129    #[serde(rename = "initialize")]
130    Initialize(#[allow(dead_code)] InitializeParams),
131    /// `params` is accepted and ignored: clients may attach `_meta`, and a unit
132    /// variant would fail the whole request with a non-JSON-RPC 422.
133    #[serde(rename = "tools/list")]
134    ToolsList(#[allow(dead_code)] Option<serde_json::Value>),
135    #[serde(rename = "tools/call")]
136    ToolsCall(ToolsCallParams),
137    /// Keepalive, and the post-initialize acknowledgement. Both are named so
138    /// their `params` deserialize; `#[serde(other)]` must be a unit variant, so
139    /// anything falling through to `Unknown` with `params` still fails the body.
140    #[serde(rename = "ping")]
141    Ping(#[allow(dead_code)] Option<serde_json::Value>),
142    #[serde(rename = "notifications/initialized")]
143    NotificationsInitialized(#[allow(dead_code)] Option<serde_json::Value>),
144    /// Catch-all for unrecognized methods.
145    #[serde(other)]
146    Unknown,
147}
148
149impl std::fmt::Display for McpMethod {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        match self {
152            McpMethod::Initialize(_) => write!(f, "initialize"),
153            McpMethod::ToolsList(_) => write!(f, "tools/list"),
154            McpMethod::ToolsCall(_) => write!(f, "tools/call"),
155            McpMethod::Ping(_) => write!(f, "ping"),
156            McpMethod::NotificationsInitialized(_) => write!(f, "notifications/initialized"),
157            McpMethod::Unknown => write!(f, "unknown"),
158        }
159    }
160}
161
162#[derive(Debug, Deserialize)]
163struct InitializeParams {
164    /// Protocol version from client. Not currently validated but accepted for MCP compliance.
165    #[serde(rename = "protocolVersion")]
166    #[allow(dead_code)]
167    protocol_version: String,
168    /// Client capabilities. Not currently used but accepted for MCP compliance.
169    #[serde(default)]
170    #[allow(dead_code)]
171    capabilities: serde_json::Value,
172    /// Client information (name, version). Not currently used but accepted for MCP compliance.
173    #[serde(rename = "clientInfo")]
174    #[allow(dead_code)]
175    client_info: Option<ClientInfo>,
176}
177
178#[derive(Debug, Deserialize)]
179struct ClientInfo {
180    #[allow(dead_code)]
181    name: String,
182    #[allow(dead_code)]
183    version: String,
184}
185
186/// Tool call parameters, deserialized via adjacently tagged enum.
187/// Serde maps `name` to the variant and `arguments` to the variant's data.
188#[derive(Debug, Deserialize)]
189#[serde(tag = "name", content = "arguments")]
190#[serde(rename_all = "snake_case")]
191enum ToolsCallParams {
192    // Agent endpoint tools
193    // Uses an ignored empty struct so MCP clients sending `"arguments": {}` can deserialize.
194    GetDataProducts(#[serde(default)] ()),
195    GetDataProductDetails(GetDataProductDetailsParams),
196    ReadDataProduct(ReadDataProductParams),
197    Query(QueryParams),
198    // Developer endpoint tools
199    QuerySystemCatalog(QuerySystemCatalogParams),
200}
201
202impl std::fmt::Display for ToolsCallParams {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        match self {
205            ToolsCallParams::GetDataProducts(_) => write!(f, "get_data_products"),
206            ToolsCallParams::GetDataProductDetails(_) => write!(f, "get_data_product_details"),
207            ToolsCallParams::ReadDataProduct(_) => write!(f, "read_data_product"),
208            ToolsCallParams::Query(_) => write!(f, "query"),
209            ToolsCallParams::QuerySystemCatalog(_) => write!(f, "query_system_catalog"),
210        }
211    }
212}
213
214#[derive(Debug, Deserialize)]
215struct GetDataProductDetailsParams {
216    name: String,
217}
218
219#[derive(Debug, Deserialize)]
220struct ReadDataProductParams {
221    name: String,
222    #[serde(default = "default_read_limit")]
223    limit: u32,
224    cluster: Option<String>,
225}
226
227/// Default row cap for `read_data_product` when the caller omits `limit`.
228const DEFAULT_READ_LIMIT: u32 = 500;
229
230fn default_read_limit() -> u32 {
231    DEFAULT_READ_LIMIT
232}
233
234#[derive(Debug, Deserialize)]
235struct QueryParams {
236    cluster: String,
237    /// Only honored on the developer endpoint. The agent endpoint's dispatch
238    /// arm drops it, since replica pinning is not part of the agent surface.
239    cluster_replica: Option<String>,
240    sql_query: String,
241}
242
243#[derive(Debug, Deserialize)]
244struct QuerySystemCatalogParams {
245    sql_query: String,
246}
247
248#[derive(Debug, Serialize)]
249struct McpResponse {
250    jsonrpc: String,
251    id: serde_json::Value,
252    #[serde(skip_serializing_if = "Option::is_none")]
253    result: Option<McpResult>,
254    #[serde(skip_serializing_if = "Option::is_none")]
255    error: Option<McpError>,
256}
257
258impl McpResponse {
259    /// A successful JSON-RPC response carrying `result`.
260    fn success(id: serde_json::Value, result: McpResult) -> Self {
261        Self {
262            jsonrpc: JSONRPC_VERSION.to_string(),
263            id,
264            result: Some(result),
265            error: None,
266        }
267    }
268
269    /// A JSON-RPC error response carrying `error`.
270    fn error(id: serde_json::Value, error: McpError) -> Self {
271        Self {
272            jsonrpc: JSONRPC_VERSION.to_string(),
273            id,
274            result: None,
275            error: Some(error),
276        }
277    }
278}
279
280/// Typed MCP response results.
281#[derive(Debug, Serialize)]
282#[serde(untagged)]
283enum McpResult {
284    Initialize(InitializeResult),
285    ToolsList(ToolsListResult),
286    ToolContent(ToolContentResult),
287}
288
289#[derive(Debug, Serialize)]
290struct InitializeResult {
291    #[serde(rename = "protocolVersion")]
292    protocol_version: String,
293    capabilities: Capabilities,
294    #[serde(rename = "serverInfo")]
295    server_info: ServerInfo,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    instructions: Option<String>,
298}
299
300#[derive(Debug, Serialize)]
301struct Capabilities {
302    tools: serde_json::Value,
303}
304
305#[derive(Debug, Serialize)]
306struct ServerInfo {
307    name: String,
308    version: String,
309}
310
311#[derive(Debug, Serialize)]
312struct ToolsListResult {
313    tools: Vec<ToolDefinition>,
314}
315
316#[derive(Debug, Serialize)]
317struct ToolDefinition {
318    name: String,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    title: Option<String>,
321    description: String,
322    #[serde(rename = "inputSchema")]
323    input_schema: serde_json::Value,
324    #[serde(skip_serializing_if = "Option::is_none")]
325    annotations: Option<ToolAnnotations>,
326}
327
328/// MCP 2025-11-25 tool annotations that describe tool behavior.
329/// These hints help clients make trust and safety decisions.
330#[derive(Debug, Serialize)]
331struct ToolAnnotations {
332    #[serde(rename = "readOnlyHint", skip_serializing_if = "Option::is_none")]
333    read_only_hint: Option<bool>,
334    #[serde(rename = "destructiveHint", skip_serializing_if = "Option::is_none")]
335    destructive_hint: Option<bool>,
336    #[serde(rename = "idempotentHint", skip_serializing_if = "Option::is_none")]
337    idempotent_hint: Option<bool>,
338    #[serde(rename = "openWorldHint", skip_serializing_if = "Option::is_none")]
339    open_world_hint: Option<bool>,
340}
341
342/// Annotations for all MCP tools: read-only, non-destructive, idempotent.
343const READ_ONLY_ANNOTATIONS: ToolAnnotations = ToolAnnotations {
344    read_only_hint: Some(true),
345    destructive_hint: Some(false),
346    idempotent_hint: Some(true),
347    open_world_hint: Some(false),
348};
349
350#[derive(Debug, Serialize)]
351struct ToolContentResult {
352    content: Vec<ContentBlock>,
353    /// Always `false`: tool failures surface as JSON-RPC errors instead.
354    #[serde(rename = "isError")]
355    is_error: bool,
356}
357
358#[derive(Debug, Serialize)]
359struct ContentBlock {
360    #[serde(rename = "type")]
361    content_type: String,
362    text: String,
363}
364
365/// JSON-RPC 2.0 error codes.
366mod error_codes {
367    pub const INVALID_REQUEST: i32 = -32600;
368    pub const METHOD_NOT_FOUND: i32 = -32601;
369    pub const INVALID_PARAMS: i32 = -32602;
370    pub const INTERNAL_ERROR: i32 = -32603;
371}
372
373#[derive(Debug, Serialize)]
374struct McpError {
375    code: i32,
376    message: String,
377    #[serde(skip_serializing_if = "Option::is_none")]
378    data: Option<serde_json::Value>,
379}
380
381impl From<McpRequestError> for McpError {
382    fn from(err: McpRequestError) -> Self {
383        McpError {
384            code: err.error_code(),
385            message: err.to_string(),
386            data: Some(json!({
387                "error_type": err.error_type(),
388            })),
389        }
390    }
391}
392
393#[derive(Debug, Clone, Copy)]
394enum McpEndpointType {
395    Agent,
396    Developer,
397}
398
399impl McpEndpointType {
400    /// Static label for metrics. Avoids per-request allocation that would
401    /// come from going through `Display`.
402    fn as_label(self) -> &'static str {
403        match self {
404            McpEndpointType::Agent => "agent",
405            McpEndpointType::Developer => "developer",
406        }
407    }
408}
409
410impl std::fmt::Display for McpEndpointType {
411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        f.write_str(self.as_label())
413    }
414}
415
416/// MCP 2025-11-25 requires servers to return 405 for GET requests
417/// on endpoints that only support POST.
418pub async fn handle_mcp_method_not_allowed() -> impl IntoResponse {
419    StatusCode::METHOD_NOT_ALLOWED
420}
421
422/// Agent endpoint: exposes user data products.
423pub async fn handle_mcp_agent(
424    headers: HeaderMap,
425    Extension(allowed_origins): Extension<Arc<Vec<HeaderValue>>>,
426    Extension(metrics): Extension<McpMetrics>,
427    client: AuthedClient,
428    Json(body): Json<McpRequest>,
429) -> axum::response::Response {
430    if let Some(resp) = validate_origin(&headers, &allowed_origins) {
431        return resp;
432    }
433    handle_mcp_request(client, body, McpEndpointType::Agent, metrics)
434        .await
435        .into_response()
436}
437
438/// Developer endpoint: exposes system catalog (mz_*) only.
439pub async fn handle_mcp_developer(
440    headers: HeaderMap,
441    Extension(allowed_origins): Extension<Arc<Vec<HeaderValue>>>,
442    Extension(metrics): Extension<McpMetrics>,
443    client: AuthedClient,
444    Json(body): Json<McpRequest>,
445) -> axum::response::Response {
446    if let Some(resp) = validate_origin(&headers, &allowed_origins) {
447        return resp;
448    }
449    handle_mcp_request(client, body, McpEndpointType::Developer, metrics)
450        .await
451        .into_response()
452}
453
454/// Validates the Origin header against the CORS allowlist to prevent DNS
455/// rebinding attacks (MCP spec 2025-11-25). Returns Some(403) if Origin is
456/// present but not on the allowlist. Returns None if absent (non-browser
457/// client) or allowed.
458///
459/// Note: this server-side check is required in addition to the CorsLayer.
460/// CorsLayer only controls response headers and can be bypassed when the
461/// attacker arranges same-origin DNS rebinding (no preflight fires).
462fn validate_origin(
463    headers: &HeaderMap,
464    allowed: &[HeaderValue],
465) -> Option<axum::response::Response> {
466    let origin = headers.get(http::header::ORIGIN)?;
467    if mz_http_util::origin_is_allowed(origin, allowed) {
468        return None;
469    }
470    warn!(
471        origin = ?origin,
472        "MCP request rejected: origin not in allowlist",
473    );
474    Some(StatusCode::FORBIDDEN.into_response())
475}
476
477async fn handle_mcp_request(
478    mut client: AuthedClient,
479    request: McpRequest,
480    endpoint_type: McpEndpointType,
481    metrics: McpMetrics,
482) -> impl IntoResponse {
483    let endpoint_label = endpoint_type.as_label();
484    let method_label = request.method.to_string();
485    let record_request =
486        |status: McpCallStatus| metrics.record_request(endpoint_label, &method_label, status);
487
488    // Check the per-endpoint feature flag via a catalog snapshot, similar to frontend_peek.rs.
489    // The configured `MCP_REQUEST_TIMEOUT` lives in the snapshot we are about
490    // to fetch, so bound this phase with the compiled-in default. Without it a
491    // stalled snapshot would hang the request past any configured timeout.
492    let catalog = match tokio::time::timeout(
493        *MCP_REQUEST_TIMEOUT.default(),
494        client.client.catalog_snapshot("mcp"),
495    )
496    .await
497    {
498        Ok(catalog) => catalog,
499        Err(_elapsed) => {
500            warn!(endpoint = %endpoint_type, "MCP catalog snapshot timed out");
501            record_request(McpCallStatus::Timeout);
502            return StatusCode::SERVICE_UNAVAILABLE.into_response();
503        }
504    };
505    let dyncfgs = catalog.system_config().dyncfgs();
506    let enabled = match endpoint_type {
507        McpEndpointType::Agent => ENABLE_MCP_AGENT.get(dyncfgs),
508        McpEndpointType::Developer => ENABLE_MCP_DEVELOPER.get(dyncfgs),
509    };
510    if !enabled {
511        debug!(endpoint = %endpoint_type, "MCP endpoint disabled by feature flag");
512        record_request(McpCallStatus::EndpointDisabled);
513        return StatusCode::SERVICE_UNAVAILABLE.into_response();
514    }
515
516    // Per-endpoint feature flag for the `query` tool. Agent and developer have
517    // independent rollouts; collapsing to one bool keeps the downstream
518    // signatures unchanged since each handler invocation is already bound to a
519    // single endpoint.
520    let query_tool_enabled = match endpoint_type {
521        McpEndpointType::Agent => ENABLE_MCP_AGENT_QUERY_TOOL.get(dyncfgs),
522        McpEndpointType::Developer => ENABLE_MCP_DEVELOPER_QUERY_TOOL.get(dyncfgs),
523    };
524    // Only meaningful on the agent endpoint; the developer endpoint doesn't
525    // expose `read_data_product`. Read it unconditionally so the plumbing
526    // matches `query_tool_enabled` above.
527    let read_data_product_tool_enabled = ENABLE_MCP_AGENT_READ_DATA_PRODUCT_TOOL.get(dyncfgs);
528    let max_response_size = MCP_MAX_RESPONSE_SIZE.get(dyncfgs);
529    let request_timeout = MCP_REQUEST_TIMEOUT.get(dyncfgs);
530
531    // Tag MCP-originated sessions so they're distinguishable in
532    // mz_session_history / mz_statement_execution_history. set_default lets a
533    // caller still override via `?options={"application_name":"..."}`
534    let app_name = match endpoint_type {
535        McpEndpointType::Agent => "mz_mcp_agents",
536        McpEndpointType::Developer => "mz_mcp_developer",
537    };
538    client
539        .client
540        .session()
541        .vars_mut()
542        .set_default(APPLICATION_NAME.name(), VarInput::Flat(app_name))
543        .expect("application_name is a known session var");
544
545    let user = client.client.session().user().name.clone();
546    let is_notification = request.id.is_none();
547
548    debug!(
549        method = %request.method,
550        endpoint = %endpoint_type,
551        user = %user,
552        is_notification = is_notification,
553        "MCP request received"
554    );
555
556    // No `id` means no reply, which the transport answers 202. SDK clients take
557    // any other status as a response to parse and close on the empty body.
558    if is_notification {
559        debug!(method = %request.method, "Received notification (no response will be sent)");
560        record_request(McpCallStatus::Ok);
561        return StatusCode::ACCEPTED.into_response();
562    }
563
564    let request_id = request.id.clone().unwrap_or(serde_json::Value::Null);
565
566    // Spawn task for fault isolation, with a timeout safety net.
567    // `abort_on_drop` propagates the timeout to the task itself; without
568    // it the task orphans and the SQL query keeps running in the
569    // background after the client gives up (see database-issues#9947).
570    let metrics_inner = metrics.clone();
571    let result = tokio::time::timeout(
572        request_timeout,
573        mz_ore::task::spawn(|| "mcp_request", async move {
574            handle_mcp_request_inner(
575                &mut client,
576                request,
577                endpoint_type,
578                query_tool_enabled,
579                read_data_product_tool_enabled,
580                max_response_size,
581                metrics_inner,
582            )
583            .await
584        })
585        .abort_on_drop(),
586    )
587    .await;
588
589    let (response, status_label): (McpResponse, McpCallStatus) = match result {
590        Ok(inner) => inner,
591        Err(_elapsed) => {
592            warn!(
593                endpoint = %endpoint_type,
594                timeout = ?request_timeout,
595                "MCP request timed out",
596            );
597            let response = McpResponse::error(
598                request_id,
599                McpRequestError::QueryExecutionFailed(format!(
600                    "Request timed out after {} seconds.",
601                    request_timeout.as_secs(),
602                ))
603                .into(),
604            );
605            (response, McpCallStatus::Timeout)
606        }
607    };
608
609    record_request(status_label);
610    (StatusCode::OK, Json(response)).into_response()
611}
612
613async fn handle_mcp_request_inner(
614    client: &mut AuthedClient,
615    request: McpRequest,
616    endpoint_type: McpEndpointType,
617    query_tool_enabled: bool,
618    read_data_product_tool_enabled: bool,
619    max_response_size: usize,
620    metrics: McpMetrics,
621) -> (McpResponse, McpCallStatus) {
622    // Extract request ID (guaranteed to be Some since notifications are filtered earlier)
623    let request_id = request.id.clone().unwrap_or(serde_json::Value::Null);
624
625    let result = handle_mcp_method(
626        client,
627        &request,
628        endpoint_type,
629        query_tool_enabled,
630        read_data_product_tool_enabled,
631        max_response_size,
632        &metrics,
633    )
634    .await;
635
636    let status_label = call_status(&result);
637
638    let response = match result {
639        Ok(result_value) => McpResponse::success(request_id, result_value),
640        Err(e) => {
641            // Log non-trivial errors
642            if !matches!(
643                e,
644                McpRequestError::MethodNotFound(_) | McpRequestError::InvalidJsonRpcVersion
645            ) {
646                warn!(error = %e, method = %request.method, "MCP method execution failed");
647            }
648            McpResponse::error(request_id, e.into())
649        }
650    };
651
652    (response, status_label)
653}
654
655async fn handle_mcp_method(
656    client: &mut AuthedClient,
657    request: &McpRequest,
658    endpoint_type: McpEndpointType,
659    query_tool_enabled: bool,
660    read_data_product_tool_enabled: bool,
661    max_response_size: usize,
662    metrics: &McpMetrics,
663) -> Result<McpResult, McpRequestError> {
664    // Validate JSON-RPC version
665    if request.jsonrpc != JSONRPC_VERSION {
666        return Err(McpRequestError::InvalidJsonRpcVersion);
667    }
668
669    // Handle different MCP methods using pattern matching
670    match &request.method {
671        McpMethod::Initialize(_) => {
672            debug!(endpoint = %endpoint_type, "Processing initialize");
673            handle_initialize(
674                endpoint_type,
675                query_tool_enabled,
676                read_data_product_tool_enabled,
677            )
678        }
679        McpMethod::ToolsList(_) => {
680            debug!(endpoint = %endpoint_type, "Processing tools/list");
681            handle_tools_list(
682                endpoint_type,
683                query_tool_enabled,
684                read_data_product_tool_enabled,
685                max_response_size,
686            )
687        }
688        McpMethod::ToolsCall(params) => {
689            debug!(tool = %params, endpoint = %endpoint_type, "Processing tools/call");
690            handle_tools_call(
691                client,
692                params,
693                endpoint_type,
694                query_tool_enabled,
695                read_data_product_tool_enabled,
696                max_response_size,
697                metrics,
698            )
699            .await
700        }
701        McpMethod::Ping(_) | McpMethod::NotificationsInitialized(_) | McpMethod::Unknown => Err(
702            McpRequestError::MethodNotFound("unknown method".to_string()),
703        ),
704    }
705}
706
707/// Instructions returned in the `initialize` response for each endpoint type.
708/// These guide the AI agent on how to use the server correctly.
709fn endpoint_instructions(
710    endpoint_type: McpEndpointType,
711    query_tool_enabled: bool,
712    read_data_product_tool_enabled: bool,
713) -> Option<String> {
714    match endpoint_type {
715        McpEndpointType::Agent => {
716            // Only reference tools that are actually exposed by tools/list.
717            // Both flags off is a valid (if unusual) config where the agent
718            // can only discover products, not read them — we say so instead
719            // of pointing at a hidden tool.
720            let read_paragraph = match (read_data_product_tool_enabled, query_tool_enabled) {
721                (true, _) => {
722                    "`read_data_product` automatically routes the \
723                     read to the cluster recorded in the data product catalog so indexes are used; \
724                     you only need to set the `cluster` parameter if you intentionally want the \
725                     read to run on a different cluster (e.g. one with larger or more replicas). \
726                     A null `cluster` in discovery means your role lacks USAGE on the object's \
727                     index/compute cluster; `read_data_product` without an override then reads on \
728                     your session's default cluster (safe: only materialized views appear this way, \
729                     and they serve from persist). "
730                }
731                (false, true) => {
732                    "Use the `query` tool to read data products, passing the cluster from \
733                     `get_data_product_details` so indexed reads hit the arrangement. If \
734                     `get_data_product_details` returns a null `cluster`, your role lacks USAGE on \
735                     the object's index/compute cluster; run the `query` against any cluster your \
736                     role can use (the read still works, just without the index arrangement). "
737                }
738                (false, false) => {
739                    "This server is configured for discovery only: no read tool is exposed. \
740                     Use `get_data_products` and `get_data_product_details` to inspect what \
741                     is available. "
742                }
743            };
744            Some(format!(
745                "You have access to Materialize data products via MCP. \
746                 Prefer indexed objects (served from memory) over unindexed materialized views \
747                 (read from persistent storage). {read_paragraph}\
748                 `get_data_product_details` returns a `hydration` object with `hydrated`, \
749                 `replica_count`, and `hydrated_replica_count` fields. Reads never return \
750                 partial data: a read against a not-yet-hydrated product blocks until the \
751                 dataflow catches up, and may hit the request timeout. Check `hydrated` \
752                 before reading: if it is false and `replica_count` is greater than 0, the \
753                 dataflow is still warming up, so wait and retry; if `replica_count` is 0 the \
754                 cluster has no replicas and the read cannot make progress until one is added.",
755            ))
756        }
757        McpEndpointType::Developer => {
758            // Only advertise the `query` tool when it is actually exposed:
759            // otherwise the instructions would point agents at a tool that
760            // tools/list does not list.
761            let query_tool_line = if query_tool_enabled {
762                "- 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"
763            } else {
764                ""
765            };
766            // Same reasoning for the introspection rule: only route to the
767            // `query` tool when it is exposed, otherwise say that
768            // cluster-targeted introspection is unavailable here.
769            let introspection_rule = if query_tool_enabled {
770                "- 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"
771            } else {
772                "- 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"
773            };
774            Some(format!(
775                "You are connected to the Materialize developer MCP server for troubleshooting and observability.\n\n\
776                 Tools:\n\
777                 - 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\
778                 {query_tool_line}\n\
779                 IMPORTANT: Before writing queries, discover table schemas using the mz_ontology tables:\n\
780                 - mz_internal.mz_ontology_entity_types: what catalog entities exist and which tables they map to\n\
781                 - mz_internal.mz_ontology_link_types: relationships between entities (foreign keys, metrics, etc.)\n\
782                 - mz_internal.mz_ontology_properties: column names, types, and descriptions for each entity\n\
783                 - mz_internal.mz_ontology_semantic_types: typed ID domains (CatalogItemId, ReplicaId, etc.)\n\n\
784                 Use these to find the correct tables, join paths, and column names instead of guessing.\n\n\
785                 Key rules:\n\
786                 - mz_source_statuses and mz_sink_statuses use `last_status_change_at` (NOT `updated_at`)\n\
787                 - mz_cluster_replica_utilization only has `replica_id` — JOIN with mz_cluster_replicas and mz_clusters to get names\n\
788                 {introspection_rule}\n\
789                 - 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\
790                 - Use SHOW COLUMNS FROM <table> to verify column names if unsure",
791            ))
792        }
793    }
794}
795
796fn handle_initialize(
797    endpoint_type: McpEndpointType,
798    query_tool_enabled: bool,
799    read_data_product_tool_enabled: bool,
800) -> Result<McpResult, McpRequestError> {
801    Ok(McpResult::Initialize(InitializeResult {
802        protocol_version: MCP_PROTOCOL_VERSION.to_string(),
803        capabilities: Capabilities { tools: json!({}) },
804        server_info: ServerInfo {
805            name: format!("materialize-mcp-{}", endpoint_type),
806            version: env!("CARGO_PKG_VERSION").to_string(),
807        },
808        instructions: endpoint_instructions(
809            endpoint_type,
810            query_tool_enabled,
811            read_data_product_tool_enabled,
812        ),
813    }))
814}
815
816fn handle_tools_list(
817    endpoint_type: McpEndpointType,
818    query_tool_enabled: bool,
819    read_data_product_tool_enabled: bool,
820    max_response_size: usize,
821) -> Result<McpResult, McpRequestError> {
822    let size_hint = format!(
823        "Response limit: {:.1} MB.",
824        f64::cast_lossy(max_response_size) / 1_000_000.0
825    );
826
827    let tools = match endpoint_type {
828        McpEndpointType::Agent => {
829            let mut tools = vec![
830                ToolDefinition {
831                    name: "get_data_products".to_string(),
832                    title: Some("List Data Products".to_string()),
833                    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(),
834                    input_schema: json!({
835                        "type": "object",
836                        "properties": {},
837                        "required": []
838                    }),
839                    annotations: Some(READ_ONLY_ANNOTATIONS),
840                },
841                ToolDefinition {
842                    name: "get_data_product_details".to_string(),
843                    title: Some("Get Data Product Details".to_string()),
844                    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(),
845                    input_schema: json!({
846                        "type": "object",
847                        "properties": {
848                            "name": {
849                                "type": "string",
850                                "description": "Exact name of the data product from get_data_products() list"
851                            }
852                        },
853                        "required": ["name"]
854                    }),
855                    annotations: Some(READ_ONLY_ANNOTATIONS),
856                },
857            ];
858            if read_data_product_tool_enabled {
859                tools.push(ToolDefinition {
860                    name: "read_data_product".to_string(),
861                    title: Some("Read Data Product".to_string()),
862                    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}"),
863                    input_schema: json!({
864                        "type": "object",
865                        "properties": {
866                            "name": {
867                                "type": "string",
868                                "description": "Exact fully-qualified name of the data product (e.g. '\"materialize\".\"schema\".\"view_name\"')"
869                            },
870                            "limit": {
871                                "type": "integer",
872                                "description": format!("Maximum number of rows to return (default {DEFAULT_READ_LIMIT})"),
873                                "default": DEFAULT_READ_LIMIT
874                            },
875                            "cluster": {
876                                "type": "string",
877                                "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."
878                            }
879                        },
880                        "required": ["name"]
881                    }),
882                    annotations: Some(READ_ONLY_ANNOTATIONS),
883                });
884            }
885            if query_tool_enabled {
886                tools.push(ToolDefinition {
887                    name: "query".to_string(),
888                    title: Some("Query Data Products".to_string()),
889                    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}"),
890                    input_schema: json!({
891                        "type": "object",
892                        "properties": {
893                            "cluster": {
894                                "type": "string",
895                                "description": "Exact cluster name from the data product details - required for query execution"
896                            },
897                            "sql_query": {
898                                "type": "string",
899                                "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."
900                            }
901                        },
902                        "required": ["cluster", "sql_query"]
903                    }),
904                    annotations: Some(READ_ONLY_ANNOTATIONS),
905                });
906            }
907            tools
908        }
909        McpEndpointType::Developer => {
910            let mut tools = vec![ToolDefinition {
911                name: "query_system_catalog".to_string(),
912                title: Some("Query System Catalog".to_string()),
913                description: concat!(
914                    "Query Materialize system catalog tables for troubleshooting and observability. ",
915                    "Only mz_*, pg_catalog, and information_schema tables are accessible. ",
916                    "Use the mz_internal.mz_ontology_* tables to discover tables, columns, and join paths before writing queries.",
917                ).to_owned() + &format!(" {size_hint}"),
918                input_schema: json!({
919                    "type": "object",
920                    "properties": {
921                        "sql_query": {
922                            "type": "string",
923                            "description": "PostgreSQL-compatible SELECT, SHOW, or EXPLAIN query referencing mz_*, pg_catalog, or information_schema tables"
924                        }
925                    },
926                    "required": ["sql_query"]
927                }),
928                annotations: Some(READ_ONLY_ANNOTATIONS),
929            }];
930            if query_tool_enabled {
931                tools.push(ToolDefinition {
932                    name: "query".to_string(),
933                    title: Some("Query".to_string()),
934                    description: format!(
935                        "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}",
936                    ),
937                    input_schema: json!({
938                        "type": "object",
939                        "properties": {
940                            "cluster": {
941                                "type": "string",
942                                "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."
943                            },
944                            "cluster_replica": {
945                                "type": "string",
946                                "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."
947                            },
948                            "sql_query": {
949                                "type": "string",
950                                "description": "PostgreSQL-compatible SELECT, SHOW, or EXPLAIN statement. Multi-statement queries are rejected."
951                            }
952                        },
953                        "required": ["cluster", "sql_query"]
954                    }),
955                    annotations: Some(READ_ONLY_ANNOTATIONS),
956                });
957            }
958            tools
959        }
960    };
961
962    Ok(McpResult::ToolsList(ToolsListResult { tools }))
963}
964
965async fn handle_tools_call(
966    client: &mut AuthedClient,
967    params: &ToolsCallParams,
968    endpoint_type: McpEndpointType,
969    query_tool_enabled: bool,
970    read_data_product_tool_enabled: bool,
971    max_response_size: usize,
972    metrics: &McpMetrics,
973) -> Result<McpResult, McpRequestError> {
974    // Drop-recording so metrics survive task cancellation.
975    let mut guard = ToolCallGuard::new(metrics, endpoint_type.as_label(), params.to_string());
976
977    let result = match (endpoint_type, params) {
978        (McpEndpointType::Agent, ToolsCallParams::GetDataProducts(_)) => {
979            get_data_products(client, max_response_size).await
980        }
981        (McpEndpointType::Agent, ToolsCallParams::GetDataProductDetails(p)) => {
982            get_data_product_details(client, &p.name, max_response_size).await
983        }
984        (McpEndpointType::Agent, ToolsCallParams::ReadDataProduct(_))
985            if !read_data_product_tool_enabled =>
986        {
987            Err(McpRequestError::ToolNotFound(
988                "read_data_product tool is not available. Use the query tool to read data products."
989                    .to_string(),
990            ))
991        }
992        (McpEndpointType::Agent, ToolsCallParams::ReadDataProduct(p)) => {
993            read_data_product(
994                client,
995                &p.name,
996                p.limit,
997                p.cluster.as_deref(),
998                max_response_size,
999            )
1000            .await
1001        }
1002        (McpEndpointType::Agent, ToolsCallParams::Query(_)) if !query_tool_enabled => {
1003            Err(McpRequestError::ToolNotFound(
1004                "query tool is not available. Use get_data_products, get_data_product_details, and read_data_product instead.".to_string(),
1005            ))
1006        }
1007        (McpEndpointType::Agent, ToolsCallParams::Query(p)) => {
1008            // Replica pinning is deliberately not part of the agent surface:
1009            // drop `cluster_replica` even if a client supplies it.
1010            execute_query(client, &p.cluster, None, &p.sql_query, max_response_size).await
1011        }
1012        (McpEndpointType::Developer, ToolsCallParams::QuerySystemCatalog(p)) => {
1013            query_system_catalog(client, &p.sql_query, max_response_size).await
1014        }
1015        (McpEndpointType::Developer, ToolsCallParams::Query(_)) if !query_tool_enabled => {
1016            Err(McpRequestError::ToolNotFound(
1017                "query tool is not available. Use query_system_catalog instead.".to_string(),
1018            ))
1019        }
1020        (McpEndpointType::Developer, ToolsCallParams::Query(p)) => {
1021            execute_query(
1022                client,
1023                &p.cluster,
1024                p.cluster_replica.as_deref(),
1025                &p.sql_query,
1026                max_response_size,
1027            )
1028            .await
1029        }
1030        // Tool called on wrong endpoint
1031        (endpoint, tool) => Err(McpRequestError::ToolNotFound(format!(
1032            "{} is not available on {} endpoint",
1033            tool, endpoint
1034        ))),
1035    };
1036
1037    guard.set_status(call_status(&result));
1038
1039    result
1040}
1041
1042/// Maps a handler result to its metric [`McpCallStatus`]. Errors carry the
1043/// closed `error_type()` label; anything else is `Ok`.
1044fn call_status<T>(result: &Result<T, McpRequestError>) -> McpCallStatus {
1045    match result {
1046        Ok(_) => McpCallStatus::Ok,
1047        Err(e) => McpCallStatus::Error(e.error_type()),
1048    }
1049}
1050
1051/// Execute SQL via `execute_request` from sql.rs.
1052async fn execute_sql(
1053    client: &mut AuthedClient,
1054    query: &str,
1055) -> Result<Vec<Box<serde_json::value::RawValue>>, McpRequestError> {
1056    let mut response = SqlResponse::new();
1057
1058    execute_request(
1059        client,
1060        SqlRequest::Simple {
1061            query: mz_ore::sql::Sql::trusted_external_request(query.to_string()),
1062        },
1063        &mut response,
1064    )
1065    .await
1066    .map_err(|e| McpRequestError::QueryExecutionFailed(e.to_string()))?;
1067
1068    select_single_rows(response.results)
1069}
1070
1071/// Returns the rows of the single row-returning statement in a response.
1072///
1073/// A read's framing statements (`BEGIN`, `SET`, `COMMIT`) report `Ok`, so only
1074/// the user's statement returns rows. Surfaces the first error, and a second
1075/// row-returning statement is an error rather than a dropped result.
1076fn select_single_rows(
1077    results: Vec<SqlResult>,
1078) -> Result<Vec<Box<serde_json::value::RawValue>>, McpRequestError> {
1079    let mut rows = None;
1080    for result in results {
1081        match result {
1082            SqlResult::Rows { rows: r, .. } => {
1083                if rows.is_some() {
1084                    return Err(McpRequestError::Internal(anyhow!(
1085                        "MCP query returned multiple row-producing statements"
1086                    )));
1087                }
1088                rows = Some(r);
1089            }
1090            SqlResult::Err { error, .. } => {
1091                return Err(McpRequestError::QueryExecutionFailed(error.message));
1092            }
1093            SqlResult::Ok { .. } => continue,
1094        }
1095    }
1096
1097    rows.ok_or_else(|| {
1098        McpRequestError::QueryExecutionFailed("Query did not return any results".to_string())
1099    })
1100}
1101
1102/// Serialize rows to JSON and enforce the response size cap.
1103///
1104/// If the serialized response exceeds `max_size` bytes, returns an error
1105/// telling the agent to narrow its query. This mirrors how the HTTP SQL
1106/// endpoint handles `max_result_size` in sql.rs — fail cleanly rather
1107/// than silently truncating.
1108fn format_rows_response(
1109    rows: Vec<Box<serde_json::value::RawValue>>,
1110    max_size: usize,
1111) -> Result<McpResult, McpRequestError> {
1112    // Each row is already-serialized compact JSON. `RawValue` re-serializes
1113    // verbatim, so the outer array is pretty-printed while rows stay compact.
1114    let text =
1115        serde_json::to_string_pretty(&rows).map_err(|e| McpRequestError::Internal(anyhow!(e)))?;
1116
1117    if text.len() > max_size {
1118        return Err(McpRequestError::QueryExecutionFailed(format!(
1119            "Response size ({} bytes) exceeds the {} byte limit. \
1120             Use LIMIT or WHERE to narrow your query.",
1121            text.len(),
1122            max_size,
1123        )));
1124    }
1125
1126    Ok(McpResult::ToolContent(ToolContentResult {
1127        content: vec![ContentBlock {
1128            content_type: "text".to_string(),
1129            text,
1130        }],
1131        is_error: false,
1132    }))
1133}
1134
1135async fn get_data_products(
1136    client: &mut AuthedClient,
1137    max_response_size: usize,
1138) -> Result<McpResult, McpRequestError> {
1139    debug!("Executing get_data_products");
1140    let rows = execute_sql(client, DISCOVERY_QUERY).await?;
1141    debug!("get_data_products returned {} rows", rows.len());
1142
1143    format_rows_response(rows, max_response_size)
1144}
1145
1146async fn get_data_product_details(
1147    client: &mut AuthedClient,
1148    name: &str,
1149    max_response_size: usize,
1150) -> Result<McpResult, McpRequestError> {
1151    debug!(name = %name, "Executing get_data_product_details");
1152
1153    let query = format!("{}{}", DETAILS_QUERY_PREFIX, escaped_string_literal(name));
1154
1155    let rows = execute_sql(client, &query).await?;
1156
1157    if rows.is_empty() {
1158        return Err(McpRequestError::DataProductNotFound(name.to_string()));
1159    }
1160
1161    format_rows_response(rows, max_response_size)
1162}
1163
1164/// Parses a data product name and returns it safely quoted for SQL interpolation.
1165///
1166/// Uses the SQL parser to validate the name as an `UnresolvedItemName`, then
1167/// formats it with `FormatMode::Stable` so every identifier part is
1168/// double-quoted with proper escaping. This prevents SQL injection regardless
1169/// of the input.
1170fn safe_data_product_name(name: &str) -> Result<String, McpRequestError> {
1171    let name = name.trim();
1172    if name.is_empty() {
1173        return Err(McpRequestError::QueryValidationFailed(
1174            "Data product name cannot be empty".to_string(),
1175        ));
1176    }
1177
1178    // `parse_item_name_with_limit` enforces the 1 MB guard on the raw input
1179    // (DEX-64) before lexing.
1180    let parsed = parse_item_name_with_limit(name)
1181        .map_err(McpRequestError::QueryValidationFailed)?
1182        .map_err(|_| {
1183            McpRequestError::QueryValidationFailed(format!(
1184                "Invalid data product name: {}. Expected a valid object name, \
1185                 e.g. '\"database\".\"schema\".\"name\"' or 'my_view'",
1186                name
1187            ))
1188        })?;
1189
1190    // Stable formatting forces all identifiers to be double-quoted,
1191    // so SQL keywords and special characters cannot escape.
1192    Ok(parsed.to_ast_string_stable())
1193}
1194
1195/// Read rows from a data product. Issues a single read-only query.
1196///
1197/// The read routes to the cluster recorded in `mz_mcp_data_products.cluster`
1198/// when the role has USAGE on it, so reads of indexed objects hit the index's
1199/// in-memory arrangement. That column is null when the role lacks USAGE on the
1200/// object's cluster (DEX-66); in that case, and absent an override, the read
1201/// runs on the session's default (serving) cluster instead. Only materialized
1202/// views can appear this way, since plain views without at least one usable
1203/// index cluster are excluded from `mz_mcp_data_products` entirely; the
1204/// fallback is therefore safe (materialized views serve from persist without
1205/// recompute).
1206///
1207/// `cluster_override` forces the read onto a named cluster instead — useful for
1208/// a differently-sized or differently-replicated cluster.
1209///
1210/// The name is expected to come from `get_data_products()` /
1211/// `get_data_product_details()`. The query runs inside a READ ONLY
1212/// transaction, preventing mutations.
1213async fn read_data_product(
1214    client: &mut AuthedClient,
1215    name: &str,
1216    limit: u32,
1217    cluster_override: Option<&str>,
1218    max_response_size: usize,
1219) -> Result<McpResult, McpRequestError> {
1220    debug!(name = %name, limit = limit, cluster_override = ?cluster_override, "Executing read_data_product");
1221
1222    // Parse and safely quote the name for SQL interpolation.
1223    let safe_name = safe_data_product_name(name)?;
1224
1225    // Existence check and cluster routing. `mz_mcp_data_products.cluster` is
1226    // non-null only when the role has USAGE on that cluster, so any value here
1227    // is safe to route to. `NULLS LAST` prefers such a usable cluster when an
1228    // object appears both with a usable cluster and as null (an object indexed
1229    // on several clusters, some of which the role cannot use).
1230    let lookup_query = format!(
1231        "SELECT dp.cluster FROM mz_internal.mz_mcp_data_products dp \
1232         WHERE dp.object_name = {} \
1233         ORDER BY dp.cluster NULLS LAST \
1234         LIMIT 1",
1235        escaped_string_literal(name)
1236    );
1237    let lookup_rows = execute_sql(client, &lookup_query).await?;
1238    if lookup_rows.is_empty() {
1239        return Err(McpRequestError::DataProductNotFound(name.to_string()));
1240    }
1241    // Rows are stored as pre-serialized JSON arrays, so parse the single-cell
1242    // lookup row to read `dp.cluster`.
1243    let catalog_cluster: Option<String> = lookup_rows
1244        .first()
1245        .and_then(|row| serde_json::from_str::<Vec<serde_json::Value>>(row.get()).ok())
1246        .and_then(|row| row.into_iter().next())
1247        .and_then(|v| v.as_str().map(|s| s.to_string()));
1248
1249    // An override wins. Otherwise route to the catalog cluster when the role
1250    // can use it (non-null); when it is null the role lacks USAGE on the
1251    // object's cluster, so leave the cluster unset and read on the session's
1252    // default (serving) cluster. That still works: materialized views serve
1253    // from persist and views recompute, just without index benefit.
1254    let target_cluster: Option<&str> = cluster_override.or(catalog_cluster.as_deref());
1255
1256    // No row cap is applied here: the response is bounded by the size cap
1257    // enforced in format_rows_response (MCP_MAX_RESPONSE_SIZE), and by
1258    // max_result_size at the adapter layer. Mirrors the SQL HTTP endpoint,
1259    // which also leans on a size cap rather than a row cap.
1260    let read_query = build_read_query(&safe_name, limit, target_cluster);
1261
1262    let rows = execute_sql(client, &read_query).await?;
1263
1264    format_rows_response(rows, max_response_size)
1265}
1266
1267/// Builds the SQL the agent runs for `read_data_product`.
1268///
1269/// `safe_name` must already be the validated, quoted form produced by
1270/// [`safe_data_product_name`]. When `target_cluster` is `Some`, it is escaped
1271/// as a SQL string literal and wrapped in `SET CLUSTER` inside the `BEGIN READ
1272/// ONLY` transaction so the cluster choice is scoped to this read and does not
1273/// leak into the session. When it is `None`, no `SET CLUSTER` is emitted and
1274/// the read runs on the session's default (serving) cluster.
1275fn build_read_query(safe_name: &str, limit: u32, target_cluster: Option<&str>) -> String {
1276    let body = format!("SELECT * FROM {safe_name} LIMIT {limit}");
1277    match target_cluster {
1278        Some(cluster) => read_only_txn(
1279            &format!("SET CLUSTER = {}", escaped_string_literal(cluster)),
1280            &body,
1281        ),
1282        None => format!("BEGIN READ ONLY; {body}\n; COMMIT;"),
1283    }
1284}
1285
1286/// Wraps `body` in a `BEGIN READ ONLY; <set_clause>; <body>; COMMIT;` frame so
1287/// `set_clause` is scoped to this read and does not leak into the session.
1288///
1289/// NOTE: the newline before `; COMMIT;` stops a trailing `--` comment in `body`
1290/// from swallowing the `COMMIT`.
1291fn read_only_txn(set_clause: &str, body: &str) -> String {
1292    format!("BEGIN READ ONLY; {set_clause}; {body}\n; COMMIT;")
1293}
1294
1295/// Validates query is a single SELECT, SHOW, or EXPLAIN statement.
1296fn validate_readonly_query(sql: &str) -> Result<(), McpRequestError> {
1297    let sql = sql.trim();
1298    if sql.is_empty() {
1299        return Err(McpRequestError::QueryValidationFailed(
1300            "Empty query".to_string(),
1301        ));
1302    }
1303
1304    // Parse the SQL to get AST. `parse_with_limit` rejects inputs larger
1305    // than `MAX_STATEMENT_BATCH_SIZE` before lexing so the MCP endpoint
1306    // enforces the same 1 MB guard as the SQL HTTP path (DEX-64).
1307    let stmts = parse_with_limit(sql)
1308        .map_err(McpRequestError::QueryValidationFailed)?
1309        .map_err(|e| {
1310            McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
1311        })?;
1312
1313    // Only allow a single statement
1314    if stmts.len() != 1 {
1315        return Err(McpRequestError::QueryValidationFailed(format!(
1316            "Only one query allowed at a time. Found {} statements.",
1317            stmts.len()
1318        )));
1319    }
1320
1321    // Allowlist: SELECT, SHOW, and every read-only EXPLAIN variant. EXPLAIN
1322    // expands to six distinct Statement variants in the parser (ExplainPlan
1323    // covers only the most common one). Listing them out exhaustively beats
1324    // matching by string prefix so a new write-capable EXPLAIN variant — were
1325    // one ever added — would have to be considered here.
1326    let stmt = &stmts[0];
1327    use mz_sql_parser::ast::Statement;
1328
1329    match &stmt.ast {
1330        Statement::Select(_)
1331        | Statement::Show(_)
1332        | Statement::ExplainPlan(_)
1333        | Statement::ExplainPushdown(_)
1334        | Statement::ExplainTimestamp(_)
1335        | Statement::ExplainSinkSchema(_)
1336        | Statement::ExplainAnalyzeObject(_)
1337        | Statement::ExplainAnalyzeCluster(_) => Ok(()),
1338        _ => Err(McpRequestError::QueryValidationFailed(
1339            "Only SELECT, SHOW, and EXPLAIN statements are allowed".to_string(),
1340        )),
1341    }
1342}
1343
1344async fn execute_query(
1345    client: &mut AuthedClient,
1346    cluster: &str,
1347    cluster_replica: Option<&str>,
1348    sql_query: &str,
1349    max_response_size: usize,
1350) -> Result<McpResult, McpRequestError> {
1351    debug!(cluster = %cluster, cluster_replica = ?cluster_replica, "Executing user query");
1352
1353    validate_readonly_query(sql_query)?;
1354    validate_cluster_replica(cluster_replica)?;
1355
1356    // READ ONLY prevents mutations; SET CLUSTER (and, when requested,
1357    // SET CLUSTER_REPLICA) scope the placement to this read.
1358    let combined_query = read_only_txn(&query_set_clause(cluster, cluster_replica), sql_query);
1359
1360    let rows = execute_sql(client, &combined_query).await?;
1361
1362    format_rows_response(rows, max_response_size)
1363}
1364
1365/// Builds the `SET` clause for `execute_query`: always `SET CLUSTER`, plus
1366/// `SET CLUSTER_REPLICA` when a replica is requested (e.g. for
1367/// `EXPLAIN ANALYZE` on a cluster with multiple replicas). Both names pass
1368/// through `escaped_string_literal` since they are interpolated into SQL
1369/// string literals.
1370fn query_set_clause(cluster: &str, cluster_replica: Option<&str>) -> String {
1371    let mut set_clause = format!("SET CLUSTER = {}", escaped_string_literal(cluster));
1372    if let Some(replica) = cluster_replica {
1373        set_clause.push_str(&format!(
1374            "; SET CLUSTER_REPLICA = {}",
1375            escaped_string_literal(replica)
1376        ));
1377    }
1378    set_clause
1379}
1380
1381/// Rejects an empty or whitespace-only `cluster_replica`. Such a name would
1382/// otherwise produce `SET CLUSTER_REPLICA = ''`, which fails deep in the engine
1383/// as a generic execution error rather than a clean validation error. `None`
1384/// (no replica pin requested) is always valid.
1385fn validate_cluster_replica(cluster_replica: Option<&str>) -> Result<(), McpRequestError> {
1386    if let Some(replica) = cluster_replica {
1387        if replica.trim().is_empty() {
1388            return Err(McpRequestError::QueryValidationFailed(
1389                "cluster_replica must not be empty or whitespace-only".to_string(),
1390            ));
1391        }
1392    }
1393    Ok(())
1394}
1395
1396async fn query_system_catalog(
1397    client: &mut AuthedClient,
1398    sql_query: &str,
1399    max_response_size: usize,
1400) -> Result<McpResult, McpRequestError> {
1401    debug!("Executing query_system_catalog");
1402
1403    // First validate it's a read-only query
1404    validate_readonly_query(sql_query)?;
1405
1406    // Then validate that query only references mz_* tables by parsing the SQL
1407    validate_system_catalog_query(sql_query)?;
1408
1409    // Wrap the query in a READ ONLY transaction with a tight search_path
1410    // restricted to system schemas. This prevents unqualified `mz_*` references
1411    // from resolving to user-created objects (e.g. a view `public.mz_leak`) via
1412    // the session's search_path (mirrors the `BEGIN READ ONLY; SET ...` pattern
1413    // used by the agent `query` tool).
1414    let combined_query = read_only_txn(
1415        "SET search_path = mz_catalog, mz_internal, pg_catalog, information_schema",
1416        sql_query,
1417    );
1418
1419    let rows = execute_sql(client, &combined_query).await?;
1420
1421    format_rows_response(rows, max_response_size)
1422}
1423
1424/// Collects table references from SQL AST with their schema qualification.
1425struct TableReferenceCollector {
1426    /// Stores (schema, table_name) tuples. Schema is None if unqualified.
1427    tables: Vec<(Option<String>, String)>,
1428    /// CTE names to exclude from validation (they're not real tables)
1429    cte_names: std::collections::BTreeSet<String>,
1430}
1431
1432impl TableReferenceCollector {
1433    fn new() -> Self {
1434        Self {
1435            tables: Vec::new(),
1436            cte_names: std::collections::BTreeSet::new(),
1437        }
1438    }
1439}
1440
1441impl<'ast> Visit<'ast, Raw> for TableReferenceCollector {
1442    fn visit_cte(&mut self, cte: &'ast mz_sql_parser::ast::Cte<Raw>) {
1443        // Track CTE names so we don't treat them as table references
1444        self.cte_names
1445            .insert(cte.alias.name.as_str().to_lowercase());
1446        visit::visit_cte(self, cte);
1447    }
1448
1449    fn visit_table_factor(&mut self, table_factor: &'ast mz_sql_parser::ast::TableFactor<Raw>) {
1450        // Only visit actual table references in FROM/JOIN clauses, not function names
1451        if let mz_sql_parser::ast::TableFactor::Table { name, .. } = table_factor {
1452            match name {
1453                RawItemName::Name(n) | RawItemName::Id(_, n, _) => {
1454                    let parts = &n.0;
1455                    if !parts.is_empty() {
1456                        let table_name = parts.last().unwrap().as_str().to_lowercase();
1457
1458                        // Skip if this is a CTE reference, not a real table
1459                        if self.cte_names.contains(&table_name) {
1460                            visit::visit_table_factor(self, table_factor);
1461                            return;
1462                        }
1463
1464                        // Extract schema if qualified (e.g., mz_catalog.mz_tables)
1465                        let schema = if parts.len() >= 2 {
1466                            Some(parts[parts.len() - 2].as_str().to_lowercase())
1467                        } else {
1468                            None
1469                        };
1470                        self.tables.push((schema, table_name));
1471                    }
1472                }
1473            }
1474        }
1475        visit::visit_table_factor(self, table_factor);
1476    }
1477}
1478
1479/// Validates that a query only references system catalog tables.
1480///
1481/// For SELECT statements, all table references must be in system schemas
1482/// (from `SYSTEM_SCHEMAS`, excluding `mz_unsafe`), and at least one system
1483/// table must be referenced (constant queries like `SELECT 1` are rejected
1484/// to prevent misuse of the developer endpoint for arbitrary computation).
1485/// SHOW and EXPLAIN statements are allowed without table references.
1486fn validate_system_catalog_query(sql: &str) -> Result<(), McpRequestError> {
1487    // Parse the SQL to validate it. `parse_with_limit` enforces the 1 MB
1488    // guard shared with the SQL HTTP path (DEX-64).
1489    let stmts = parse_with_limit(sql)
1490        .map_err(McpRequestError::QueryValidationFailed)?
1491        .map_err(|e| {
1492            McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
1493        })?;
1494
1495    if stmts.is_empty() {
1496        return Err(McpRequestError::QueryValidationFailed(
1497            "Empty query".to_string(),
1498        ));
1499    }
1500
1501    // Walk the AST to collect all table references
1502    let mut collector = TableReferenceCollector::new();
1503    for stmt in &stmts {
1504        collector.visit_statement(&stmt.ast);
1505    }
1506
1507    // Use the canonical system schema list, excluding mz_unsafe which contains
1508    // internal-only objects that should not be exposed to MCP clients.
1509    let is_allowed_schema =
1510        |s: &str| SYSTEM_SCHEMAS.contains(&s) && s != namespaces::MZ_UNSAFE_SCHEMA;
1511
1512    // Helper to check if a table reference is allowed. Unqualified references
1513    // are accepted when they carry an unambiguous system prefix (`mz_`/`pg_`);
1514    // execution pins `search_path` to system schemas (see `query_system_catalog`),
1515    // so a user view like `public.mz_leak` cannot be reached by an unqualified name.
1516    let is_system_table = |(schema, table_name): &(Option<String>, String)| match schema {
1517        Some(s) => is_allowed_schema(s.as_str()),
1518        None => table_name.starts_with("mz_") || table_name.starts_with("pg_"),
1519    };
1520
1521    // Check that all table references are system tables
1522    let non_system_tables: Vec<String> = collector
1523        .tables
1524        .iter()
1525        .filter(|t| !is_system_table(t))
1526        .map(|(schema, table)| match schema {
1527            Some(s) => format!("{}.{}", s, table),
1528            None => table.clone(),
1529        })
1530        .collect();
1531
1532    if !non_system_tables.is_empty() {
1533        return Err(McpRequestError::QueryValidationFailed(format!(
1534            "Query references non-system tables: {}. Only system catalog tables (mz_*, pg_catalog, information_schema) are allowed.",
1535            non_system_tables.join(", ")
1536        )));
1537    }
1538
1539    // SHOW and EXPLAIN statements don't reference tables in the AST, but are safe
1540    // read-only operations. Only require system table references for SELECT.
1541    use mz_sql_parser::ast::Statement;
1542    let is_select = stmts.iter().any(|s| matches!(&s.ast, Statement::Select(_)));
1543
1544    if is_select && (collector.tables.is_empty() || !collector.tables.iter().any(is_system_table)) {
1545        return Err(McpRequestError::QueryValidationFailed(
1546            "Query must reference at least one system catalog table".to_string(),
1547        ));
1548    }
1549
1550    Ok(())
1551}
1552
1553#[cfg(test)]
1554mod tests {
1555    use super::*;
1556    use crate::http::sql::{Description, SqlError};
1557
1558    /// Serializes each row to a compact JSON array, matching how the HTTP SQL
1559    /// layer stores rows.
1560    fn raw_rows(rows: Vec<Vec<serde_json::Value>>) -> Vec<Box<serde_json::value::RawValue>> {
1561        rows.iter()
1562            .map(|r| serde_json::value::to_raw_value(r).unwrap())
1563            .collect()
1564    }
1565
1566    fn rows_result(rows: Vec<Vec<serde_json::Value>>) -> SqlResult {
1567        SqlResult::Rows {
1568            tag: String::new(),
1569            rows: raw_rows(rows),
1570            desc: Description { columns: vec![] },
1571            notices: vec![],
1572        }
1573    }
1574
1575    fn ok_result() -> SqlResult {
1576        SqlResult::Ok {
1577            ok: String::new(),
1578            notices: vec![],
1579            parameters: vec![],
1580        }
1581    }
1582
1583    fn err_result(message: &str) -> SqlResult {
1584        SqlResult::Err {
1585            error: SqlError {
1586                message: message.to_string(),
1587                code: String::new(),
1588                detail: None,
1589                hint: None,
1590                position: None,
1591            },
1592            notices: vec![],
1593        }
1594    }
1595
1596    /// The row-returning statement's rows are returned, ignoring the `Ok`
1597    /// framing statements around it.
1598    #[mz_ore::test]
1599    fn test_select_single_rows_extracts_rows() {
1600        let rows = vec![vec![serde_json::json!(1)]];
1601        let results = vec![
1602            ok_result(),
1603            ok_result(),
1604            rows_result(rows.clone()),
1605            ok_result(),
1606        ];
1607        let got = select_single_rows(results).unwrap();
1608        let got: Vec<&str> = got.iter().map(|r| r.get()).collect();
1609        assert_eq!(got, vec!["[1]"]);
1610    }
1611
1612    /// A response with no row-returning statement is an error.
1613    #[mz_ore::test]
1614    fn test_select_single_rows_requires_rows() {
1615        let err = select_single_rows(vec![ok_result(), ok_result()]).unwrap_err();
1616        assert!(
1617            matches!(err, McpRequestError::QueryExecutionFailed(_)),
1618            "{err:?}"
1619        );
1620    }
1621
1622    /// The invariant is enforced: a second row-returning statement is an
1623    /// internal error rather than a silently dropped result.
1624    #[mz_ore::test]
1625    fn test_select_single_rows_rejects_multiple() {
1626        let results = vec![rows_result(vec![]), rows_result(vec![])];
1627        let err = select_single_rows(results).unwrap_err();
1628        assert!(matches!(err, McpRequestError::Internal(_)), "{err:?}");
1629    }
1630
1631    /// A statement error is surfaced.
1632    #[mz_ore::test]
1633    fn test_select_single_rows_surfaces_error() {
1634        let err = select_single_rows(vec![ok_result(), err_result("boom")]).unwrap_err();
1635        match err {
1636            McpRequestError::QueryExecutionFailed(msg) => assert_eq!(msg, "boom"),
1637            other => panic!("unexpected error: {other:?}"),
1638        }
1639    }
1640
1641    /// The DNS-rebinding defense: a disallowed `Origin` is rejected with 403,
1642    /// an allowed one passes, and a missing one passes (non-browser clients).
1643    #[mz_ore::test]
1644    fn test_validate_origin() {
1645        let allowed = [HeaderValue::from_static("https://good.example")];
1646
1647        assert!(validate_origin(&HeaderMap::new(), &allowed).is_none());
1648
1649        let mut ok = HeaderMap::new();
1650        ok.insert(http::header::ORIGIN, allowed[0].clone());
1651        assert!(validate_origin(&ok, &allowed).is_none());
1652
1653        let mut bad = HeaderMap::new();
1654        bad.insert(
1655            http::header::ORIGIN,
1656            HeaderValue::from_static("https://evil.example"),
1657        );
1658        let rejected = validate_origin(&bad, &allowed);
1659        assert_eq!(
1660            rejected
1661                .expect("disallowed origin must be rejected")
1662                .status(),
1663            StatusCode::FORBIDDEN,
1664        );
1665    }
1666
1667    /// The two constructors set the JSON-RPC version and put the payload in
1668    /// the right one of the mutually-exclusive `result` / `error` fields.
1669    #[mz_ore::test]
1670    fn test_mcp_response_constructors() {
1671        let id = serde_json::json!(1);
1672
1673        let ok = McpResponse::success(
1674            id.clone(),
1675            McpResult::ToolContent(ToolContentResult {
1676                content: vec![],
1677                is_error: false,
1678            }),
1679        );
1680        assert_eq!(ok.jsonrpc, JSONRPC_VERSION);
1681        assert!(ok.result.is_some());
1682        assert!(ok.error.is_none());
1683
1684        let err = McpResponse::error(id, McpRequestError::ToolNotFound("t".to_string()).into());
1685        assert_eq!(err.jsonrpc, JSONRPC_VERSION);
1686        assert!(err.result.is_none());
1687        assert!(err.error.is_some());
1688    }
1689
1690    #[mz_ore::test]
1691    fn test_validate_readonly_query_select() {
1692        assert!(validate_readonly_query("SELECT * FROM mz_tables").is_ok());
1693        assert!(validate_readonly_query("SELECT 1 + 2").is_ok());
1694        assert!(validate_readonly_query("  SELECT 1  ").is_ok());
1695    }
1696
1697    #[mz_ore::test]
1698    fn test_validate_readonly_query_subqueries() {
1699        // Simple subquery in WHERE clause
1700        assert!(
1701            validate_readonly_query(
1702                "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
1703            )
1704            .is_ok()
1705        );
1706
1707        // Subquery in FROM clause
1708        assert!(
1709            validate_readonly_query(
1710                "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
1711            )
1712            .is_ok()
1713        );
1714
1715        // Correlated subquery
1716        assert!(validate_readonly_query(
1717            "SELECT * FROM mz_tables t WHERE EXISTS (SELECT 1 FROM mz_columns c WHERE c.id = t.id)"
1718        )
1719        .is_ok());
1720
1721        // Nested subqueries
1722        assert!(validate_readonly_query(
1723            "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns WHERE type IN (SELECT name FROM mz_types))"
1724        )
1725        .is_ok());
1726
1727        // Subquery with aggregation
1728        assert!(
1729            validate_readonly_query(
1730                "SELECT * FROM mz_tables WHERE id = (SELECT MAX(id) FROM mz_columns)"
1731            )
1732            .is_ok()
1733        );
1734    }
1735
1736    #[mz_ore::test]
1737    fn test_validate_readonly_query_show() {
1738        assert!(validate_readonly_query("SHOW CLUSTERS").is_ok());
1739        assert!(validate_readonly_query("SHOW TABLES").is_ok());
1740    }
1741
1742    #[mz_ore::test]
1743    fn test_validate_readonly_query_explain() {
1744        // Every read-only EXPLAIN variant must be accepted. Each line
1745        // corresponds to a distinct Statement::Explain* arm of the parser; if
1746        // one of them is dropped from the validator the test that lost its
1747        // arm will fail, naming the variant.
1748        assert!(validate_readonly_query("EXPLAIN SELECT 1").is_ok());
1749        assert!(
1750            validate_readonly_query("EXPLAIN FILTER PUSHDOWN FOR SELECT * FROM mz_tables").is_ok()
1751        );
1752        assert!(validate_readonly_query("EXPLAIN TIMESTAMP FOR SELECT 1").is_ok());
1753        assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR INDEX foo").is_ok());
1754        assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR MATERIALIZED VIEW mv").is_ok());
1755        assert!(validate_readonly_query("EXPLAIN ANALYZE CLUSTER MEMORY").is_ok());
1756    }
1757
1758    #[mz_ore::test]
1759    fn test_validate_readonly_query_rejects_writes() {
1760        assert!(validate_readonly_query("INSERT INTO t VALUES (1)").is_err());
1761        assert!(validate_readonly_query("UPDATE t SET a = 1").is_err());
1762        assert!(validate_readonly_query("DELETE FROM t").is_err());
1763        assert!(validate_readonly_query("CREATE TABLE t (a INT)").is_err());
1764        assert!(validate_readonly_query("DROP TABLE t").is_err());
1765    }
1766
1767    #[mz_ore::test]
1768    fn test_validate_readonly_query_rejects_multiple() {
1769        assert!(validate_readonly_query("SELECT 1; SELECT 2").is_err());
1770    }
1771
1772    #[mz_ore::test]
1773    fn test_validate_readonly_query_rejects_empty() {
1774        assert!(validate_readonly_query("").is_err());
1775        assert!(validate_readonly_query("   ").is_err());
1776    }
1777
1778    /// Regression test for DEX-64: without the 1 MB parser guard, the MCP
1779    /// validators would happily lex and parse multi-megabyte input. Both
1780    /// `validate_readonly_query` and `validate_system_catalog_query` now go
1781    /// through `parse_with_limit`, so an oversized batch is rejected with the
1782    /// same "statement batch size cannot exceed" message the SQL HTTP path
1783    /// emits.
1784    #[mz_ore::test]
1785    fn test_validate_readonly_query_enforces_size_limit() {
1786        use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
1787        // Just over the limit so the guard is the *only* thing that rejects.
1788        let oversized: String =
1789            "SELECT 1;".repeat((MAX_STATEMENT_BATCH_SIZE / "SELECT 1;".len()) + 1);
1790        let err = validate_readonly_query(&oversized).expect_err("should be rejected");
1791        let msg = err.to_string();
1792        assert!(
1793            msg.contains("statement batch size cannot exceed"),
1794            "expected size-guard error, got: {msg}"
1795        );
1796    }
1797
1798    #[mz_ore::test]
1799    fn test_validate_system_catalog_query_enforces_size_limit() {
1800        use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
1801        let stmt = "SELECT * FROM mz_tables;";
1802        let oversized: String = stmt.repeat((MAX_STATEMENT_BATCH_SIZE / stmt.len()) + 1);
1803        let err = validate_system_catalog_query(&oversized).expect_err("should be rejected");
1804        let msg = err.to_string();
1805        assert!(
1806            msg.contains("statement batch size cannot exceed"),
1807            "expected size-guard error, got: {msg}"
1808        );
1809    }
1810
1811    #[mz_ore::test]
1812    fn test_validate_system_catalog_query_accepts_mz_tables() {
1813        assert!(validate_system_catalog_query("SELECT * FROM mz_tables").is_ok());
1814        assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_comments").is_ok());
1815        assert!(
1816            validate_system_catalog_query(
1817                "SELECT * FROM mz_tables t JOIN mz_columns c ON t.id = c.id"
1818            )
1819            .is_ok()
1820        );
1821    }
1822
1823    #[mz_ore::test]
1824    fn test_validate_system_catalog_query_subqueries() {
1825        // Subquery with mz_* tables
1826        assert!(
1827            validate_system_catalog_query(
1828                "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
1829            )
1830            .is_ok()
1831        );
1832
1833        // Nested subqueries with mz_* tables
1834        assert!(validate_system_catalog_query(
1835            "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM mz_columns WHERE type IN (SELECT id FROM mz_types))"
1836        )
1837        .is_ok());
1838
1839        // Subquery in FROM clause
1840        assert!(
1841            validate_system_catalog_query(
1842                "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
1843            )
1844            .is_ok()
1845        );
1846
1847        // Reject subqueries that reference non-mz_* tables
1848        assert!(
1849            validate_system_catalog_query(
1850                "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM user_data)"
1851            )
1852            .is_err()
1853        );
1854
1855        // Reject mixed references in nested subqueries
1856        assert!(validate_system_catalog_query(
1857            "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM (SELECT id FROM user_table) AS t)"
1858        )
1859        .is_err());
1860    }
1861
1862    #[mz_ore::test]
1863    fn test_validate_system_catalog_query_rejects_user_tables() {
1864        assert!(validate_system_catalog_query("SELECT * FROM user_data").is_err());
1865        assert!(validate_system_catalog_query("SELECT * FROM my_table").is_err());
1866        // Security: reject queries that mention mz_ in a non-table context
1867        assert!(
1868            validate_system_catalog_query("SELECT * FROM user_data WHERE 'mz_' IS NOT NULL")
1869                .is_err()
1870        );
1871    }
1872
1873    #[mz_ore::test]
1874    fn test_validate_system_catalog_query_allows_functions() {
1875        // Function names should not be treated as table references
1876        assert!(
1877            validate_system_catalog_query(
1878                "SELECT date_part('year', now())::int4 AS y FROM mz_tables LIMIT 1"
1879            )
1880            .is_ok()
1881        );
1882        assert!(validate_system_catalog_query("SELECT length(name) FROM mz_tables").is_ok());
1883        assert!(
1884            validate_system_catalog_query(
1885                "SELECT count(*) FROM mz_sources WHERE now() > created_at"
1886            )
1887            .is_ok()
1888        );
1889    }
1890
1891    #[mz_ore::test]
1892    fn test_validate_system_catalog_query_unqualified_pg_catalog() {
1893        // Unqualified `pg_`-prefixed names are as unambiguously system as `mz_`
1894        // ones, and resolve safely under the pinned search_path.
1895        assert!(validate_system_catalog_query("SELECT * FROM pg_class").is_ok());
1896        assert!(validate_system_catalog_query("SELECT nspname FROM pg_namespace").is_ok());
1897    }
1898
1899    #[mz_ore::test]
1900    fn test_validate_system_catalog_query_schema_qualified() {
1901        // Qualified with allowed schemas should work
1902        assert!(validate_system_catalog_query("SELECT * FROM mz_catalog.mz_tables").is_ok());
1903        assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_sessions").is_ok());
1904        assert!(validate_system_catalog_query("SELECT * FROM pg_catalog.pg_type").is_ok());
1905        assert!(validate_system_catalog_query("SELECT * FROM information_schema.tables").is_ok());
1906
1907        // Qualified with disallowed schema should fail
1908        assert!(validate_system_catalog_query("SELECT * FROM public.user_table").is_err());
1909        assert!(validate_system_catalog_query("SELECT * FROM myschema.mytable").is_err());
1910
1911        // mz_unsafe is a system schema but explicitly blocked for MCP
1912        assert!(
1913            validate_system_catalog_query("SELECT * FROM mz_unsafe.mz_some_table").is_err(),
1914            "mz_unsafe schema should be blocked even though it is a system schema"
1915        );
1916
1917        // Mixed: system and user schemas should fail
1918        assert!(
1919            validate_system_catalog_query(
1920                "SELECT * FROM mz_catalog.mz_tables JOIN public.user_data ON true"
1921            )
1922            .is_err()
1923        );
1924    }
1925
1926    #[mz_ore::test]
1927    fn test_validate_system_catalog_query_adversarial_cases() {
1928        // Try to sneak in user table via CTE
1929        assert!(
1930            validate_system_catalog_query(
1931                "WITH user_cte AS (SELECT * FROM user_data) \
1932                 SELECT * FROM mz_tables, user_cte"
1933            )
1934            .is_err(),
1935            "Should reject CTE referencing user table"
1936        );
1937
1938        // Complex multi-level CTE with user table buried deep
1939        assert!(
1940            validate_system_catalog_query(
1941                "WITH \
1942                   cte1 AS (SELECT * FROM mz_tables), \
1943                   cte2 AS (SELECT * FROM cte1), \
1944                   cte3 AS (SELECT * FROM user_data) \
1945                 SELECT * FROM cte2"
1946            )
1947            .is_err(),
1948            "Should reject CTE chain with user table"
1949        );
1950
1951        // Multiple joins - user table in the middle
1952        assert!(
1953            validate_system_catalog_query(
1954                "SELECT * FROM mz_tables t1 \
1955                 JOIN user_data u ON t1.id = u.id \
1956                 JOIN mz_sources s ON t1.id = s.id"
1957            )
1958            .is_err(),
1959            "Should reject multi-join with user table"
1960        );
1961
1962        // LEFT JOIN trying to hide user table
1963        assert!(
1964            validate_system_catalog_query(
1965                "SELECT * FROM mz_tables t \
1966                 LEFT JOIN user_data u ON t.id = u.table_id \
1967                 WHERE u.id IS NULL"
1968            )
1969            .is_err(),
1970            "Should reject LEFT JOIN with user table"
1971        );
1972
1973        // Nested subquery with user table in FROM
1974        assert!(
1975            validate_system_catalog_query(
1976                "SELECT * FROM mz_tables WHERE id IN \
1977                 (SELECT table_id FROM (SELECT * FROM user_data) AS u)"
1978            )
1979            .is_err(),
1980            "Should reject nested subquery with user table"
1981        );
1982
1983        // UNION trying to mix system and user data
1984        assert!(
1985            validate_system_catalog_query(
1986                "SELECT name FROM mz_tables \
1987                 UNION \
1988                 SELECT name FROM user_data"
1989            )
1990            .is_err(),
1991            "Should reject UNION with user table"
1992        );
1993
1994        // UNION ALL variation
1995        assert!(
1996            validate_system_catalog_query(
1997                "SELECT id FROM mz_sources \
1998                 UNION ALL \
1999                 SELECT id FROM products"
2000            )
2001            .is_err(),
2002            "Should reject UNION ALL with user table"
2003        );
2004
2005        // Cross join with user table
2006        assert!(
2007            validate_system_catalog_query("SELECT * FROM mz_tables CROSS JOIN user_data").is_err(),
2008            "Should reject CROSS JOIN with user table"
2009        );
2010
2011        // Subquery in SELECT clause referencing user table
2012        assert!(
2013            validate_system_catalog_query(
2014                "SELECT t.*, (SELECT COUNT(*) FROM user_data) AS cnt FROM mz_tables t"
2015            )
2016            .is_err(),
2017            "Should reject subquery in SELECT with user table"
2018        );
2019
2020        // Try to use a schema name that looks similar to allowed ones
2021        assert!(
2022            validate_system_catalog_query("SELECT * FROM mz_catalogg.fake_table").is_err(),
2023            "Should reject typo-squatting schema name"
2024        );
2025        assert!(
2026            validate_system_catalog_query("SELECT * FROM mz_catalog_hack.fake_table").is_err(),
2027            "Should reject fake schema with mz_catalog prefix"
2028        );
2029
2030        // Lateral join with user table
2031        assert!(
2032            validate_system_catalog_query(
2033                "SELECT * FROM mz_tables t, LATERAL (SELECT * FROM user_data WHERE id = t.id) u"
2034            )
2035            .is_err(),
2036            "Should reject LATERAL join with user table"
2037        );
2038
2039        // Valid complex query - all system tables
2040        assert!(
2041            validate_system_catalog_query(
2042                "WITH \
2043                   tables AS (SELECT * FROM mz_tables), \
2044                   sources AS (SELECT * FROM mz_sources) \
2045                 SELECT t.name, s.name \
2046                 FROM tables t \
2047                 JOIN sources s ON t.id = s.id \
2048                 WHERE t.id IN (SELECT id FROM mz_columns)"
2049            )
2050            .is_ok(),
2051            "Should allow complex query with only system tables"
2052        );
2053
2054        // Valid UNION of system tables
2055        assert!(
2056            validate_system_catalog_query(
2057                "SELECT name FROM mz_tables \
2058                 UNION \
2059                 SELECT name FROM mz_sources"
2060            )
2061            .is_ok(),
2062            "Should allow UNION of system tables"
2063        );
2064    }
2065
2066    #[mz_ore::test]
2067    fn test_validate_system_catalog_query_rejects_constant_queries() {
2068        // SELECT without any table reference should be rejected — the developer
2069        // endpoint is for system catalog queries, not arbitrary computation.
2070        assert!(
2071            validate_system_catalog_query("SELECT 1").is_err(),
2072            "Should reject constant SELECT with no table references"
2073        );
2074        assert!(
2075            validate_system_catalog_query("SELECT 1 + 2, 'hello'").is_err(),
2076            "Should reject constant expression SELECT"
2077        );
2078        assert!(
2079            validate_system_catalog_query("SELECT now()").is_err(),
2080            "Should reject function-only SELECT with no table references"
2081        );
2082    }
2083
2084    #[mz_ore::test]
2085    fn test_validate_system_catalog_query_rejects_mixed_tables() {
2086        assert!(
2087            validate_system_catalog_query(
2088                "SELECT * FROM mz_tables t JOIN user_data u ON t.id = u.table_id"
2089            )
2090            .is_err()
2091        );
2092    }
2093
2094    #[mz_ore::test]
2095    fn test_validate_system_catalog_query_allows_show() {
2096        // SHOW queries don't reference tables in the AST but are safe read-only ops
2097        assert!(
2098            validate_system_catalog_query("SHOW TABLES FROM mz_internal").is_ok(),
2099            "SHOW TABLES FROM mz_internal should be allowed"
2100        );
2101        assert!(
2102            validate_system_catalog_query("SHOW TABLES FROM mz_catalog").is_ok(),
2103            "SHOW TABLES FROM mz_catalog should be allowed"
2104        );
2105        assert!(
2106            validate_system_catalog_query("SHOW CLUSTERS").is_ok(),
2107            "SHOW CLUSTERS should be allowed"
2108        );
2109        assert!(
2110            validate_system_catalog_query("SHOW SOURCES").is_ok(),
2111            "SHOW SOURCES should be allowed"
2112        );
2113        assert!(
2114            validate_system_catalog_query("SHOW TABLES").is_ok(),
2115            "SHOW TABLES should be allowed"
2116        );
2117    }
2118
2119    #[mz_ore::test]
2120    fn test_validate_system_catalog_query_allows_explain() {
2121        assert!(
2122            validate_system_catalog_query("EXPLAIN SELECT * FROM mz_tables").is_ok(),
2123            "EXPLAIN of system table query should be allowed"
2124        );
2125        assert!(
2126            validate_system_catalog_query("EXPLAIN SELECT 1").is_ok(),
2127            "EXPLAIN SELECT 1 should be allowed"
2128        );
2129    }
2130
2131    // ── Query tool feature flag tests ──────────────────────────────────────
2132
2133    #[mz_ore::test(tokio::test)]
2134    async fn test_tools_list_agent_query_tool_disabled() {
2135        let result = handle_tools_list(McpEndpointType::Agent, false, true, 1_000_000).unwrap();
2136        let McpResult::ToolsList(list) = result else {
2137            panic!("Expected ToolsList result");
2138        };
2139        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2140        assert!(
2141            tool_names.contains(&"get_data_products"),
2142            "get_data_products should always be present"
2143        );
2144        assert!(
2145            tool_names.contains(&"get_data_product_details"),
2146            "get_data_product_details should always be present"
2147        );
2148        assert!(
2149            tool_names.contains(&"read_data_product"),
2150            "read_data_product should be present when its flag is on"
2151        );
2152        assert!(
2153            !tool_names.contains(&"query"),
2154            "query tool should be hidden when disabled"
2155        );
2156    }
2157
2158    #[mz_ore::test(tokio::test)]
2159    async fn test_tools_list_agent_query_tool_enabled() {
2160        let result = handle_tools_list(McpEndpointType::Agent, true, true, 1_000_000).unwrap();
2161        let McpResult::ToolsList(list) = result else {
2162            panic!("Expected ToolsList result");
2163        };
2164        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2165        assert!(
2166            tool_names.contains(&"get_data_products"),
2167            "get_data_products should always be present"
2168        );
2169        assert!(
2170            tool_names.contains(&"get_data_product_details"),
2171            "get_data_product_details should always be present"
2172        );
2173        assert!(
2174            tool_names.contains(&"read_data_product"),
2175            "read_data_product should be present when its flag is on"
2176        );
2177        assert!(
2178            tool_names.contains(&"query"),
2179            "query tool should be present when enabled"
2180        );
2181    }
2182
2183    #[mz_ore::test(tokio::test)]
2184    async fn test_tools_list_agent_read_data_product_tool_disabled() {
2185        let result = handle_tools_list(McpEndpointType::Agent, true, false, 1_000_000).unwrap();
2186        let McpResult::ToolsList(list) = result else {
2187            panic!("Expected ToolsList result");
2188        };
2189        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2190        assert!(
2191            tool_names.contains(&"get_data_products"),
2192            "get_data_products should always be present"
2193        );
2194        assert!(
2195            tool_names.contains(&"get_data_product_details"),
2196            "get_data_product_details should always be present"
2197        );
2198        assert!(
2199            !tool_names.contains(&"read_data_product"),
2200            "read_data_product should be hidden when disabled"
2201        );
2202        assert!(
2203            tool_names.contains(&"query"),
2204            "query tool should remain present when enabled"
2205        );
2206    }
2207
2208    /// Both read tools off is a valid (if unusual) config where the agent
2209    /// is discovery-only. Pin the behavior: only get_data_products and
2210    /// get_data_product_details are advertised, and the initialize
2211    /// instructions do not tell the agent to use a tool that isn't listed.
2212    #[mz_ore::test(tokio::test)]
2213    async fn test_tools_list_agent_both_read_tools_disabled() {
2214        let result = handle_tools_list(McpEndpointType::Agent, false, false, 1_000_000).unwrap();
2215        let McpResult::ToolsList(list) = result else {
2216            panic!("Expected ToolsList result");
2217        };
2218        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2219        assert_eq!(
2220            tool_names
2221                .iter()
2222                .copied()
2223                .collect::<std::collections::BTreeSet<_>>(),
2224            ["get_data_product_details", "get_data_products"]
2225                .into_iter()
2226                .collect(),
2227            "only discovery tools should be advertised when both read flags are off",
2228        );
2229
2230        let instructions = endpoint_instructions(McpEndpointType::Agent, false, false)
2231            .expect("agent instructions must be present");
2232        assert!(
2233            !instructions.contains("Use the `query` tool"),
2234            "instructions must not point at query when it is hidden: {instructions}",
2235        );
2236        assert!(
2237            !instructions.contains("`read_data_product` automatically"),
2238            "instructions must not point at read_data_product when it is hidden: {instructions}",
2239        );
2240        assert!(
2241            instructions.contains("discovery only"),
2242            "instructions must tell the agent it is discovery-only: {instructions}",
2243        );
2244    }
2245
2246    #[mz_ore::test(tokio::test)]
2247    async fn test_tools_list_developer_query_tool_disabled() {
2248        // Developer endpoint doesn't expose read_data_product; the flag is
2249        // orthogonal, so pass whichever value.
2250        let result = handle_tools_list(McpEndpointType::Developer, false, true, 1_000_000).unwrap();
2251        let McpResult::ToolsList(list) = result else {
2252            panic!("Expected ToolsList result");
2253        };
2254        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2255        assert!(
2256            tool_names.contains(&"query_system_catalog"),
2257            "query_system_catalog should always be present on developer"
2258        );
2259        assert!(
2260            !tool_names.contains(&"query"),
2261            "query tool should be hidden when disabled"
2262        );
2263    }
2264
2265    #[mz_ore::test(tokio::test)]
2266    async fn test_tools_list_developer_query_tool_enabled() {
2267        let result = handle_tools_list(McpEndpointType::Developer, true, true, 1_000_000).unwrap();
2268        let McpResult::ToolsList(list) = result else {
2269            panic!("Expected ToolsList result");
2270        };
2271        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2272        assert!(
2273            tool_names.contains(&"query_system_catalog"),
2274            "query_system_catalog should always be present on developer"
2275        );
2276        assert!(
2277            tool_names.contains(&"query"),
2278            "query tool should be present on developer when enabled"
2279        );
2280
2281        let instructions = endpoint_instructions(McpEndpointType::Developer, true, true)
2282            .expect("developer instructions must be present");
2283        assert!(
2284            instructions.contains("through the query tool with its cluster argument"),
2285            "instructions must route mz_introspection through query: {instructions}",
2286        );
2287    }
2288
2289    #[mz_ore::test]
2290    fn test_developer_instructions_query_tool_disabled() {
2291        let instructions = endpoint_instructions(McpEndpointType::Developer, false, true)
2292            .expect("developer instructions must be present");
2293        assert!(
2294            instructions.contains("cluster-targeted introspection is unavailable")
2295                && !instructions.contains("through the query tool"),
2296            "instructions must not route to the hidden query tool: {instructions}",
2297        );
2298    }
2299
2300    /// Clients attach `_meta` to `tools/list`. Rejecting it fails the whole
2301    /// request with a 422, which the client reads as the server being down.
2302    #[mz_ore::test]
2303    fn test_tools_list_accepts_optional_params() {
2304        for body in [
2305            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
2306            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":null}"#,
2307            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#,
2308            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"progressToken":0}}}"#,
2309        ] {
2310            let req: McpRequest = serde_json::from_str(body)
2311                .unwrap_or_else(|e| panic!("tools/list must deserialize: {body}: {e}"));
2312            assert!(
2313                matches!(req.method, McpMethod::ToolsList(_)),
2314                "expected ToolsList for {body}"
2315            );
2316        }
2317    }
2318
2319    /// `ping` and `notifications/initialized` are named variants so their
2320    /// `params` deserialize. A `_meta` on the acknowledgement would otherwise
2321    /// fail the body with a 422 in the middle of the handshake.
2322    #[mz_ore::test]
2323    fn test_named_methods_accept_optional_params() {
2324        for (body, want_ping) in [
2325            (r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#, true),
2326            (
2327                r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}"#,
2328                true,
2329            ),
2330            (
2331                r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{"_meta":{"progressToken":0}}}"#,
2332                true,
2333            ),
2334            (
2335                r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
2336                false,
2337            ),
2338            (
2339                r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{"_meta":{"x":1}}}"#,
2340                false,
2341            ),
2342        ] {
2343            let req: McpRequest = serde_json::from_str(body)
2344                .unwrap_or_else(|e| panic!("must deserialize: {body}: {e}"));
2345            if want_ping {
2346                assert!(matches!(req.method, McpMethod::Ping(_)), "for {body}");
2347            } else {
2348                assert!(
2349                    matches!(req.method, McpMethod::NotificationsInitialized(_)),
2350                    "for {body}"
2351                );
2352            }
2353        }
2354    }
2355
2356    // ── Response size cap tests ────────────────────────────────────────
2357
2358    #[mz_ore::test]
2359    fn test_format_rows_response_within_limit() {
2360        let rows = vec![vec![json!("a"), json!(1)], vec![json!("b"), json!(2)]];
2361        let result = format_rows_response(raw_rows(rows), 1_000_000).unwrap();
2362        let McpResult::ToolContent(content) = result else {
2363            panic!("Expected ToolContent");
2364        };
2365        assert_eq!(content.content.len(), 1);
2366        assert!(content.content[0].text.contains("\"a\""));
2367        assert!(content.content[0].text.contains("\"b\""));
2368    }
2369
2370    #[mz_ore::test]
2371    fn test_format_rows_response_errors_when_over_limit() {
2372        let rows: Vec<Vec<serde_json::Value>> = (0..100)
2373            .map(|i| vec![json!(format!("row_{}", i)), json!(i)])
2374            .collect();
2375        let err = format_rows_response(raw_rows(rows), 500).unwrap_err();
2376        let msg = err.to_string();
2377        assert!(
2378            msg.contains("exceeds the 500 byte limit"),
2379            "Error should mention the size limit, got: {msg}"
2380        );
2381        assert!(
2382            msg.contains("Use LIMIT or WHERE"),
2383            "Error should suggest narrowing the query, got: {msg}"
2384        );
2385    }
2386
2387    #[mz_ore::test]
2388    fn test_format_rows_response_empty_rows() {
2389        let rows: Vec<Vec<serde_json::Value>> = vec![];
2390        let result = format_rows_response(raw_rows(rows), 1000).unwrap();
2391        let McpResult::ToolContent(content) = result else {
2392            panic!("Expected ToolContent");
2393        };
2394        assert_eq!(content.content.len(), 1);
2395        assert_eq!(content.content[0].text, "[]");
2396    }
2397
2398    // ── Data product name validation tests ─────────────────────────────
2399
2400    #[mz_ore::test]
2401    fn test_safe_data_product_name_valid() {
2402        // Fully qualified quoted identifiers
2403        assert_eq!(
2404            safe_data_product_name(r#""materialize"."public"."my_view""#).unwrap(),
2405            r#""materialize"."public"."my_view""#
2406        );
2407        // Two-part name
2408        assert_eq!(
2409            safe_data_product_name(r#""public"."my_view""#).unwrap(),
2410            r#""public"."my_view""#
2411        );
2412        // Unquoted name gets quoted in stable mode
2413        assert_eq!(safe_data_product_name("my_view").unwrap(), r#""my_view""#);
2414    }
2415
2416    #[mz_ore::test]
2417    fn test_safe_data_product_name_rejects_empty() {
2418        assert!(safe_data_product_name("").is_err());
2419        assert!(safe_data_product_name("   ").is_err());
2420    }
2421
2422    /// DEX-64: `parse_item_name` is unbounded on its own; the MCP path
2423    /// switches to `parse_item_name_with_limit` so a pathological name (e.g.
2424    /// millions of `(` characters) is rejected before lexing.
2425    #[mz_ore::test]
2426    fn test_safe_data_product_name_enforces_size_limit() {
2427        use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
2428        let oversized: String = "(".repeat(MAX_STATEMENT_BATCH_SIZE + 1);
2429        let err = safe_data_product_name(&oversized).expect_err("should be rejected");
2430        let msg = err.to_string();
2431        assert!(
2432            msg.contains("statement batch size cannot exceed"),
2433            "expected size-guard error, got: {msg}"
2434        );
2435    }
2436
2437    #[mz_ore::test]
2438    fn test_safe_data_product_name_rejects_sql_injection() {
2439        // Attempted injection via semicolon
2440        assert!(safe_data_product_name("my_view; DROP TABLE users").is_err());
2441        // Attempted injection via subquery
2442        assert!(safe_data_product_name("my_view UNION SELECT * FROM secrets").is_err());
2443        // Multiple table references via comma
2444        assert!(safe_data_product_name("my_view, secrets").is_err());
2445        // SQL keywords after name are rejected by the parser
2446        assert!(safe_data_product_name("my_view WHERE 1=1 --").is_err());
2447    }
2448
2449    /// A trailing `--` comment in the body must not swallow the `; COMMIT;`.
2450    #[mz_ore::test]
2451    fn test_read_only_txn_comment_cannot_swallow_commit() {
2452        let sql = read_only_txn("SET CLUSTER = 'c'", "SELECT 1 --");
2453        assert!(
2454            sql.contains("\n; COMMIT;"),
2455            "COMMIT must sit on its own line: {sql}",
2456        );
2457    }
2458
2459    // ── query_set_clause tests (QAR-136) ───────────────────────────────
2460
2461    /// Without a replica, only `SET CLUSTER` is emitted.
2462    #[mz_ore::test]
2463    fn test_query_set_clause_without_replica() {
2464        let clause = query_set_clause("prod_cluster", None);
2465        assert_eq!(clause, "SET CLUSTER = 'prod_cluster'");
2466    }
2467
2468    /// With a replica, `SET CLUSTER_REPLICA` follows `SET CLUSTER`, scoping
2469    /// the read to one replica (what makes EXPLAIN ANALYZE usable on
2470    /// clusters with multiple replicas).
2471    #[mz_ore::test]
2472    fn test_query_set_clause_with_replica() {
2473        let clause = query_set_clause("prod_cluster", Some("r1"));
2474        assert_eq!(
2475            clause,
2476            "SET CLUSTER = 'prod_cluster'; SET CLUSTER_REPLICA = 'r1'"
2477        );
2478    }
2479
2480    /// Replica names are interpolated into a SQL string literal, so they get
2481    /// the same escaping treatment as cluster names. Defends against
2482    /// injection via adversarial replica names.
2483    #[mz_ore::test]
2484    fn test_query_set_clause_escapes_replica_name() {
2485        let clause = query_set_clause("c", Some("evil'; DROP TABLE secrets; --"));
2486        assert_eq!(
2487            clause,
2488            "SET CLUSTER = 'c'; SET CLUSTER_REPLICA = 'evil''; DROP TABLE secrets; --'"
2489        );
2490    }
2491
2492    /// A `None` replica (no pinning requested) is always valid, and a normal
2493    /// replica name passes validation.
2494    #[mz_ore::test]
2495    fn test_validate_cluster_replica_accepts_none_and_names() {
2496        assert!(validate_cluster_replica(None).is_ok());
2497        assert!(validate_cluster_replica(Some("r1")).is_ok());
2498    }
2499
2500    /// An empty or whitespace-only replica name is rejected as a validation
2501    /// error up front, rather than producing `SET CLUSTER_REPLICA = ''` that
2502    /// fails deep in the engine as a generic execution error.
2503    #[mz_ore::test]
2504    fn test_validate_cluster_replica_rejects_empty() {
2505        for name in ["", "   ", "\t\n"] {
2506            assert!(
2507                matches!(
2508                    validate_cluster_replica(Some(name)),
2509                    Err(McpRequestError::QueryValidationFailed(_))
2510                ),
2511                "expected validation error for {name:?}",
2512            );
2513        }
2514    }
2515
2516    // ── build_read_query tests (DEX-27) ────────────────────────────────
2517
2518    /// The read is wrapped in a `BEGIN READ ONLY` transaction so the
2519    /// `SET CLUSTER` scope is bounded to this read and does not leak
2520    /// into the rest of the session.
2521    #[mz_ore::test]
2522    fn test_build_read_query_with_cluster() {
2523        let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, Some("prod_cluster"));
2524        assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
2525        assert!(sql.contains("SET CLUSTER = 'prod_cluster'"), "{sql}");
2526        assert!(
2527            sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
2528            "{sql}",
2529        );
2530        assert!(sql.contains("COMMIT"), "{sql}");
2531    }
2532
2533    /// With no cluster (the role lacks USAGE on the object's cluster, DEX-66),
2534    /// the read omits `SET CLUSTER` and runs on the session's serving cluster.
2535    #[mz_ore::test]
2536    fn test_build_read_query_without_cluster() {
2537        let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, None);
2538        assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
2539        assert!(!sql.contains("SET CLUSTER"), "{sql}");
2540        assert!(
2541            sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
2542            "{sql}",
2543        );
2544        assert!(sql.contains("COMMIT"), "{sql}");
2545    }
2546
2547    /// Cluster names with single quotes and backslashes must be escaped
2548    /// the same way as the `name` argument, since they end up interpolated
2549    /// into a SQL string literal. Defends against catalog-injection or
2550    /// adversarial cluster names.
2551    #[mz_ore::test]
2552    fn test_build_read_query_escapes_cluster_name() {
2553        let sql = build_read_query(
2554            "\"db\".\"sch\".\"v\"",
2555            10,
2556            Some("evil'; DROP TABLE secrets; --"),
2557        );
2558        // The single quote in `evil'` must be doubled inside the literal.
2559        assert!(
2560            sql.contains("SET CLUSTER = 'evil''; DROP TABLE secrets; --'"),
2561            "single quote should be doubled inside the literal: {sql}",
2562        );
2563        // The injected `DROP TABLE` must remain inside the literal — i.e.
2564        // there is no second statement that escapes the SET CLUSTER call.
2565        assert_eq!(
2566            sql.matches("SET CLUSTER").count(),
2567            1,
2568            "exactly one SET CLUSTER statement: {sql}",
2569        );
2570        assert_eq!(
2571            sql.matches("DROP TABLE").count(),
2572            1,
2573            "DROP TABLE should appear once, inside the quoted literal: {sql}",
2574        );
2575    }
2576
2577    #[mz_ore::test]
2578    fn test_mcp_error_codes() {
2579        assert_eq!(
2580            McpRequestError::InvalidJsonRpcVersion.error_code(),
2581            error_codes::INVALID_REQUEST
2582        );
2583        assert_eq!(
2584            McpRequestError::MethodNotFound("test".to_string()).error_code(),
2585            error_codes::METHOD_NOT_FOUND
2586        );
2587        assert_eq!(
2588            McpRequestError::QueryExecutionFailed("test".to_string()).error_code(),
2589            error_codes::INTERNAL_ERROR
2590        );
2591    }
2592}