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