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<Vec<serde_json::Value>>, 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<Vec<serde_json::Value>>, 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<Vec<serde_json::Value>>,
1089    max_size: usize,
1090) -> Result<McpResult, McpRequestError> {
1091    let text =
1092        serde_json::to_string_pretty(&rows).map_err(|e| McpRequestError::Internal(anyhow!(e)))?;
1093
1094    if text.len() > max_size {
1095        return Err(McpRequestError::QueryExecutionFailed(format!(
1096            "Response size ({} bytes) exceeds the {} byte limit. \
1097             Use LIMIT or WHERE to narrow your query.",
1098            text.len(),
1099            max_size,
1100        )));
1101    }
1102
1103    Ok(McpResult::ToolContent(ToolContentResult {
1104        content: vec![ContentBlock {
1105            content_type: "text".to_string(),
1106            text,
1107        }],
1108        is_error: false,
1109    }))
1110}
1111
1112async fn get_data_products(
1113    client: &mut AuthedClient,
1114    max_response_size: usize,
1115) -> Result<McpResult, McpRequestError> {
1116    debug!("Executing get_data_products");
1117    let rows = execute_sql(client, DISCOVERY_QUERY).await?;
1118    debug!("get_data_products returned {} rows", rows.len());
1119
1120    format_rows_response(rows, max_response_size)
1121}
1122
1123async fn get_data_product_details(
1124    client: &mut AuthedClient,
1125    name: &str,
1126    max_response_size: usize,
1127) -> Result<McpResult, McpRequestError> {
1128    debug!(name = %name, "Executing get_data_product_details");
1129
1130    let query = format!("{}{}", DETAILS_QUERY_PREFIX, escaped_string_literal(name));
1131
1132    let rows = execute_sql(client, &query).await?;
1133
1134    if rows.is_empty() {
1135        return Err(McpRequestError::DataProductNotFound(name.to_string()));
1136    }
1137
1138    format_rows_response(rows, max_response_size)
1139}
1140
1141/// Parses a data product name and returns it safely quoted for SQL interpolation.
1142///
1143/// Uses the SQL parser to validate the name as an `UnresolvedItemName`, then
1144/// formats it with `FormatMode::Stable` so every identifier part is
1145/// double-quoted with proper escaping. This prevents SQL injection regardless
1146/// of the input.
1147fn safe_data_product_name(name: &str) -> Result<String, McpRequestError> {
1148    let name = name.trim();
1149    if name.is_empty() {
1150        return Err(McpRequestError::QueryValidationFailed(
1151            "Data product name cannot be empty".to_string(),
1152        ));
1153    }
1154
1155    // `parse_item_name_with_limit` enforces the 1 MB guard on the raw input
1156    // (DEX-64) before lexing.
1157    let parsed = parse_item_name_with_limit(name)
1158        .map_err(McpRequestError::QueryValidationFailed)?
1159        .map_err(|_| {
1160            McpRequestError::QueryValidationFailed(format!(
1161                "Invalid data product name: {}. Expected a valid object name, \
1162                 e.g. '\"database\".\"schema\".\"name\"' or 'my_view'",
1163                name
1164            ))
1165        })?;
1166
1167    // Stable formatting forces all identifiers to be double-quoted,
1168    // so SQL keywords and special characters cannot escape.
1169    Ok(parsed.to_ast_string_stable())
1170}
1171
1172/// Read rows from a data product. Issues a single read-only query.
1173///
1174/// The read routes to the cluster recorded in `mz_mcp_data_products.cluster`
1175/// when the role has USAGE on it, so reads of indexed objects hit the index's
1176/// in-memory arrangement. That column is null when the role lacks USAGE on the
1177/// object's cluster (DEX-66); in that case, and absent an override, the read
1178/// runs on the session's default (serving) cluster instead. Only materialized
1179/// views can appear this way, since plain views without at least one usable
1180/// index cluster are excluded from `mz_mcp_data_products` entirely; the
1181/// fallback is therefore safe (materialized views serve from persist without
1182/// recompute).
1183///
1184/// `cluster_override` forces the read onto a named cluster instead — useful for
1185/// a differently-sized or differently-replicated cluster.
1186///
1187/// The name is expected to come from `get_data_products()` /
1188/// `get_data_product_details()`. The query runs inside a READ ONLY
1189/// transaction, preventing mutations.
1190async fn read_data_product(
1191    client: &mut AuthedClient,
1192    name: &str,
1193    limit: u32,
1194    cluster_override: Option<&str>,
1195    max_response_size: usize,
1196) -> Result<McpResult, McpRequestError> {
1197    debug!(name = %name, limit = limit, cluster_override = ?cluster_override, "Executing read_data_product");
1198
1199    // Parse and safely quote the name for SQL interpolation.
1200    let safe_name = safe_data_product_name(name)?;
1201
1202    // Existence check and cluster routing. `mz_mcp_data_products.cluster` is
1203    // non-null only when the role has USAGE on that cluster, so any value here
1204    // is safe to route to. `NULLS LAST` prefers such a usable cluster when an
1205    // object appears both with a usable cluster and as null (an object indexed
1206    // on several clusters, some of which the role cannot use).
1207    let lookup_query = format!(
1208        "SELECT dp.cluster FROM mz_internal.mz_mcp_data_products dp \
1209         WHERE dp.object_name = {} \
1210         ORDER BY dp.cluster NULLS LAST \
1211         LIMIT 1",
1212        escaped_string_literal(name)
1213    );
1214    let lookup_rows = execute_sql(client, &lookup_query).await?;
1215    if lookup_rows.is_empty() {
1216        return Err(McpRequestError::DataProductNotFound(name.to_string()));
1217    }
1218    let catalog_cluster: Option<&str> = lookup_rows
1219        .first()
1220        .and_then(|row| row.first())
1221        .and_then(|v| v.as_str());
1222
1223    // An override wins. Otherwise route to the catalog cluster when the role
1224    // can use it (non-null); when it is null the role lacks USAGE on the
1225    // object's cluster, so leave the cluster unset and read on the session's
1226    // default (serving) cluster. That still works: materialized views serve
1227    // from persist and views recompute, just without index benefit.
1228    let target_cluster: Option<&str> = cluster_override.or(catalog_cluster);
1229
1230    // No row cap is applied here: the response is bounded by the size cap
1231    // enforced in format_rows_response (MCP_MAX_RESPONSE_SIZE), and by
1232    // max_result_size at the adapter layer. Mirrors the SQL HTTP endpoint,
1233    // which also leans on a size cap rather than a row cap.
1234    let read_query = build_read_query(&safe_name, limit, target_cluster);
1235
1236    let rows = execute_sql(client, &read_query).await?;
1237
1238    format_rows_response(rows, max_response_size)
1239}
1240
1241/// Builds the SQL the agent runs for `read_data_product`.
1242///
1243/// `safe_name` must already be the validated, quoted form produced by
1244/// [`safe_data_product_name`]. When `target_cluster` is `Some`, it is escaped
1245/// as a SQL string literal and wrapped in `SET CLUSTER` inside the `BEGIN READ
1246/// ONLY` transaction so the cluster choice is scoped to this read and does not
1247/// leak into the session. When it is `None`, no `SET CLUSTER` is emitted and
1248/// the read runs on the session's default (serving) cluster.
1249fn build_read_query(safe_name: &str, limit: u32, target_cluster: Option<&str>) -> String {
1250    let body = format!("SELECT * FROM {safe_name} LIMIT {limit}");
1251    match target_cluster {
1252        Some(cluster) => read_only_txn(
1253            &format!("SET CLUSTER = {}", escaped_string_literal(cluster)),
1254            &body,
1255        ),
1256        None => format!("BEGIN READ ONLY; {body}\n; COMMIT;"),
1257    }
1258}
1259
1260/// Wraps `body` in a `BEGIN READ ONLY; <set_clause>; <body>; COMMIT;` frame so
1261/// `set_clause` is scoped to this read and does not leak into the session.
1262///
1263/// NOTE: the newline before `; COMMIT;` stops a trailing `--` comment in `body`
1264/// from swallowing the `COMMIT`.
1265fn read_only_txn(set_clause: &str, body: &str) -> String {
1266    format!("BEGIN READ ONLY; {set_clause}; {body}\n; COMMIT;")
1267}
1268
1269/// Validates query is a single SELECT, SHOW, or EXPLAIN statement.
1270fn validate_readonly_query(sql: &str) -> Result<(), McpRequestError> {
1271    let sql = sql.trim();
1272    if sql.is_empty() {
1273        return Err(McpRequestError::QueryValidationFailed(
1274            "Empty query".to_string(),
1275        ));
1276    }
1277
1278    // Parse the SQL to get AST. `parse_with_limit` rejects inputs larger
1279    // than `MAX_STATEMENT_BATCH_SIZE` before lexing so the MCP endpoint
1280    // enforces the same 1 MB guard as the SQL HTTP path (DEX-64).
1281    let stmts = parse_with_limit(sql)
1282        .map_err(McpRequestError::QueryValidationFailed)?
1283        .map_err(|e| {
1284            McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
1285        })?;
1286
1287    // Only allow a single statement
1288    if stmts.len() != 1 {
1289        return Err(McpRequestError::QueryValidationFailed(format!(
1290            "Only one query allowed at a time. Found {} statements.",
1291            stmts.len()
1292        )));
1293    }
1294
1295    // Allowlist: SELECT, SHOW, and every read-only EXPLAIN variant. EXPLAIN
1296    // expands to six distinct Statement variants in the parser (ExplainPlan
1297    // covers only the most common one). Listing them out exhaustively beats
1298    // matching by string prefix so a new write-capable EXPLAIN variant — were
1299    // one ever added — would have to be considered here.
1300    let stmt = &stmts[0];
1301    use mz_sql_parser::ast::Statement;
1302
1303    match &stmt.ast {
1304        Statement::Select(_)
1305        | Statement::Show(_)
1306        | Statement::ExplainPlan(_)
1307        | Statement::ExplainPushdown(_)
1308        | Statement::ExplainTimestamp(_)
1309        | Statement::ExplainSinkSchema(_)
1310        | Statement::ExplainAnalyzeObject(_)
1311        | Statement::ExplainAnalyzeCluster(_) => Ok(()),
1312        _ => Err(McpRequestError::QueryValidationFailed(
1313            "Only SELECT, SHOW, and EXPLAIN statements are allowed".to_string(),
1314        )),
1315    }
1316}
1317
1318async fn execute_query(
1319    client: &mut AuthedClient,
1320    cluster: &str,
1321    cluster_replica: Option<&str>,
1322    sql_query: &str,
1323    max_response_size: usize,
1324) -> Result<McpResult, McpRequestError> {
1325    debug!(cluster = %cluster, cluster_replica = ?cluster_replica, "Executing user query");
1326
1327    validate_readonly_query(sql_query)?;
1328    validate_cluster_replica(cluster_replica)?;
1329
1330    // READ ONLY prevents mutations; SET CLUSTER (and, when requested,
1331    // SET CLUSTER_REPLICA) scope the placement to this read.
1332    let combined_query = read_only_txn(&query_set_clause(cluster, cluster_replica), sql_query);
1333
1334    let rows = execute_sql(client, &combined_query).await?;
1335
1336    format_rows_response(rows, max_response_size)
1337}
1338
1339/// Builds the `SET` clause for `execute_query`: always `SET CLUSTER`, plus
1340/// `SET CLUSTER_REPLICA` when a replica is requested (e.g. for
1341/// `EXPLAIN ANALYZE` on a cluster with multiple replicas). Both names pass
1342/// through `escaped_string_literal` since they are interpolated into SQL
1343/// string literals.
1344fn query_set_clause(cluster: &str, cluster_replica: Option<&str>) -> String {
1345    let mut set_clause = format!("SET CLUSTER = {}", escaped_string_literal(cluster));
1346    if let Some(replica) = cluster_replica {
1347        set_clause.push_str(&format!(
1348            "; SET CLUSTER_REPLICA = {}",
1349            escaped_string_literal(replica)
1350        ));
1351    }
1352    set_clause
1353}
1354
1355/// Rejects an empty or whitespace-only `cluster_replica`. Such a name would
1356/// otherwise produce `SET CLUSTER_REPLICA = ''`, which fails deep in the engine
1357/// as a generic execution error rather than a clean validation error. `None`
1358/// (no replica pin requested) is always valid.
1359fn validate_cluster_replica(cluster_replica: Option<&str>) -> Result<(), McpRequestError> {
1360    if let Some(replica) = cluster_replica {
1361        if replica.trim().is_empty() {
1362            return Err(McpRequestError::QueryValidationFailed(
1363                "cluster_replica must not be empty or whitespace-only".to_string(),
1364            ));
1365        }
1366    }
1367    Ok(())
1368}
1369
1370async fn query_system_catalog(
1371    client: &mut AuthedClient,
1372    sql_query: &str,
1373    max_response_size: usize,
1374) -> Result<McpResult, McpRequestError> {
1375    debug!("Executing query_system_catalog");
1376
1377    // First validate it's a read-only query
1378    validate_readonly_query(sql_query)?;
1379
1380    // Then validate that query only references mz_* tables by parsing the SQL
1381    validate_system_catalog_query(sql_query)?;
1382
1383    // Wrap the query in a READ ONLY transaction with a tight search_path
1384    // restricted to system schemas. This prevents unqualified `mz_*` references
1385    // from resolving to user-created objects (e.g. a view `public.mz_leak`) via
1386    // the session's search_path (mirrors the `BEGIN READ ONLY; SET ...` pattern
1387    // used by the agent `query` tool).
1388    let combined_query = read_only_txn(
1389        "SET search_path = mz_catalog, mz_internal, pg_catalog, information_schema",
1390        sql_query,
1391    );
1392
1393    let rows = execute_sql(client, &combined_query).await?;
1394
1395    format_rows_response(rows, max_response_size)
1396}
1397
1398/// Collects table references from SQL AST with their schema qualification.
1399struct TableReferenceCollector {
1400    /// Stores (schema, table_name) tuples. Schema is None if unqualified.
1401    tables: Vec<(Option<String>, String)>,
1402    /// CTE names to exclude from validation (they're not real tables)
1403    cte_names: std::collections::BTreeSet<String>,
1404}
1405
1406impl TableReferenceCollector {
1407    fn new() -> Self {
1408        Self {
1409            tables: Vec::new(),
1410            cte_names: std::collections::BTreeSet::new(),
1411        }
1412    }
1413}
1414
1415impl<'ast> Visit<'ast, Raw> for TableReferenceCollector {
1416    fn visit_cte(&mut self, cte: &'ast mz_sql_parser::ast::Cte<Raw>) {
1417        // Track CTE names so we don't treat them as table references
1418        self.cte_names
1419            .insert(cte.alias.name.as_str().to_lowercase());
1420        visit::visit_cte(self, cte);
1421    }
1422
1423    fn visit_table_factor(&mut self, table_factor: &'ast mz_sql_parser::ast::TableFactor<Raw>) {
1424        // Only visit actual table references in FROM/JOIN clauses, not function names
1425        if let mz_sql_parser::ast::TableFactor::Table { name, .. } = table_factor {
1426            match name {
1427                RawItemName::Name(n) | RawItemName::Id(_, n, _) => {
1428                    let parts = &n.0;
1429                    if !parts.is_empty() {
1430                        let table_name = parts.last().unwrap().as_str().to_lowercase();
1431
1432                        // Skip if this is a CTE reference, not a real table
1433                        if self.cte_names.contains(&table_name) {
1434                            visit::visit_table_factor(self, table_factor);
1435                            return;
1436                        }
1437
1438                        // Extract schema if qualified (e.g., mz_catalog.mz_tables)
1439                        let schema = if parts.len() >= 2 {
1440                            Some(parts[parts.len() - 2].as_str().to_lowercase())
1441                        } else {
1442                            None
1443                        };
1444                        self.tables.push((schema, table_name));
1445                    }
1446                }
1447            }
1448        }
1449        visit::visit_table_factor(self, table_factor);
1450    }
1451}
1452
1453/// Validates that a query only references system catalog tables.
1454///
1455/// For SELECT statements, all table references must be in system schemas
1456/// (from `SYSTEM_SCHEMAS`, excluding `mz_unsafe`), and at least one system
1457/// table must be referenced (constant queries like `SELECT 1` are rejected
1458/// to prevent misuse of the developer endpoint for arbitrary computation).
1459/// SHOW and EXPLAIN statements are allowed without table references.
1460fn validate_system_catalog_query(sql: &str) -> Result<(), McpRequestError> {
1461    // Parse the SQL to validate it. `parse_with_limit` enforces the 1 MB
1462    // guard shared with the SQL HTTP path (DEX-64).
1463    let stmts = parse_with_limit(sql)
1464        .map_err(McpRequestError::QueryValidationFailed)?
1465        .map_err(|e| {
1466            McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
1467        })?;
1468
1469    if stmts.is_empty() {
1470        return Err(McpRequestError::QueryValidationFailed(
1471            "Empty query".to_string(),
1472        ));
1473    }
1474
1475    // Walk the AST to collect all table references
1476    let mut collector = TableReferenceCollector::new();
1477    for stmt in &stmts {
1478        collector.visit_statement(&stmt.ast);
1479    }
1480
1481    // Use the canonical system schema list, excluding mz_unsafe which contains
1482    // internal-only objects that should not be exposed to MCP clients.
1483    let is_allowed_schema =
1484        |s: &str| SYSTEM_SCHEMAS.contains(&s) && s != namespaces::MZ_UNSAFE_SCHEMA;
1485
1486    // Helper to check if a table reference is allowed. Unqualified references
1487    // are accepted when they carry an unambiguous system prefix (`mz_`/`pg_`);
1488    // execution pins `search_path` to system schemas (see `query_system_catalog`),
1489    // so a user view like `public.mz_leak` cannot be reached by an unqualified name.
1490    let is_system_table = |(schema, table_name): &(Option<String>, String)| match schema {
1491        Some(s) => is_allowed_schema(s.as_str()),
1492        None => table_name.starts_with("mz_") || table_name.starts_with("pg_"),
1493    };
1494
1495    // Check that all table references are system tables
1496    let non_system_tables: Vec<String> = collector
1497        .tables
1498        .iter()
1499        .filter(|t| !is_system_table(t))
1500        .map(|(schema, table)| match schema {
1501            Some(s) => format!("{}.{}", s, table),
1502            None => table.clone(),
1503        })
1504        .collect();
1505
1506    if !non_system_tables.is_empty() {
1507        return Err(McpRequestError::QueryValidationFailed(format!(
1508            "Query references non-system tables: {}. Only system catalog tables (mz_*, pg_catalog, information_schema) are allowed.",
1509            non_system_tables.join(", ")
1510        )));
1511    }
1512
1513    // SHOW and EXPLAIN statements don't reference tables in the AST, but are safe
1514    // read-only operations. Only require system table references for SELECT.
1515    use mz_sql_parser::ast::Statement;
1516    let is_select = stmts.iter().any(|s| matches!(&s.ast, Statement::Select(_)));
1517
1518    if is_select && (collector.tables.is_empty() || !collector.tables.iter().any(is_system_table)) {
1519        return Err(McpRequestError::QueryValidationFailed(
1520            "Query must reference at least one system catalog table".to_string(),
1521        ));
1522    }
1523
1524    Ok(())
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529    use super::*;
1530    use crate::http::sql::{Description, SqlError};
1531
1532    fn rows_result(rows: Vec<Vec<serde_json::Value>>) -> SqlResult {
1533        SqlResult::Rows {
1534            tag: String::new(),
1535            rows,
1536            desc: Description { columns: vec![] },
1537            notices: vec![],
1538        }
1539    }
1540
1541    fn ok_result() -> SqlResult {
1542        SqlResult::Ok {
1543            ok: String::new(),
1544            notices: vec![],
1545            parameters: vec![],
1546        }
1547    }
1548
1549    fn err_result(message: &str) -> SqlResult {
1550        SqlResult::Err {
1551            error: SqlError {
1552                message: message.to_string(),
1553                code: String::new(),
1554                detail: None,
1555                hint: None,
1556                position: None,
1557            },
1558            notices: vec![],
1559        }
1560    }
1561
1562    /// The row-returning statement's rows are returned, ignoring the `Ok`
1563    /// framing statements around it.
1564    #[mz_ore::test]
1565    fn test_select_single_rows_extracts_rows() {
1566        let rows = vec![vec![serde_json::json!(1)]];
1567        let results = vec![
1568            ok_result(),
1569            ok_result(),
1570            rows_result(rows.clone()),
1571            ok_result(),
1572        ];
1573        assert_eq!(select_single_rows(results).unwrap(), rows);
1574    }
1575
1576    /// A response with no row-returning statement is an error.
1577    #[mz_ore::test]
1578    fn test_select_single_rows_requires_rows() {
1579        let err = select_single_rows(vec![ok_result(), ok_result()]).unwrap_err();
1580        assert!(
1581            matches!(err, McpRequestError::QueryExecutionFailed(_)),
1582            "{err:?}"
1583        );
1584    }
1585
1586    /// The invariant is enforced: a second row-returning statement is an
1587    /// internal error rather than a silently dropped result.
1588    #[mz_ore::test]
1589    fn test_select_single_rows_rejects_multiple() {
1590        let results = vec![rows_result(vec![]), rows_result(vec![])];
1591        let err = select_single_rows(results).unwrap_err();
1592        assert!(matches!(err, McpRequestError::Internal(_)), "{err:?}");
1593    }
1594
1595    /// A statement error is surfaced.
1596    #[mz_ore::test]
1597    fn test_select_single_rows_surfaces_error() {
1598        let err = select_single_rows(vec![ok_result(), err_result("boom")]).unwrap_err();
1599        match err {
1600            McpRequestError::QueryExecutionFailed(msg) => assert_eq!(msg, "boom"),
1601            other => panic!("unexpected error: {other:?}"),
1602        }
1603    }
1604
1605    /// The DNS-rebinding defense: a disallowed `Origin` is rejected with 403,
1606    /// an allowed one passes, and a missing one passes (non-browser clients).
1607    #[mz_ore::test]
1608    fn test_validate_origin() {
1609        let allowed = [HeaderValue::from_static("https://good.example")];
1610
1611        assert!(validate_origin(&HeaderMap::new(), &allowed).is_none());
1612
1613        let mut ok = HeaderMap::new();
1614        ok.insert(http::header::ORIGIN, allowed[0].clone());
1615        assert!(validate_origin(&ok, &allowed).is_none());
1616
1617        let mut bad = HeaderMap::new();
1618        bad.insert(
1619            http::header::ORIGIN,
1620            HeaderValue::from_static("https://evil.example"),
1621        );
1622        let rejected = validate_origin(&bad, &allowed);
1623        assert_eq!(
1624            rejected
1625                .expect("disallowed origin must be rejected")
1626                .status(),
1627            StatusCode::FORBIDDEN,
1628        );
1629    }
1630
1631    /// The two constructors set the JSON-RPC version and put the payload in
1632    /// the right one of the mutually-exclusive `result` / `error` fields.
1633    #[mz_ore::test]
1634    fn test_mcp_response_constructors() {
1635        let id = serde_json::json!(1);
1636
1637        let ok = McpResponse::success(
1638            id.clone(),
1639            McpResult::ToolContent(ToolContentResult {
1640                content: vec![],
1641                is_error: false,
1642            }),
1643        );
1644        assert_eq!(ok.jsonrpc, JSONRPC_VERSION);
1645        assert!(ok.result.is_some());
1646        assert!(ok.error.is_none());
1647
1648        let err = McpResponse::error(id, McpRequestError::ToolNotFound("t".to_string()).into());
1649        assert_eq!(err.jsonrpc, JSONRPC_VERSION);
1650        assert!(err.result.is_none());
1651        assert!(err.error.is_some());
1652    }
1653
1654    #[mz_ore::test]
1655    fn test_validate_readonly_query_select() {
1656        assert!(validate_readonly_query("SELECT * FROM mz_tables").is_ok());
1657        assert!(validate_readonly_query("SELECT 1 + 2").is_ok());
1658        assert!(validate_readonly_query("  SELECT 1  ").is_ok());
1659    }
1660
1661    #[mz_ore::test]
1662    fn test_validate_readonly_query_subqueries() {
1663        // Simple subquery in WHERE clause
1664        assert!(
1665            validate_readonly_query(
1666                "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
1667            )
1668            .is_ok()
1669        );
1670
1671        // Subquery in FROM clause
1672        assert!(
1673            validate_readonly_query(
1674                "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
1675            )
1676            .is_ok()
1677        );
1678
1679        // Correlated subquery
1680        assert!(validate_readonly_query(
1681            "SELECT * FROM mz_tables t WHERE EXISTS (SELECT 1 FROM mz_columns c WHERE c.id = t.id)"
1682        )
1683        .is_ok());
1684
1685        // Nested subqueries
1686        assert!(validate_readonly_query(
1687            "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns WHERE type IN (SELECT name FROM mz_types))"
1688        )
1689        .is_ok());
1690
1691        // Subquery with aggregation
1692        assert!(
1693            validate_readonly_query(
1694                "SELECT * FROM mz_tables WHERE id = (SELECT MAX(id) FROM mz_columns)"
1695            )
1696            .is_ok()
1697        );
1698    }
1699
1700    #[mz_ore::test]
1701    fn test_validate_readonly_query_show() {
1702        assert!(validate_readonly_query("SHOW CLUSTERS").is_ok());
1703        assert!(validate_readonly_query("SHOW TABLES").is_ok());
1704    }
1705
1706    #[mz_ore::test]
1707    fn test_validate_readonly_query_explain() {
1708        // Every read-only EXPLAIN variant must be accepted. Each line
1709        // corresponds to a distinct Statement::Explain* arm of the parser; if
1710        // one of them is dropped from the validator the test that lost its
1711        // arm will fail, naming the variant.
1712        assert!(validate_readonly_query("EXPLAIN SELECT 1").is_ok());
1713        assert!(
1714            validate_readonly_query("EXPLAIN FILTER PUSHDOWN FOR SELECT * FROM mz_tables").is_ok()
1715        );
1716        assert!(validate_readonly_query("EXPLAIN TIMESTAMP FOR SELECT 1").is_ok());
1717        assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR INDEX foo").is_ok());
1718        assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR MATERIALIZED VIEW mv").is_ok());
1719        assert!(validate_readonly_query("EXPLAIN ANALYZE CLUSTER MEMORY").is_ok());
1720    }
1721
1722    #[mz_ore::test]
1723    fn test_validate_readonly_query_rejects_writes() {
1724        assert!(validate_readonly_query("INSERT INTO t VALUES (1)").is_err());
1725        assert!(validate_readonly_query("UPDATE t SET a = 1").is_err());
1726        assert!(validate_readonly_query("DELETE FROM t").is_err());
1727        assert!(validate_readonly_query("CREATE TABLE t (a INT)").is_err());
1728        assert!(validate_readonly_query("DROP TABLE t").is_err());
1729    }
1730
1731    #[mz_ore::test]
1732    fn test_validate_readonly_query_rejects_multiple() {
1733        assert!(validate_readonly_query("SELECT 1; SELECT 2").is_err());
1734    }
1735
1736    #[mz_ore::test]
1737    fn test_validate_readonly_query_rejects_empty() {
1738        assert!(validate_readonly_query("").is_err());
1739        assert!(validate_readonly_query("   ").is_err());
1740    }
1741
1742    /// Regression test for DEX-64: without the 1 MB parser guard, the MCP
1743    /// validators would happily lex and parse multi-megabyte input. Both
1744    /// `validate_readonly_query` and `validate_system_catalog_query` now go
1745    /// through `parse_with_limit`, so an oversized batch is rejected with the
1746    /// same "statement batch size cannot exceed" message the SQL HTTP path
1747    /// emits.
1748    #[mz_ore::test]
1749    fn test_validate_readonly_query_enforces_size_limit() {
1750        use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
1751        // Just over the limit so the guard is the *only* thing that rejects.
1752        let oversized: String =
1753            "SELECT 1;".repeat((MAX_STATEMENT_BATCH_SIZE / "SELECT 1;".len()) + 1);
1754        let err = validate_readonly_query(&oversized).expect_err("should be rejected");
1755        let msg = err.to_string();
1756        assert!(
1757            msg.contains("statement batch size cannot exceed"),
1758            "expected size-guard error, got: {msg}"
1759        );
1760    }
1761
1762    #[mz_ore::test]
1763    fn test_validate_system_catalog_query_enforces_size_limit() {
1764        use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
1765        let stmt = "SELECT * FROM mz_tables;";
1766        let oversized: String = stmt.repeat((MAX_STATEMENT_BATCH_SIZE / stmt.len()) + 1);
1767        let err = validate_system_catalog_query(&oversized).expect_err("should be rejected");
1768        let msg = err.to_string();
1769        assert!(
1770            msg.contains("statement batch size cannot exceed"),
1771            "expected size-guard error, got: {msg}"
1772        );
1773    }
1774
1775    #[mz_ore::test]
1776    fn test_validate_system_catalog_query_accepts_mz_tables() {
1777        assert!(validate_system_catalog_query("SELECT * FROM mz_tables").is_ok());
1778        assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_comments").is_ok());
1779        assert!(
1780            validate_system_catalog_query(
1781                "SELECT * FROM mz_tables t JOIN mz_columns c ON t.id = c.id"
1782            )
1783            .is_ok()
1784        );
1785    }
1786
1787    #[mz_ore::test]
1788    fn test_validate_system_catalog_query_subqueries() {
1789        // Subquery with mz_* tables
1790        assert!(
1791            validate_system_catalog_query(
1792                "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
1793            )
1794            .is_ok()
1795        );
1796
1797        // Nested subqueries with mz_* tables
1798        assert!(validate_system_catalog_query(
1799            "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM mz_columns WHERE type IN (SELECT id FROM mz_types))"
1800        )
1801        .is_ok());
1802
1803        // Subquery in FROM clause
1804        assert!(
1805            validate_system_catalog_query(
1806                "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
1807            )
1808            .is_ok()
1809        );
1810
1811        // Reject subqueries that reference non-mz_* tables
1812        assert!(
1813            validate_system_catalog_query(
1814                "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM user_data)"
1815            )
1816            .is_err()
1817        );
1818
1819        // Reject mixed references in nested subqueries
1820        assert!(validate_system_catalog_query(
1821            "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM (SELECT id FROM user_table) AS t)"
1822        )
1823        .is_err());
1824    }
1825
1826    #[mz_ore::test]
1827    fn test_validate_system_catalog_query_rejects_user_tables() {
1828        assert!(validate_system_catalog_query("SELECT * FROM user_data").is_err());
1829        assert!(validate_system_catalog_query("SELECT * FROM my_table").is_err());
1830        // Security: reject queries that mention mz_ in a non-table context
1831        assert!(
1832            validate_system_catalog_query("SELECT * FROM user_data WHERE 'mz_' IS NOT NULL")
1833                .is_err()
1834        );
1835    }
1836
1837    #[mz_ore::test]
1838    fn test_validate_system_catalog_query_allows_functions() {
1839        // Function names should not be treated as table references
1840        assert!(
1841            validate_system_catalog_query(
1842                "SELECT date_part('year', now())::int4 AS y FROM mz_tables LIMIT 1"
1843            )
1844            .is_ok()
1845        );
1846        assert!(validate_system_catalog_query("SELECT length(name) FROM mz_tables").is_ok());
1847        assert!(
1848            validate_system_catalog_query(
1849                "SELECT count(*) FROM mz_sources WHERE now() > created_at"
1850            )
1851            .is_ok()
1852        );
1853    }
1854
1855    #[mz_ore::test]
1856    fn test_validate_system_catalog_query_unqualified_pg_catalog() {
1857        // Unqualified `pg_`-prefixed names are as unambiguously system as `mz_`
1858        // ones, and resolve safely under the pinned search_path.
1859        assert!(validate_system_catalog_query("SELECT * FROM pg_class").is_ok());
1860        assert!(validate_system_catalog_query("SELECT nspname FROM pg_namespace").is_ok());
1861    }
1862
1863    #[mz_ore::test]
1864    fn test_validate_system_catalog_query_schema_qualified() {
1865        // Qualified with allowed schemas should work
1866        assert!(validate_system_catalog_query("SELECT * FROM mz_catalog.mz_tables").is_ok());
1867        assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_sessions").is_ok());
1868        assert!(validate_system_catalog_query("SELECT * FROM pg_catalog.pg_type").is_ok());
1869        assert!(validate_system_catalog_query("SELECT * FROM information_schema.tables").is_ok());
1870
1871        // Qualified with disallowed schema should fail
1872        assert!(validate_system_catalog_query("SELECT * FROM public.user_table").is_err());
1873        assert!(validate_system_catalog_query("SELECT * FROM myschema.mytable").is_err());
1874
1875        // mz_unsafe is a system schema but explicitly blocked for MCP
1876        assert!(
1877            validate_system_catalog_query("SELECT * FROM mz_unsafe.mz_some_table").is_err(),
1878            "mz_unsafe schema should be blocked even though it is a system schema"
1879        );
1880
1881        // Mixed: system and user schemas should fail
1882        assert!(
1883            validate_system_catalog_query(
1884                "SELECT * FROM mz_catalog.mz_tables JOIN public.user_data ON true"
1885            )
1886            .is_err()
1887        );
1888    }
1889
1890    #[mz_ore::test]
1891    fn test_validate_system_catalog_query_adversarial_cases() {
1892        // Try to sneak in user table via CTE
1893        assert!(
1894            validate_system_catalog_query(
1895                "WITH user_cte AS (SELECT * FROM user_data) \
1896                 SELECT * FROM mz_tables, user_cte"
1897            )
1898            .is_err(),
1899            "Should reject CTE referencing user table"
1900        );
1901
1902        // Complex multi-level CTE with user table buried deep
1903        assert!(
1904            validate_system_catalog_query(
1905                "WITH \
1906                   cte1 AS (SELECT * FROM mz_tables), \
1907                   cte2 AS (SELECT * FROM cte1), \
1908                   cte3 AS (SELECT * FROM user_data) \
1909                 SELECT * FROM cte2"
1910            )
1911            .is_err(),
1912            "Should reject CTE chain with user table"
1913        );
1914
1915        // Multiple joins - user table in the middle
1916        assert!(
1917            validate_system_catalog_query(
1918                "SELECT * FROM mz_tables t1 \
1919                 JOIN user_data u ON t1.id = u.id \
1920                 JOIN mz_sources s ON t1.id = s.id"
1921            )
1922            .is_err(),
1923            "Should reject multi-join with user table"
1924        );
1925
1926        // LEFT JOIN trying to hide user table
1927        assert!(
1928            validate_system_catalog_query(
1929                "SELECT * FROM mz_tables t \
1930                 LEFT JOIN user_data u ON t.id = u.table_id \
1931                 WHERE u.id IS NULL"
1932            )
1933            .is_err(),
1934            "Should reject LEFT JOIN with user table"
1935        );
1936
1937        // Nested subquery with user table in FROM
1938        assert!(
1939            validate_system_catalog_query(
1940                "SELECT * FROM mz_tables WHERE id IN \
1941                 (SELECT table_id FROM (SELECT * FROM user_data) AS u)"
1942            )
1943            .is_err(),
1944            "Should reject nested subquery with user table"
1945        );
1946
1947        // UNION trying to mix system and user data
1948        assert!(
1949            validate_system_catalog_query(
1950                "SELECT name FROM mz_tables \
1951                 UNION \
1952                 SELECT name FROM user_data"
1953            )
1954            .is_err(),
1955            "Should reject UNION with user table"
1956        );
1957
1958        // UNION ALL variation
1959        assert!(
1960            validate_system_catalog_query(
1961                "SELECT id FROM mz_sources \
1962                 UNION ALL \
1963                 SELECT id FROM products"
1964            )
1965            .is_err(),
1966            "Should reject UNION ALL with user table"
1967        );
1968
1969        // Cross join with user table
1970        assert!(
1971            validate_system_catalog_query("SELECT * FROM mz_tables CROSS JOIN user_data").is_err(),
1972            "Should reject CROSS JOIN with user table"
1973        );
1974
1975        // Subquery in SELECT clause referencing user table
1976        assert!(
1977            validate_system_catalog_query(
1978                "SELECT t.*, (SELECT COUNT(*) FROM user_data) AS cnt FROM mz_tables t"
1979            )
1980            .is_err(),
1981            "Should reject subquery in SELECT with user table"
1982        );
1983
1984        // Try to use a schema name that looks similar to allowed ones
1985        assert!(
1986            validate_system_catalog_query("SELECT * FROM mz_catalogg.fake_table").is_err(),
1987            "Should reject typo-squatting schema name"
1988        );
1989        assert!(
1990            validate_system_catalog_query("SELECT * FROM mz_catalog_hack.fake_table").is_err(),
1991            "Should reject fake schema with mz_catalog prefix"
1992        );
1993
1994        // Lateral join with user table
1995        assert!(
1996            validate_system_catalog_query(
1997                "SELECT * FROM mz_tables t, LATERAL (SELECT * FROM user_data WHERE id = t.id) u"
1998            )
1999            .is_err(),
2000            "Should reject LATERAL join with user table"
2001        );
2002
2003        // Valid complex query - all system tables
2004        assert!(
2005            validate_system_catalog_query(
2006                "WITH \
2007                   tables AS (SELECT * FROM mz_tables), \
2008                   sources AS (SELECT * FROM mz_sources) \
2009                 SELECT t.name, s.name \
2010                 FROM tables t \
2011                 JOIN sources s ON t.id = s.id \
2012                 WHERE t.id IN (SELECT id FROM mz_columns)"
2013            )
2014            .is_ok(),
2015            "Should allow complex query with only system tables"
2016        );
2017
2018        // Valid UNION of system tables
2019        assert!(
2020            validate_system_catalog_query(
2021                "SELECT name FROM mz_tables \
2022                 UNION \
2023                 SELECT name FROM mz_sources"
2024            )
2025            .is_ok(),
2026            "Should allow UNION of system tables"
2027        );
2028    }
2029
2030    #[mz_ore::test]
2031    fn test_validate_system_catalog_query_rejects_constant_queries() {
2032        // SELECT without any table reference should be rejected — the developer
2033        // endpoint is for system catalog queries, not arbitrary computation.
2034        assert!(
2035            validate_system_catalog_query("SELECT 1").is_err(),
2036            "Should reject constant SELECT with no table references"
2037        );
2038        assert!(
2039            validate_system_catalog_query("SELECT 1 + 2, 'hello'").is_err(),
2040            "Should reject constant expression SELECT"
2041        );
2042        assert!(
2043            validate_system_catalog_query("SELECT now()").is_err(),
2044            "Should reject function-only SELECT with no table references"
2045        );
2046    }
2047
2048    #[mz_ore::test]
2049    fn test_validate_system_catalog_query_rejects_mixed_tables() {
2050        assert!(
2051            validate_system_catalog_query(
2052                "SELECT * FROM mz_tables t JOIN user_data u ON t.id = u.table_id"
2053            )
2054            .is_err()
2055        );
2056    }
2057
2058    #[mz_ore::test]
2059    fn test_validate_system_catalog_query_allows_show() {
2060        // SHOW queries don't reference tables in the AST but are safe read-only ops
2061        assert!(
2062            validate_system_catalog_query("SHOW TABLES FROM mz_internal").is_ok(),
2063            "SHOW TABLES FROM mz_internal should be allowed"
2064        );
2065        assert!(
2066            validate_system_catalog_query("SHOW TABLES FROM mz_catalog").is_ok(),
2067            "SHOW TABLES FROM mz_catalog should be allowed"
2068        );
2069        assert!(
2070            validate_system_catalog_query("SHOW CLUSTERS").is_ok(),
2071            "SHOW CLUSTERS should be allowed"
2072        );
2073        assert!(
2074            validate_system_catalog_query("SHOW SOURCES").is_ok(),
2075            "SHOW SOURCES should be allowed"
2076        );
2077        assert!(
2078            validate_system_catalog_query("SHOW TABLES").is_ok(),
2079            "SHOW TABLES should be allowed"
2080        );
2081    }
2082
2083    #[mz_ore::test]
2084    fn test_validate_system_catalog_query_allows_explain() {
2085        assert!(
2086            validate_system_catalog_query("EXPLAIN SELECT * FROM mz_tables").is_ok(),
2087            "EXPLAIN of system table query should be allowed"
2088        );
2089        assert!(
2090            validate_system_catalog_query("EXPLAIN SELECT 1").is_ok(),
2091            "EXPLAIN SELECT 1 should be allowed"
2092        );
2093    }
2094
2095    // ── Query tool feature flag tests ──────────────────────────────────────
2096
2097    #[mz_ore::test(tokio::test)]
2098    async fn test_tools_list_agent_query_tool_disabled() {
2099        let result = handle_tools_list(McpEndpointType::Agent, false, true, 1_000_000).unwrap();
2100        let McpResult::ToolsList(list) = result else {
2101            panic!("Expected ToolsList result");
2102        };
2103        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2104        assert!(
2105            tool_names.contains(&"get_data_products"),
2106            "get_data_products should always be present"
2107        );
2108        assert!(
2109            tool_names.contains(&"get_data_product_details"),
2110            "get_data_product_details should always be present"
2111        );
2112        assert!(
2113            tool_names.contains(&"read_data_product"),
2114            "read_data_product should be present when its flag is on"
2115        );
2116        assert!(
2117            !tool_names.contains(&"query"),
2118            "query tool should be hidden when disabled"
2119        );
2120    }
2121
2122    #[mz_ore::test(tokio::test)]
2123    async fn test_tools_list_agent_query_tool_enabled() {
2124        let result = handle_tools_list(McpEndpointType::Agent, true, true, 1_000_000).unwrap();
2125        let McpResult::ToolsList(list) = result else {
2126            panic!("Expected ToolsList result");
2127        };
2128        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2129        assert!(
2130            tool_names.contains(&"get_data_products"),
2131            "get_data_products should always be present"
2132        );
2133        assert!(
2134            tool_names.contains(&"get_data_product_details"),
2135            "get_data_product_details should always be present"
2136        );
2137        assert!(
2138            tool_names.contains(&"read_data_product"),
2139            "read_data_product should be present when its flag is on"
2140        );
2141        assert!(
2142            tool_names.contains(&"query"),
2143            "query tool should be present when enabled"
2144        );
2145    }
2146
2147    #[mz_ore::test(tokio::test)]
2148    async fn test_tools_list_agent_read_data_product_tool_disabled() {
2149        let result = handle_tools_list(McpEndpointType::Agent, true, false, 1_000_000).unwrap();
2150        let McpResult::ToolsList(list) = result else {
2151            panic!("Expected ToolsList result");
2152        };
2153        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2154        assert!(
2155            tool_names.contains(&"get_data_products"),
2156            "get_data_products should always be present"
2157        );
2158        assert!(
2159            tool_names.contains(&"get_data_product_details"),
2160            "get_data_product_details should always be present"
2161        );
2162        assert!(
2163            !tool_names.contains(&"read_data_product"),
2164            "read_data_product should be hidden when disabled"
2165        );
2166        assert!(
2167            tool_names.contains(&"query"),
2168            "query tool should remain present when enabled"
2169        );
2170    }
2171
2172    /// Both read tools off is a valid (if unusual) config where the agent
2173    /// is discovery-only. Pin the behavior: only get_data_products and
2174    /// get_data_product_details are advertised, and the initialize
2175    /// instructions do not tell the agent to use a tool that isn't listed.
2176    #[mz_ore::test(tokio::test)]
2177    async fn test_tools_list_agent_both_read_tools_disabled() {
2178        let result = handle_tools_list(McpEndpointType::Agent, false, false, 1_000_000).unwrap();
2179        let McpResult::ToolsList(list) = result else {
2180            panic!("Expected ToolsList result");
2181        };
2182        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2183        assert_eq!(
2184            tool_names
2185                .iter()
2186                .copied()
2187                .collect::<std::collections::BTreeSet<_>>(),
2188            ["get_data_product_details", "get_data_products"]
2189                .into_iter()
2190                .collect(),
2191            "only discovery tools should be advertised when both read flags are off",
2192        );
2193
2194        let instructions = endpoint_instructions(McpEndpointType::Agent, false, false)
2195            .expect("agent instructions must be present");
2196        assert!(
2197            !instructions.contains("Use the `query` tool"),
2198            "instructions must not point at query when it is hidden: {instructions}",
2199        );
2200        assert!(
2201            !instructions.contains("`read_data_product` automatically"),
2202            "instructions must not point at read_data_product when it is hidden: {instructions}",
2203        );
2204        assert!(
2205            instructions.contains("discovery only"),
2206            "instructions must tell the agent it is discovery-only: {instructions}",
2207        );
2208    }
2209
2210    #[mz_ore::test(tokio::test)]
2211    async fn test_tools_list_developer_query_tool_disabled() {
2212        // Developer endpoint doesn't expose read_data_product; the flag is
2213        // orthogonal, so pass whichever value.
2214        let result = handle_tools_list(McpEndpointType::Developer, false, true, 1_000_000).unwrap();
2215        let McpResult::ToolsList(list) = result else {
2216            panic!("Expected ToolsList result");
2217        };
2218        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2219        assert!(
2220            tool_names.contains(&"query_system_catalog"),
2221            "query_system_catalog should always be present on developer"
2222        );
2223        assert!(
2224            !tool_names.contains(&"query"),
2225            "query tool should be hidden when disabled"
2226        );
2227    }
2228
2229    #[mz_ore::test(tokio::test)]
2230    async fn test_tools_list_developer_query_tool_enabled() {
2231        let result = handle_tools_list(McpEndpointType::Developer, true, true, 1_000_000).unwrap();
2232        let McpResult::ToolsList(list) = result else {
2233            panic!("Expected ToolsList result");
2234        };
2235        let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2236        assert!(
2237            tool_names.contains(&"query_system_catalog"),
2238            "query_system_catalog should always be present on developer"
2239        );
2240        assert!(
2241            tool_names.contains(&"query"),
2242            "query tool should be present on developer when enabled"
2243        );
2244    }
2245
2246    // ── Response size cap tests ────────────────────────────────────────
2247
2248    #[mz_ore::test]
2249    fn test_format_rows_response_within_limit() {
2250        let rows = vec![vec![json!("a"), json!(1)], vec![json!("b"), json!(2)]];
2251        let result = format_rows_response(rows, 1_000_000).unwrap();
2252        let McpResult::ToolContent(content) = result else {
2253            panic!("Expected ToolContent");
2254        };
2255        assert_eq!(content.content.len(), 1);
2256        assert!(content.content[0].text.contains("\"a\""));
2257        assert!(content.content[0].text.contains("\"b\""));
2258    }
2259
2260    #[mz_ore::test]
2261    fn test_format_rows_response_errors_when_over_limit() {
2262        let rows: Vec<Vec<serde_json::Value>> = (0..100)
2263            .map(|i| vec![json!(format!("row_{}", i)), json!(i)])
2264            .collect();
2265        let err = format_rows_response(rows, 500).unwrap_err();
2266        let msg = err.to_string();
2267        assert!(
2268            msg.contains("exceeds the 500 byte limit"),
2269            "Error should mention the size limit, got: {msg}"
2270        );
2271        assert!(
2272            msg.contains("Use LIMIT or WHERE"),
2273            "Error should suggest narrowing the query, got: {msg}"
2274        );
2275    }
2276
2277    #[mz_ore::test]
2278    fn test_format_rows_response_empty_rows() {
2279        let rows: Vec<Vec<serde_json::Value>> = vec![];
2280        let result = format_rows_response(rows, 1000).unwrap();
2281        let McpResult::ToolContent(content) = result else {
2282            panic!("Expected ToolContent");
2283        };
2284        assert_eq!(content.content.len(), 1);
2285        assert_eq!(content.content[0].text, "[]");
2286    }
2287
2288    // ── Data product name validation tests ─────────────────────────────
2289
2290    #[mz_ore::test]
2291    fn test_safe_data_product_name_valid() {
2292        // Fully qualified quoted identifiers
2293        assert_eq!(
2294            safe_data_product_name(r#""materialize"."public"."my_view""#).unwrap(),
2295            r#""materialize"."public"."my_view""#
2296        );
2297        // Two-part name
2298        assert_eq!(
2299            safe_data_product_name(r#""public"."my_view""#).unwrap(),
2300            r#""public"."my_view""#
2301        );
2302        // Unquoted name gets quoted in stable mode
2303        assert_eq!(safe_data_product_name("my_view").unwrap(), r#""my_view""#);
2304    }
2305
2306    #[mz_ore::test]
2307    fn test_safe_data_product_name_rejects_empty() {
2308        assert!(safe_data_product_name("").is_err());
2309        assert!(safe_data_product_name("   ").is_err());
2310    }
2311
2312    /// DEX-64: `parse_item_name` is unbounded on its own; the MCP path
2313    /// switches to `parse_item_name_with_limit` so a pathological name (e.g.
2314    /// millions of `(` characters) is rejected before lexing.
2315    #[mz_ore::test]
2316    fn test_safe_data_product_name_enforces_size_limit() {
2317        use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
2318        let oversized: String = "(".repeat(MAX_STATEMENT_BATCH_SIZE + 1);
2319        let err = safe_data_product_name(&oversized).expect_err("should be rejected");
2320        let msg = err.to_string();
2321        assert!(
2322            msg.contains("statement batch size cannot exceed"),
2323            "expected size-guard error, got: {msg}"
2324        );
2325    }
2326
2327    #[mz_ore::test]
2328    fn test_safe_data_product_name_rejects_sql_injection() {
2329        // Attempted injection via semicolon
2330        assert!(safe_data_product_name("my_view; DROP TABLE users").is_err());
2331        // Attempted injection via subquery
2332        assert!(safe_data_product_name("my_view UNION SELECT * FROM secrets").is_err());
2333        // Multiple table references via comma
2334        assert!(safe_data_product_name("my_view, secrets").is_err());
2335        // SQL keywords after name are rejected by the parser
2336        assert!(safe_data_product_name("my_view WHERE 1=1 --").is_err());
2337    }
2338
2339    /// A trailing `--` comment in the body must not swallow the `; COMMIT;`.
2340    #[mz_ore::test]
2341    fn test_read_only_txn_comment_cannot_swallow_commit() {
2342        let sql = read_only_txn("SET CLUSTER = 'c'", "SELECT 1 --");
2343        assert!(
2344            sql.contains("\n; COMMIT;"),
2345            "COMMIT must sit on its own line: {sql}",
2346        );
2347    }
2348
2349    // ── query_set_clause tests (QAR-136) ───────────────────────────────
2350
2351    /// Without a replica, only `SET CLUSTER` is emitted.
2352    #[mz_ore::test]
2353    fn test_query_set_clause_without_replica() {
2354        let clause = query_set_clause("prod_cluster", None);
2355        assert_eq!(clause, "SET CLUSTER = 'prod_cluster'");
2356    }
2357
2358    /// With a replica, `SET CLUSTER_REPLICA` follows `SET CLUSTER`, scoping
2359    /// the read to one replica (what makes EXPLAIN ANALYZE usable on
2360    /// clusters with multiple replicas).
2361    #[mz_ore::test]
2362    fn test_query_set_clause_with_replica() {
2363        let clause = query_set_clause("prod_cluster", Some("r1"));
2364        assert_eq!(
2365            clause,
2366            "SET CLUSTER = 'prod_cluster'; SET CLUSTER_REPLICA = 'r1'"
2367        );
2368    }
2369
2370    /// Replica names are interpolated into a SQL string literal, so they get
2371    /// the same escaping treatment as cluster names. Defends against
2372    /// injection via adversarial replica names.
2373    #[mz_ore::test]
2374    fn test_query_set_clause_escapes_replica_name() {
2375        let clause = query_set_clause("c", Some("evil'; DROP TABLE secrets; --"));
2376        assert_eq!(
2377            clause,
2378            "SET CLUSTER = 'c'; SET CLUSTER_REPLICA = 'evil''; DROP TABLE secrets; --'"
2379        );
2380    }
2381
2382    /// A `None` replica (no pinning requested) is always valid, and a normal
2383    /// replica name passes validation.
2384    #[mz_ore::test]
2385    fn test_validate_cluster_replica_accepts_none_and_names() {
2386        assert!(validate_cluster_replica(None).is_ok());
2387        assert!(validate_cluster_replica(Some("r1")).is_ok());
2388    }
2389
2390    /// An empty or whitespace-only replica name is rejected as a validation
2391    /// error up front, rather than producing `SET CLUSTER_REPLICA = ''` that
2392    /// fails deep in the engine as a generic execution error.
2393    #[mz_ore::test]
2394    fn test_validate_cluster_replica_rejects_empty() {
2395        for name in ["", "   ", "\t\n"] {
2396            assert!(
2397                matches!(
2398                    validate_cluster_replica(Some(name)),
2399                    Err(McpRequestError::QueryValidationFailed(_))
2400                ),
2401                "expected validation error for {name:?}",
2402            );
2403        }
2404    }
2405
2406    // ── build_read_query tests (DEX-27) ────────────────────────────────
2407
2408    /// The read is wrapped in a `BEGIN READ ONLY` transaction so the
2409    /// `SET CLUSTER` scope is bounded to this read and does not leak
2410    /// into the rest of the session.
2411    #[mz_ore::test]
2412    fn test_build_read_query_with_cluster() {
2413        let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, Some("prod_cluster"));
2414        assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
2415        assert!(sql.contains("SET CLUSTER = 'prod_cluster'"), "{sql}");
2416        assert!(
2417            sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
2418            "{sql}",
2419        );
2420        assert!(sql.contains("COMMIT"), "{sql}");
2421    }
2422
2423    /// With no cluster (the role lacks USAGE on the object's cluster, DEX-66),
2424    /// the read omits `SET CLUSTER` and runs on the session's serving cluster.
2425    #[mz_ore::test]
2426    fn test_build_read_query_without_cluster() {
2427        let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, None);
2428        assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
2429        assert!(!sql.contains("SET CLUSTER"), "{sql}");
2430        assert!(
2431            sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
2432            "{sql}",
2433        );
2434        assert!(sql.contains("COMMIT"), "{sql}");
2435    }
2436
2437    /// Cluster names with single quotes and backslashes must be escaped
2438    /// the same way as the `name` argument, since they end up interpolated
2439    /// into a SQL string literal. Defends against catalog-injection or
2440    /// adversarial cluster names.
2441    #[mz_ore::test]
2442    fn test_build_read_query_escapes_cluster_name() {
2443        let sql = build_read_query(
2444            "\"db\".\"sch\".\"v\"",
2445            10,
2446            Some("evil'; DROP TABLE secrets; --"),
2447        );
2448        // The single quote in `evil'` must be doubled inside the literal.
2449        assert!(
2450            sql.contains("SET CLUSTER = 'evil''; DROP TABLE secrets; --'"),
2451            "single quote should be doubled inside the literal: {sql}",
2452        );
2453        // The injected `DROP TABLE` must remain inside the literal — i.e.
2454        // there is no second statement that escapes the SET CLUSTER call.
2455        assert_eq!(
2456            sql.matches("SET CLUSTER").count(),
2457            1,
2458            "exactly one SET CLUSTER statement: {sql}",
2459        );
2460        assert_eq!(
2461            sql.matches("DROP TABLE").count(),
2462            1,
2463            "DROP TABLE should appear once, inside the quoted literal: {sql}",
2464        );
2465    }
2466
2467    #[mz_ore::test]
2468    fn test_mcp_error_codes() {
2469        assert_eq!(
2470            McpRequestError::InvalidJsonRpcVersion.error_code(),
2471            error_codes::INVALID_REQUEST
2472        );
2473        assert_eq!(
2474            McpRequestError::MethodNotFound("test".to_string()).error_code(),
2475            error_codes::METHOD_NOT_FOUND
2476        );
2477        assert_eq!(
2478            McpRequestError::QueryExecutionFailed("test".to_string()).error_code(),
2479            error_codes::INTERNAL_ERROR
2480        );
2481    }
2482}