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