1use std::sync::Arc;
29
30use anyhow::anyhow;
31use axum::Extension;
32use axum::Json;
33use axum::response::IntoResponse;
34use http::{HeaderMap, HeaderValue, StatusCode};
35use mz_adapter_types::dyncfgs::{
36 ENABLE_MCP_AGENT, ENABLE_MCP_AGENT_QUERY_TOOL, ENABLE_MCP_AGENT_READ_DATA_PRODUCT_TOOL,
37 ENABLE_MCP_DEVELOPER, ENABLE_MCP_DEVELOPER_QUERY_TOOL, MCP_MAX_RESPONSE_SIZE,
38 MCP_REQUEST_TIMEOUT,
39};
40use mz_ore::cast::CastLossy;
41use mz_repr::namespaces::{self, SYSTEM_SCHEMAS};
42use mz_sql::parse::{parse_item_name_with_limit, parse_with_limit};
43use mz_sql::session::metadata::SessionMetadata;
44use mz_sql::session::vars::{APPLICATION_NAME, Var, VarInput};
45use mz_sql_parser::ast::display::{AstDisplay, escaped_string_literal};
46use mz_sql_parser::ast::visit::{self, Visit};
47use mz_sql_parser::ast::{Raw, RawItemName};
48use serde::{Deserialize, Serialize};
49use serde_json::json;
50use thiserror::Error;
51use tracing::{debug, warn};
52
53use crate::http::AuthedClient;
54use crate::http::mcp_metrics::{McpCallStatus, McpMetrics, ToolCallGuard};
55use crate::http::sql::{SqlRequest, SqlResponse, SqlResult, execute_request};
56
57const JSONRPC_VERSION: &str = "2.0";
61
62const MCP_PROTOCOL_VERSION: &str = "2025-11-25";
65
66const DISCOVERY_QUERY: &str = "SELECT * FROM mz_internal.mz_mcp_data_products";
68const DETAILS_QUERY_PREFIX: &str =
69 "SELECT * FROM mz_internal.mz_mcp_data_product_details WHERE object_name = ";
70
71#[derive(Debug, Error)]
73enum McpRequestError {
74 #[error("Invalid JSON-RPC version: expected 2.0")]
75 InvalidJsonRpcVersion,
76 #[error("Method not found: {0}")]
77 MethodNotFound(String),
78 #[error("Tool not found: {0}")]
79 ToolNotFound(String),
80 #[error("Data product not found: {0}")]
81 DataProductNotFound(String),
82 #[error("Query validation failed: {0}")]
83 QueryValidationFailed(String),
84 #[error("Query execution failed: {0}")]
85 QueryExecutionFailed(String),
86 #[error("Internal error: {0}")]
87 Internal(#[from] anyhow::Error),
88}
89
90impl McpRequestError {
91 fn error_code(&self) -> i32 {
92 match self {
93 Self::InvalidJsonRpcVersion => error_codes::INVALID_REQUEST,
94 Self::MethodNotFound(_) => error_codes::METHOD_NOT_FOUND,
95 Self::ToolNotFound(_) => error_codes::INVALID_PARAMS,
96 Self::DataProductNotFound(_) => error_codes::INVALID_PARAMS,
97 Self::QueryValidationFailed(_) => error_codes::INVALID_PARAMS,
98 Self::QueryExecutionFailed(_) | Self::Internal(_) => error_codes::INTERNAL_ERROR,
99 }
100 }
101
102 fn error_type(&self) -> &'static str {
103 match self {
104 Self::InvalidJsonRpcVersion => "InvalidRequest",
105 Self::MethodNotFound(_) => "MethodNotFound",
106 Self::ToolNotFound(_) => "ToolNotFound",
107 Self::DataProductNotFound(_) => "DataProductNotFound",
108 Self::QueryValidationFailed(_) => "ValidationError",
109 Self::QueryExecutionFailed(_) => "ExecutionError",
110 Self::Internal(_) => "InternalError",
111 }
112 }
113}
114
115#[derive(Debug, Deserialize)]
117pub(crate) struct McpRequest {
118 jsonrpc: String,
119 id: Option<serde_json::Value>,
120 #[serde(flatten)]
121 method: McpMethod,
122}
123
124#[derive(Debug, Deserialize)]
126#[serde(tag = "method", content = "params")]
127enum McpMethod {
128 #[serde(rename = "initialize")]
130 Initialize(#[allow(dead_code)] InitializeParams),
131 #[serde(rename = "tools/list")]
132 ToolsList,
133 #[serde(rename = "tools/call")]
134 ToolsCall(ToolsCallParams),
135 #[serde(other)]
137 Unknown,
138}
139
140impl std::fmt::Display for McpMethod {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 match self {
143 McpMethod::Initialize(_) => write!(f, "initialize"),
144 McpMethod::ToolsList => write!(f, "tools/list"),
145 McpMethod::ToolsCall(_) => write!(f, "tools/call"),
146 McpMethod::Unknown => write!(f, "unknown"),
147 }
148 }
149}
150
151#[derive(Debug, Deserialize)]
152struct InitializeParams {
153 #[serde(rename = "protocolVersion")]
155 #[allow(dead_code)]
156 protocol_version: String,
157 #[serde(default)]
159 #[allow(dead_code)]
160 capabilities: serde_json::Value,
161 #[serde(rename = "clientInfo")]
163 #[allow(dead_code)]
164 client_info: Option<ClientInfo>,
165}
166
167#[derive(Debug, Deserialize)]
168struct ClientInfo {
169 #[allow(dead_code)]
170 name: String,
171 #[allow(dead_code)]
172 version: String,
173}
174
175#[derive(Debug, Deserialize)]
178#[serde(tag = "name", content = "arguments")]
179#[serde(rename_all = "snake_case")]
180enum ToolsCallParams {
181 GetDataProducts(#[serde(default)] ()),
184 GetDataProductDetails(GetDataProductDetailsParams),
185 ReadDataProduct(ReadDataProductParams),
186 Query(QueryParams),
187 QuerySystemCatalog(QuerySystemCatalogParams),
189}
190
191impl std::fmt::Display for ToolsCallParams {
192 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193 match self {
194 ToolsCallParams::GetDataProducts(_) => write!(f, "get_data_products"),
195 ToolsCallParams::GetDataProductDetails(_) => write!(f, "get_data_product_details"),
196 ToolsCallParams::ReadDataProduct(_) => write!(f, "read_data_product"),
197 ToolsCallParams::Query(_) => write!(f, "query"),
198 ToolsCallParams::QuerySystemCatalog(_) => write!(f, "query_system_catalog"),
199 }
200 }
201}
202
203#[derive(Debug, Deserialize)]
204struct GetDataProductDetailsParams {
205 name: String,
206}
207
208#[derive(Debug, Deserialize)]
209struct ReadDataProductParams {
210 name: String,
211 #[serde(default = "default_read_limit")]
212 limit: u32,
213 cluster: Option<String>,
214}
215
216const DEFAULT_READ_LIMIT: u32 = 500;
218
219fn default_read_limit() -> u32 {
220 DEFAULT_READ_LIMIT
221}
222
223#[derive(Debug, Deserialize)]
224struct QueryParams {
225 cluster: String,
226 cluster_replica: Option<String>,
229 sql_query: String,
230}
231
232#[derive(Debug, Deserialize)]
233struct QuerySystemCatalogParams {
234 sql_query: String,
235}
236
237#[derive(Debug, Serialize)]
238struct McpResponse {
239 jsonrpc: String,
240 id: serde_json::Value,
241 #[serde(skip_serializing_if = "Option::is_none")]
242 result: Option<McpResult>,
243 #[serde(skip_serializing_if = "Option::is_none")]
244 error: Option<McpError>,
245}
246
247impl McpResponse {
248 fn success(id: serde_json::Value, result: McpResult) -> Self {
250 Self {
251 jsonrpc: JSONRPC_VERSION.to_string(),
252 id,
253 result: Some(result),
254 error: None,
255 }
256 }
257
258 fn error(id: serde_json::Value, error: McpError) -> Self {
260 Self {
261 jsonrpc: JSONRPC_VERSION.to_string(),
262 id,
263 result: None,
264 error: Some(error),
265 }
266 }
267}
268
269#[derive(Debug, Serialize)]
271#[serde(untagged)]
272enum McpResult {
273 Initialize(InitializeResult),
274 ToolsList(ToolsListResult),
275 ToolContent(ToolContentResult),
276}
277
278#[derive(Debug, Serialize)]
279struct InitializeResult {
280 #[serde(rename = "protocolVersion")]
281 protocol_version: String,
282 capabilities: Capabilities,
283 #[serde(rename = "serverInfo")]
284 server_info: ServerInfo,
285 #[serde(skip_serializing_if = "Option::is_none")]
286 instructions: Option<String>,
287}
288
289#[derive(Debug, Serialize)]
290struct Capabilities {
291 tools: serde_json::Value,
292}
293
294#[derive(Debug, Serialize)]
295struct ServerInfo {
296 name: String,
297 version: String,
298}
299
300#[derive(Debug, Serialize)]
301struct ToolsListResult {
302 tools: Vec<ToolDefinition>,
303}
304
305#[derive(Debug, Serialize)]
306struct ToolDefinition {
307 name: String,
308 #[serde(skip_serializing_if = "Option::is_none")]
309 title: Option<String>,
310 description: String,
311 #[serde(rename = "inputSchema")]
312 input_schema: serde_json::Value,
313 #[serde(skip_serializing_if = "Option::is_none")]
314 annotations: Option<ToolAnnotations>,
315}
316
317#[derive(Debug, Serialize)]
320struct ToolAnnotations {
321 #[serde(rename = "readOnlyHint", skip_serializing_if = "Option::is_none")]
322 read_only_hint: Option<bool>,
323 #[serde(rename = "destructiveHint", skip_serializing_if = "Option::is_none")]
324 destructive_hint: Option<bool>,
325 #[serde(rename = "idempotentHint", skip_serializing_if = "Option::is_none")]
326 idempotent_hint: Option<bool>,
327 #[serde(rename = "openWorldHint", skip_serializing_if = "Option::is_none")]
328 open_world_hint: Option<bool>,
329}
330
331const READ_ONLY_ANNOTATIONS: ToolAnnotations = ToolAnnotations {
333 read_only_hint: Some(true),
334 destructive_hint: Some(false),
335 idempotent_hint: Some(true),
336 open_world_hint: Some(false),
337};
338
339#[derive(Debug, Serialize)]
340struct ToolContentResult {
341 content: Vec<ContentBlock>,
342 #[serde(rename = "isError")]
344 is_error: bool,
345}
346
347#[derive(Debug, Serialize)]
348struct ContentBlock {
349 #[serde(rename = "type")]
350 content_type: String,
351 text: String,
352}
353
354mod error_codes {
356 pub const INVALID_REQUEST: i32 = -32600;
357 pub const METHOD_NOT_FOUND: i32 = -32601;
358 pub const INVALID_PARAMS: i32 = -32602;
359 pub const INTERNAL_ERROR: i32 = -32603;
360}
361
362#[derive(Debug, Serialize)]
363struct McpError {
364 code: i32,
365 message: String,
366 #[serde(skip_serializing_if = "Option::is_none")]
367 data: Option<serde_json::Value>,
368}
369
370impl From<McpRequestError> for McpError {
371 fn from(err: McpRequestError) -> Self {
372 McpError {
373 code: err.error_code(),
374 message: err.to_string(),
375 data: Some(json!({
376 "error_type": err.error_type(),
377 })),
378 }
379 }
380}
381
382#[derive(Debug, Clone, Copy)]
383enum McpEndpointType {
384 Agent,
385 Developer,
386}
387
388impl McpEndpointType {
389 fn as_label(self) -> &'static str {
392 match self {
393 McpEndpointType::Agent => "agent",
394 McpEndpointType::Developer => "developer",
395 }
396 }
397}
398
399impl std::fmt::Display for McpEndpointType {
400 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
401 f.write_str(self.as_label())
402 }
403}
404
405pub async fn handle_mcp_method_not_allowed() -> impl IntoResponse {
408 StatusCode::METHOD_NOT_ALLOWED
409}
410
411pub async fn handle_mcp_agent(
413 headers: HeaderMap,
414 Extension(allowed_origins): Extension<Arc<Vec<HeaderValue>>>,
415 Extension(metrics): Extension<McpMetrics>,
416 client: AuthedClient,
417 Json(body): Json<McpRequest>,
418) -> axum::response::Response {
419 if let Some(resp) = validate_origin(&headers, &allowed_origins) {
420 return resp;
421 }
422 handle_mcp_request(client, body, McpEndpointType::Agent, metrics)
423 .await
424 .into_response()
425}
426
427pub async fn handle_mcp_developer(
429 headers: HeaderMap,
430 Extension(allowed_origins): Extension<Arc<Vec<HeaderValue>>>,
431 Extension(metrics): Extension<McpMetrics>,
432 client: AuthedClient,
433 Json(body): Json<McpRequest>,
434) -> axum::response::Response {
435 if let Some(resp) = validate_origin(&headers, &allowed_origins) {
436 return resp;
437 }
438 handle_mcp_request(client, body, McpEndpointType::Developer, metrics)
439 .await
440 .into_response()
441}
442
443fn validate_origin(
452 headers: &HeaderMap,
453 allowed: &[HeaderValue],
454) -> Option<axum::response::Response> {
455 let origin = headers.get(http::header::ORIGIN)?;
456 if mz_http_util::origin_is_allowed(origin, allowed) {
457 return None;
458 }
459 warn!(
460 origin = ?origin,
461 "MCP request rejected: origin not in allowlist",
462 );
463 Some(StatusCode::FORBIDDEN.into_response())
464}
465
466async fn handle_mcp_request(
467 mut client: AuthedClient,
468 request: McpRequest,
469 endpoint_type: McpEndpointType,
470 metrics: McpMetrics,
471) -> impl IntoResponse {
472 let endpoint_label = endpoint_type.as_label();
473 let method_label = request.method.to_string();
474 let record_request =
475 |status: McpCallStatus| metrics.record_request(endpoint_label, &method_label, status);
476
477 let catalog = match tokio::time::timeout(
482 *MCP_REQUEST_TIMEOUT.default(),
483 client.client.catalog_snapshot("mcp"),
484 )
485 .await
486 {
487 Ok(catalog) => catalog,
488 Err(_elapsed) => {
489 warn!(endpoint = %endpoint_type, "MCP catalog snapshot timed out");
490 record_request(McpCallStatus::Timeout);
491 return StatusCode::SERVICE_UNAVAILABLE.into_response();
492 }
493 };
494 let dyncfgs = catalog.system_config().dyncfgs();
495 let enabled = match endpoint_type {
496 McpEndpointType::Agent => ENABLE_MCP_AGENT.get(dyncfgs),
497 McpEndpointType::Developer => ENABLE_MCP_DEVELOPER.get(dyncfgs),
498 };
499 if !enabled {
500 debug!(endpoint = %endpoint_type, "MCP endpoint disabled by feature flag");
501 record_request(McpCallStatus::EndpointDisabled);
502 return StatusCode::SERVICE_UNAVAILABLE.into_response();
503 }
504
505 let query_tool_enabled = match endpoint_type {
510 McpEndpointType::Agent => ENABLE_MCP_AGENT_QUERY_TOOL.get(dyncfgs),
511 McpEndpointType::Developer => ENABLE_MCP_DEVELOPER_QUERY_TOOL.get(dyncfgs),
512 };
513 let read_data_product_tool_enabled = ENABLE_MCP_AGENT_READ_DATA_PRODUCT_TOOL.get(dyncfgs);
517 let max_response_size = MCP_MAX_RESPONSE_SIZE.get(dyncfgs);
518 let request_timeout = MCP_REQUEST_TIMEOUT.get(dyncfgs);
519
520 let app_name = match endpoint_type {
524 McpEndpointType::Agent => "mz_mcp_agents",
525 McpEndpointType::Developer => "mz_mcp_developer",
526 };
527 client
528 .client
529 .session()
530 .vars_mut()
531 .set_default(APPLICATION_NAME.name(), VarInput::Flat(app_name))
532 .expect("application_name is a known session var");
533
534 let user = client.client.session().user().name.clone();
535 let is_notification = request.id.is_none();
536
537 debug!(
538 method = %request.method,
539 endpoint = %endpoint_type,
540 user = %user,
541 is_notification = is_notification,
542 "MCP request received"
543 );
544
545 if is_notification {
547 debug!(method = %request.method, "Received notification (no response will be sent)");
548 record_request(McpCallStatus::Ok);
549 return StatusCode::OK.into_response();
550 }
551
552 let request_id = request.id.clone().unwrap_or(serde_json::Value::Null);
553
554 let metrics_inner = metrics.clone();
559 let result = tokio::time::timeout(
560 request_timeout,
561 mz_ore::task::spawn(|| "mcp_request", async move {
562 handle_mcp_request_inner(
563 &mut client,
564 request,
565 endpoint_type,
566 query_tool_enabled,
567 read_data_product_tool_enabled,
568 max_response_size,
569 metrics_inner,
570 )
571 .await
572 })
573 .abort_on_drop(),
574 )
575 .await;
576
577 let (response, status_label): (McpResponse, McpCallStatus) = match result {
578 Ok(inner) => inner,
579 Err(_elapsed) => {
580 warn!(
581 endpoint = %endpoint_type,
582 timeout = ?request_timeout,
583 "MCP request timed out",
584 );
585 let response = McpResponse::error(
586 request_id,
587 McpRequestError::QueryExecutionFailed(format!(
588 "Request timed out after {} seconds.",
589 request_timeout.as_secs(),
590 ))
591 .into(),
592 );
593 (response, McpCallStatus::Timeout)
594 }
595 };
596
597 record_request(status_label);
598 (StatusCode::OK, Json(response)).into_response()
599}
600
601async fn handle_mcp_request_inner(
602 client: &mut AuthedClient,
603 request: McpRequest,
604 endpoint_type: McpEndpointType,
605 query_tool_enabled: bool,
606 read_data_product_tool_enabled: bool,
607 max_response_size: usize,
608 metrics: McpMetrics,
609) -> (McpResponse, McpCallStatus) {
610 let request_id = request.id.clone().unwrap_or(serde_json::Value::Null);
612
613 let result = handle_mcp_method(
614 client,
615 &request,
616 endpoint_type,
617 query_tool_enabled,
618 read_data_product_tool_enabled,
619 max_response_size,
620 &metrics,
621 )
622 .await;
623
624 let status_label = call_status(&result);
625
626 let response = match result {
627 Ok(result_value) => McpResponse::success(request_id, result_value),
628 Err(e) => {
629 if !matches!(
631 e,
632 McpRequestError::MethodNotFound(_) | McpRequestError::InvalidJsonRpcVersion
633 ) {
634 warn!(error = %e, method = %request.method, "MCP method execution failed");
635 }
636 McpResponse::error(request_id, e.into())
637 }
638 };
639
640 (response, status_label)
641}
642
643async fn handle_mcp_method(
644 client: &mut AuthedClient,
645 request: &McpRequest,
646 endpoint_type: McpEndpointType,
647 query_tool_enabled: bool,
648 read_data_product_tool_enabled: bool,
649 max_response_size: usize,
650 metrics: &McpMetrics,
651) -> Result<McpResult, McpRequestError> {
652 if request.jsonrpc != JSONRPC_VERSION {
654 return Err(McpRequestError::InvalidJsonRpcVersion);
655 }
656
657 match &request.method {
659 McpMethod::Initialize(_) => {
660 debug!(endpoint = %endpoint_type, "Processing initialize");
661 handle_initialize(
662 endpoint_type,
663 query_tool_enabled,
664 read_data_product_tool_enabled,
665 )
666 }
667 McpMethod::ToolsList => {
668 debug!(endpoint = %endpoint_type, "Processing tools/list");
669 handle_tools_list(
670 endpoint_type,
671 query_tool_enabled,
672 read_data_product_tool_enabled,
673 max_response_size,
674 )
675 }
676 McpMethod::ToolsCall(params) => {
677 debug!(tool = %params, endpoint = %endpoint_type, "Processing tools/call");
678 handle_tools_call(
679 client,
680 params,
681 endpoint_type,
682 query_tool_enabled,
683 read_data_product_tool_enabled,
684 max_response_size,
685 metrics,
686 )
687 .await
688 }
689 McpMethod::Unknown => Err(McpRequestError::MethodNotFound(
690 "unknown method".to_string(),
691 )),
692 }
693}
694
695fn endpoint_instructions(
698 endpoint_type: McpEndpointType,
699 query_tool_enabled: bool,
700 read_data_product_tool_enabled: bool,
701) -> Option<String> {
702 match endpoint_type {
703 McpEndpointType::Agent => {
704 let read_paragraph = match (read_data_product_tool_enabled, query_tool_enabled) {
709 (true, _) => {
710 "`read_data_product` automatically routes the \
711 read to the cluster recorded in the data product catalog so indexes are used; \
712 you only need to set the `cluster` parameter if you intentionally want the \
713 read to run on a different cluster (e.g. one with larger or more replicas). \
714 A null `cluster` in discovery means your role lacks USAGE on the object's \
715 index/compute cluster; `read_data_product` without an override then reads on \
716 your session's default cluster (safe: only materialized views appear this way, \
717 and they serve from persist). "
718 }
719 (false, true) => {
720 "Use the `query` tool to read data products, passing the cluster from \
721 `get_data_product_details` so indexed reads hit the arrangement. If \
722 `get_data_product_details` returns a null `cluster`, your role lacks USAGE on \
723 the object's index/compute cluster; run the `query` against any cluster your \
724 role can use (the read still works, just without the index arrangement). "
725 }
726 (false, false) => {
727 "This server is configured for discovery only: no read tool is exposed. \
728 Use `get_data_products` and `get_data_product_details` to inspect what \
729 is available. "
730 }
731 };
732 Some(format!(
733 "You have access to Materialize data products via MCP. \
734 Prefer indexed objects (served from memory) over unindexed materialized views \
735 (read from persistent storage). {read_paragraph}\
736 `get_data_product_details` returns a `hydration` object with `hydrated`, \
737 `replica_count`, and `hydrated_replica_count` fields. Reads never return \
738 partial data: a read against a not-yet-hydrated product blocks until the \
739 dataflow catches up, and may hit the request timeout. Check `hydrated` \
740 before reading: if it is false and `replica_count` is greater than 0, the \
741 dataflow is still warming up, so wait and retry; if `replica_count` is 0 the \
742 cluster has no replicas and the read cannot make progress until one is added.",
743 ))
744 }
745 McpEndpointType::Developer => {
746 let query_tool_line = if query_tool_enabled {
750 "- query: read-only SELECT/SHOW/EXPLAIN that can also reach user objects on a named cluster. Use this for EXPLAIN ANALYZE and for inspecting user objects directly.\n"
751 } else {
752 ""
753 };
754 Some(format!(
755 "You are connected to the Materialize developer MCP server for troubleshooting and observability.\n\n\
756 Tools:\n\
757 - query_system_catalog: read-only SELECT/SHOW/EXPLAIN restricted to system catalog tables (mz_*, pg_catalog, information_schema). No cluster argument; prefer this for most catalog lookups.\n\
758 {query_tool_line}\n\
759 IMPORTANT: Before writing queries, discover table schemas using the mz_ontology tables:\n\
760 - mz_internal.mz_ontology_entity_types: what catalog entities exist and which tables they map to\n\
761 - mz_internal.mz_ontology_link_types: relationships between entities (foreign keys, metrics, etc.)\n\
762 - mz_internal.mz_ontology_properties: column names, types, and descriptions for each entity\n\
763 - mz_internal.mz_ontology_semantic_types: typed ID domains (CatalogItemId, ReplicaId, etc.)\n\n\
764 Use these to find the correct tables, join paths, and column names instead of guessing.\n\n\
765 Key rules:\n\
766 - mz_source_statuses and mz_sink_statuses use `last_status_change_at` (NOT `updated_at`)\n\
767 - mz_cluster_replica_utilization only has `replica_id` — JOIN with mz_cluster_replicas and mz_clusters to get names\n\
768 - Do NOT query mz_introspection.mz_dataflow_arrangement_sizes — it is cluster-scoped and has uint8/text type mismatches\n\
769 - Use SHOW COLUMNS FROM <table> to verify column names if unsure",
770 ))
771 }
772 }
773}
774
775fn handle_initialize(
776 endpoint_type: McpEndpointType,
777 query_tool_enabled: bool,
778 read_data_product_tool_enabled: bool,
779) -> Result<McpResult, McpRequestError> {
780 Ok(McpResult::Initialize(InitializeResult {
781 protocol_version: MCP_PROTOCOL_VERSION.to_string(),
782 capabilities: Capabilities { tools: json!({}) },
783 server_info: ServerInfo {
784 name: format!("materialize-mcp-{}", endpoint_type),
785 version: env!("CARGO_PKG_VERSION").to_string(),
786 },
787 instructions: endpoint_instructions(
788 endpoint_type,
789 query_tool_enabled,
790 read_data_product_tool_enabled,
791 ),
792 }))
793}
794
795fn handle_tools_list(
796 endpoint_type: McpEndpointType,
797 query_tool_enabled: bool,
798 read_data_product_tool_enabled: bool,
799 max_response_size: usize,
800) -> Result<McpResult, McpRequestError> {
801 let size_hint = format!(
802 "Response limit: {:.1} MB.",
803 f64::cast_lossy(max_response_size) / 1_000_000.0
804 );
805
806 let tools = match endpoint_type {
807 McpEndpointType::Agent => {
808 let mut tools = vec![
809 ToolDefinition {
810 name: "get_data_products".to_string(),
811 title: Some("List Data Products".to_string()),
812 description: "Discover all available real-time data views (data products) that represent business entities like customers, orders, products, etc. Each data product provides fresh, queryable data with defined schemas. Use this first to see what data is available before querying specific information.".to_string(),
813 input_schema: json!({
814 "type": "object",
815 "properties": {},
816 "required": []
817 }),
818 annotations: Some(READ_ONLY_ANNOTATIONS),
819 },
820 ToolDefinition {
821 name: "get_data_product_details".to_string(),
822 title: Some("Get Data Product Details".to_string()),
823 description: "Get the complete schema and structure of a specific data product, plus a `hydration` object reporting whether the dataflow is ready across the cluster's replicas (`{hydrated, replica_count, hydrated_replica_count}`). This shows you exactly what fields are available, their types, and what data you can query. Reads never return partial data, so check `hydration` before reading: if `hydrated` is false and `replica_count` is greater than 0 the dataflow is still warming up (a read would block until it catches up, possibly hitting the request timeout), so wait and retry; if `replica_count` is 0 the cluster has no replicas and the read cannot make progress until one is added.".to_string(),
824 input_schema: json!({
825 "type": "object",
826 "properties": {
827 "name": {
828 "type": "string",
829 "description": "Exact name of the data product from get_data_products() list"
830 }
831 },
832 "required": ["name"]
833 }),
834 annotations: Some(READ_ONLY_ANNOTATIONS),
835 },
836 ];
837 if read_data_product_tool_enabled {
838 tools.push(ToolDefinition {
839 name: "read_data_product".to_string(),
840 title: Some("Read Data Product".to_string()),
841 description: format!("Read rows from a specific data product. Returns up to `limit` rows (default {DEFAULT_READ_LIMIT}). The data product must exist in the catalog (use get_data_products() to discover available products). Use this to retrieve actual data from a known data product. {size_hint}"),
842 input_schema: json!({
843 "type": "object",
844 "properties": {
845 "name": {
846 "type": "string",
847 "description": "Exact fully-qualified name of the data product (e.g. '\"materialize\".\"schema\".\"view_name\"')"
848 },
849 "limit": {
850 "type": "integer",
851 "description": format!("Maximum number of rows to return (default {DEFAULT_READ_LIMIT})"),
852 "default": DEFAULT_READ_LIMIT
853 },
854 "cluster": {
855 "type": "string",
856 "description": "Optional override. By default, the read runs on the cluster recorded in the data product catalog (where the index or materialized view dataflow lives), so indexed reads actually hit their arrangement. A null `cluster` in discovery means your role lacks USAGE on the object's index/compute cluster; without an override, the read then runs on your session's default cluster (safe: only materialized views appear this way, and they serve from persist). Set this only to intentionally run the same read on a different cluster — e.g. one with more or larger replicas, or to compare cost/latency."
857 }
858 },
859 "required": ["name"]
860 }),
861 annotations: Some(READ_ONLY_ANNOTATIONS),
862 });
863 }
864 if query_tool_enabled {
865 tools.push(ToolDefinition {
866 name: "query".to_string(),
867 title: Some("Query Data Products".to_string()),
868 description: format!("Execute SQL queries against real-time data products to retrieve current business information. Use standard PostgreSQL syntax. You can JOIN multiple data products together, but ONLY if they are all hosted on the same cluster. Always specify the cluster parameter from the data product details. This provides fresh, up-to-date results from materialized views. {size_hint}"),
869 input_schema: json!({
870 "type": "object",
871 "properties": {
872 "cluster": {
873 "type": "string",
874 "description": "Exact cluster name from the data product details - required for query execution"
875 },
876 "sql_query": {
877 "type": "string",
878 "description": "PostgreSQL-compatible SELECT statement to retrieve data. Use the fully qualified data product name exactly as provided (with double quotes). You can JOIN multiple data products, but only those on the same cluster."
879 }
880 },
881 "required": ["cluster", "sql_query"]
882 }),
883 annotations: Some(READ_ONLY_ANNOTATIONS),
884 });
885 }
886 tools
887 }
888 McpEndpointType::Developer => {
889 let mut tools = vec![ToolDefinition {
890 name: "query_system_catalog".to_string(),
891 title: Some("Query System Catalog".to_string()),
892 description: concat!(
893 "Query Materialize system catalog tables for troubleshooting and observability. ",
894 "Only mz_*, pg_catalog, and information_schema tables are accessible. ",
895 "Use the mz_internal.mz_ontology_* tables to discover tables, columns, and join paths before writing queries.",
896 ).to_owned() + &format!(" {size_hint}"),
897 input_schema: json!({
898 "type": "object",
899 "properties": {
900 "sql_query": {
901 "type": "string",
902 "description": "PostgreSQL-compatible SELECT, SHOW, or EXPLAIN query referencing mz_*, pg_catalog, or information_schema tables"
903 }
904 },
905 "required": ["sql_query"]
906 }),
907 annotations: Some(READ_ONLY_ANNOTATIONS),
908 }];
909 if query_tool_enabled {
910 tools.push(ToolDefinition {
911 name: "query".to_string(),
912 title: Some("Query".to_string()),
913 description: format!(
914 "Execute a read-only SQL query (SELECT, SHOW, or EXPLAIN) against any object the role can access, including system catalog and user objects. Requires a cluster, which is what enables EXPLAIN ANALYZE and queries against indexed user objects. For pure system catalog lookups that do not need a cluster, prefer `query_system_catalog`. {size_hint}",
915 ),
916 input_schema: json!({
917 "type": "object",
918 "properties": {
919 "cluster": {
920 "type": "string",
921 "description": "Exact cluster name the query should run on. Required: EXPLAIN ANALYZE and queries against indexed user objects need a specific cluster to execute on."
922 },
923 "cluster_replica": {
924 "type": "string",
925 "description": "Optional replica name (e.g. 'r1') to target one replica of the cluster. Required for EXPLAIN ANALYZE on clusters with more than one replica. Find replica names in mz_catalog.mz_cluster_replicas."
926 },
927 "sql_query": {
928 "type": "string",
929 "description": "PostgreSQL-compatible SELECT, SHOW, or EXPLAIN statement. Multi-statement queries are rejected."
930 }
931 },
932 "required": ["cluster", "sql_query"]
933 }),
934 annotations: Some(READ_ONLY_ANNOTATIONS),
935 });
936 }
937 tools
938 }
939 };
940
941 Ok(McpResult::ToolsList(ToolsListResult { tools }))
942}
943
944async fn handle_tools_call(
945 client: &mut AuthedClient,
946 params: &ToolsCallParams,
947 endpoint_type: McpEndpointType,
948 query_tool_enabled: bool,
949 read_data_product_tool_enabled: bool,
950 max_response_size: usize,
951 metrics: &McpMetrics,
952) -> Result<McpResult, McpRequestError> {
953 let mut guard = ToolCallGuard::new(metrics, endpoint_type.as_label(), params.to_string());
955
956 let result = match (endpoint_type, params) {
957 (McpEndpointType::Agent, ToolsCallParams::GetDataProducts(_)) => {
958 get_data_products(client, max_response_size).await
959 }
960 (McpEndpointType::Agent, ToolsCallParams::GetDataProductDetails(p)) => {
961 get_data_product_details(client, &p.name, max_response_size).await
962 }
963 (McpEndpointType::Agent, ToolsCallParams::ReadDataProduct(_))
964 if !read_data_product_tool_enabled =>
965 {
966 Err(McpRequestError::ToolNotFound(
967 "read_data_product tool is not available. Use the query tool to read data products."
968 .to_string(),
969 ))
970 }
971 (McpEndpointType::Agent, ToolsCallParams::ReadDataProduct(p)) => {
972 read_data_product(
973 client,
974 &p.name,
975 p.limit,
976 p.cluster.as_deref(),
977 max_response_size,
978 )
979 .await
980 }
981 (McpEndpointType::Agent, ToolsCallParams::Query(_)) if !query_tool_enabled => {
982 Err(McpRequestError::ToolNotFound(
983 "query tool is not available. Use get_data_products, get_data_product_details, and read_data_product instead.".to_string(),
984 ))
985 }
986 (McpEndpointType::Agent, ToolsCallParams::Query(p)) => {
987 execute_query(client, &p.cluster, None, &p.sql_query, max_response_size).await
990 }
991 (McpEndpointType::Developer, ToolsCallParams::QuerySystemCatalog(p)) => {
992 query_system_catalog(client, &p.sql_query, max_response_size).await
993 }
994 (McpEndpointType::Developer, ToolsCallParams::Query(_)) if !query_tool_enabled => {
995 Err(McpRequestError::ToolNotFound(
996 "query tool is not available. Use query_system_catalog instead.".to_string(),
997 ))
998 }
999 (McpEndpointType::Developer, ToolsCallParams::Query(p)) => {
1000 execute_query(
1001 client,
1002 &p.cluster,
1003 p.cluster_replica.as_deref(),
1004 &p.sql_query,
1005 max_response_size,
1006 )
1007 .await
1008 }
1009 (endpoint, tool) => Err(McpRequestError::ToolNotFound(format!(
1011 "{} is not available on {} endpoint",
1012 tool, endpoint
1013 ))),
1014 };
1015
1016 guard.set_status(call_status(&result));
1017
1018 result
1019}
1020
1021fn call_status<T>(result: &Result<T, McpRequestError>) -> McpCallStatus {
1024 match result {
1025 Ok(_) => McpCallStatus::Ok,
1026 Err(e) => McpCallStatus::Error(e.error_type()),
1027 }
1028}
1029
1030async fn execute_sql(
1032 client: &mut AuthedClient,
1033 query: &str,
1034) -> Result<Vec<Vec<serde_json::Value>>, McpRequestError> {
1035 let mut response = SqlResponse::new();
1036
1037 execute_request(
1038 client,
1039 SqlRequest::Simple {
1040 query: mz_ore::sql::Sql::trusted_external_request(query.to_string()),
1041 },
1042 &mut response,
1043 )
1044 .await
1045 .map_err(|e| McpRequestError::QueryExecutionFailed(e.to_string()))?;
1046
1047 select_single_rows(response.results)
1048}
1049
1050fn select_single_rows(
1056 results: Vec<SqlResult>,
1057) -> Result<Vec<Vec<serde_json::Value>>, McpRequestError> {
1058 let mut rows = None;
1059 for result in results {
1060 match result {
1061 SqlResult::Rows { rows: r, .. } => {
1062 if rows.is_some() {
1063 return Err(McpRequestError::Internal(anyhow!(
1064 "MCP query returned multiple row-producing statements"
1065 )));
1066 }
1067 rows = Some(r);
1068 }
1069 SqlResult::Err { error, .. } => {
1070 return Err(McpRequestError::QueryExecutionFailed(error.message));
1071 }
1072 SqlResult::Ok { .. } => continue,
1073 }
1074 }
1075
1076 rows.ok_or_else(|| {
1077 McpRequestError::QueryExecutionFailed("Query did not return any results".to_string())
1078 })
1079}
1080
1081fn format_rows_response(
1088 rows: Vec<Vec<serde_json::Value>>,
1089 max_size: usize,
1090) -> Result<McpResult, McpRequestError> {
1091 let text =
1092 serde_json::to_string_pretty(&rows).map_err(|e| McpRequestError::Internal(anyhow!(e)))?;
1093
1094 if text.len() > max_size {
1095 return Err(McpRequestError::QueryExecutionFailed(format!(
1096 "Response size ({} bytes) exceeds the {} byte limit. \
1097 Use LIMIT or WHERE to narrow your query.",
1098 text.len(),
1099 max_size,
1100 )));
1101 }
1102
1103 Ok(McpResult::ToolContent(ToolContentResult {
1104 content: vec![ContentBlock {
1105 content_type: "text".to_string(),
1106 text,
1107 }],
1108 is_error: false,
1109 }))
1110}
1111
1112async fn get_data_products(
1113 client: &mut AuthedClient,
1114 max_response_size: usize,
1115) -> Result<McpResult, McpRequestError> {
1116 debug!("Executing get_data_products");
1117 let rows = execute_sql(client, DISCOVERY_QUERY).await?;
1118 debug!("get_data_products returned {} rows", rows.len());
1119
1120 format_rows_response(rows, max_response_size)
1121}
1122
1123async fn get_data_product_details(
1124 client: &mut AuthedClient,
1125 name: &str,
1126 max_response_size: usize,
1127) -> Result<McpResult, McpRequestError> {
1128 debug!(name = %name, "Executing get_data_product_details");
1129
1130 let query = format!("{}{}", DETAILS_QUERY_PREFIX, escaped_string_literal(name));
1131
1132 let rows = execute_sql(client, &query).await?;
1133
1134 if rows.is_empty() {
1135 return Err(McpRequestError::DataProductNotFound(name.to_string()));
1136 }
1137
1138 format_rows_response(rows, max_response_size)
1139}
1140
1141fn safe_data_product_name(name: &str) -> Result<String, McpRequestError> {
1148 let name = name.trim();
1149 if name.is_empty() {
1150 return Err(McpRequestError::QueryValidationFailed(
1151 "Data product name cannot be empty".to_string(),
1152 ));
1153 }
1154
1155 let parsed = parse_item_name_with_limit(name)
1158 .map_err(McpRequestError::QueryValidationFailed)?
1159 .map_err(|_| {
1160 McpRequestError::QueryValidationFailed(format!(
1161 "Invalid data product name: {}. Expected a valid object name, \
1162 e.g. '\"database\".\"schema\".\"name\"' or 'my_view'",
1163 name
1164 ))
1165 })?;
1166
1167 Ok(parsed.to_ast_string_stable())
1170}
1171
1172async fn read_data_product(
1191 client: &mut AuthedClient,
1192 name: &str,
1193 limit: u32,
1194 cluster_override: Option<&str>,
1195 max_response_size: usize,
1196) -> Result<McpResult, McpRequestError> {
1197 debug!(name = %name, limit = limit, cluster_override = ?cluster_override, "Executing read_data_product");
1198
1199 let safe_name = safe_data_product_name(name)?;
1201
1202 let lookup_query = format!(
1208 "SELECT dp.cluster FROM mz_internal.mz_mcp_data_products dp \
1209 WHERE dp.object_name = {} \
1210 ORDER BY dp.cluster NULLS LAST \
1211 LIMIT 1",
1212 escaped_string_literal(name)
1213 );
1214 let lookup_rows = execute_sql(client, &lookup_query).await?;
1215 if lookup_rows.is_empty() {
1216 return Err(McpRequestError::DataProductNotFound(name.to_string()));
1217 }
1218 let catalog_cluster: Option<&str> = lookup_rows
1219 .first()
1220 .and_then(|row| row.first())
1221 .and_then(|v| v.as_str());
1222
1223 let target_cluster: Option<&str> = cluster_override.or(catalog_cluster);
1229
1230 let read_query = build_read_query(&safe_name, limit, target_cluster);
1235
1236 let rows = execute_sql(client, &read_query).await?;
1237
1238 format_rows_response(rows, max_response_size)
1239}
1240
1241fn build_read_query(safe_name: &str, limit: u32, target_cluster: Option<&str>) -> String {
1250 let body = format!("SELECT * FROM {safe_name} LIMIT {limit}");
1251 match target_cluster {
1252 Some(cluster) => read_only_txn(
1253 &format!("SET CLUSTER = {}", escaped_string_literal(cluster)),
1254 &body,
1255 ),
1256 None => format!("BEGIN READ ONLY; {body}\n; COMMIT;"),
1257 }
1258}
1259
1260fn read_only_txn(set_clause: &str, body: &str) -> String {
1266 format!("BEGIN READ ONLY; {set_clause}; {body}\n; COMMIT;")
1267}
1268
1269fn validate_readonly_query(sql: &str) -> Result<(), McpRequestError> {
1271 let sql = sql.trim();
1272 if sql.is_empty() {
1273 return Err(McpRequestError::QueryValidationFailed(
1274 "Empty query".to_string(),
1275 ));
1276 }
1277
1278 let stmts = parse_with_limit(sql)
1282 .map_err(McpRequestError::QueryValidationFailed)?
1283 .map_err(|e| {
1284 McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
1285 })?;
1286
1287 if stmts.len() != 1 {
1289 return Err(McpRequestError::QueryValidationFailed(format!(
1290 "Only one query allowed at a time. Found {} statements.",
1291 stmts.len()
1292 )));
1293 }
1294
1295 let stmt = &stmts[0];
1301 use mz_sql_parser::ast::Statement;
1302
1303 match &stmt.ast {
1304 Statement::Select(_)
1305 | Statement::Show(_)
1306 | Statement::ExplainPlan(_)
1307 | Statement::ExplainPushdown(_)
1308 | Statement::ExplainTimestamp(_)
1309 | Statement::ExplainSinkSchema(_)
1310 | Statement::ExplainAnalyzeObject(_)
1311 | Statement::ExplainAnalyzeCluster(_) => Ok(()),
1312 _ => Err(McpRequestError::QueryValidationFailed(
1313 "Only SELECT, SHOW, and EXPLAIN statements are allowed".to_string(),
1314 )),
1315 }
1316}
1317
1318async fn execute_query(
1319 client: &mut AuthedClient,
1320 cluster: &str,
1321 cluster_replica: Option<&str>,
1322 sql_query: &str,
1323 max_response_size: usize,
1324) -> Result<McpResult, McpRequestError> {
1325 debug!(cluster = %cluster, cluster_replica = ?cluster_replica, "Executing user query");
1326
1327 validate_readonly_query(sql_query)?;
1328 validate_cluster_replica(cluster_replica)?;
1329
1330 let combined_query = read_only_txn(&query_set_clause(cluster, cluster_replica), sql_query);
1333
1334 let rows = execute_sql(client, &combined_query).await?;
1335
1336 format_rows_response(rows, max_response_size)
1337}
1338
1339fn query_set_clause(cluster: &str, cluster_replica: Option<&str>) -> String {
1345 let mut set_clause = format!("SET CLUSTER = {}", escaped_string_literal(cluster));
1346 if let Some(replica) = cluster_replica {
1347 set_clause.push_str(&format!(
1348 "; SET CLUSTER_REPLICA = {}",
1349 escaped_string_literal(replica)
1350 ));
1351 }
1352 set_clause
1353}
1354
1355fn validate_cluster_replica(cluster_replica: Option<&str>) -> Result<(), McpRequestError> {
1360 if let Some(replica) = cluster_replica {
1361 if replica.trim().is_empty() {
1362 return Err(McpRequestError::QueryValidationFailed(
1363 "cluster_replica must not be empty or whitespace-only".to_string(),
1364 ));
1365 }
1366 }
1367 Ok(())
1368}
1369
1370async fn query_system_catalog(
1371 client: &mut AuthedClient,
1372 sql_query: &str,
1373 max_response_size: usize,
1374) -> Result<McpResult, McpRequestError> {
1375 debug!("Executing query_system_catalog");
1376
1377 validate_readonly_query(sql_query)?;
1379
1380 validate_system_catalog_query(sql_query)?;
1382
1383 let combined_query = read_only_txn(
1389 "SET search_path = mz_catalog, mz_internal, pg_catalog, information_schema",
1390 sql_query,
1391 );
1392
1393 let rows = execute_sql(client, &combined_query).await?;
1394
1395 format_rows_response(rows, max_response_size)
1396}
1397
1398struct TableReferenceCollector {
1400 tables: Vec<(Option<String>, String)>,
1402 cte_names: std::collections::BTreeSet<String>,
1404}
1405
1406impl TableReferenceCollector {
1407 fn new() -> Self {
1408 Self {
1409 tables: Vec::new(),
1410 cte_names: std::collections::BTreeSet::new(),
1411 }
1412 }
1413}
1414
1415impl<'ast> Visit<'ast, Raw> for TableReferenceCollector {
1416 fn visit_cte(&mut self, cte: &'ast mz_sql_parser::ast::Cte<Raw>) {
1417 self.cte_names
1419 .insert(cte.alias.name.as_str().to_lowercase());
1420 visit::visit_cte(self, cte);
1421 }
1422
1423 fn visit_table_factor(&mut self, table_factor: &'ast mz_sql_parser::ast::TableFactor<Raw>) {
1424 if let mz_sql_parser::ast::TableFactor::Table { name, .. } = table_factor {
1426 match name {
1427 RawItemName::Name(n) | RawItemName::Id(_, n, _) => {
1428 let parts = &n.0;
1429 if !parts.is_empty() {
1430 let table_name = parts.last().unwrap().as_str().to_lowercase();
1431
1432 if self.cte_names.contains(&table_name) {
1434 visit::visit_table_factor(self, table_factor);
1435 return;
1436 }
1437
1438 let schema = if parts.len() >= 2 {
1440 Some(parts[parts.len() - 2].as_str().to_lowercase())
1441 } else {
1442 None
1443 };
1444 self.tables.push((schema, table_name));
1445 }
1446 }
1447 }
1448 }
1449 visit::visit_table_factor(self, table_factor);
1450 }
1451}
1452
1453fn validate_system_catalog_query(sql: &str) -> Result<(), McpRequestError> {
1461 let stmts = parse_with_limit(sql)
1464 .map_err(McpRequestError::QueryValidationFailed)?
1465 .map_err(|e| {
1466 McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
1467 })?;
1468
1469 if stmts.is_empty() {
1470 return Err(McpRequestError::QueryValidationFailed(
1471 "Empty query".to_string(),
1472 ));
1473 }
1474
1475 let mut collector = TableReferenceCollector::new();
1477 for stmt in &stmts {
1478 collector.visit_statement(&stmt.ast);
1479 }
1480
1481 let is_allowed_schema =
1484 |s: &str| SYSTEM_SCHEMAS.contains(&s) && s != namespaces::MZ_UNSAFE_SCHEMA;
1485
1486 let is_system_table = |(schema, table_name): &(Option<String>, String)| match schema {
1491 Some(s) => is_allowed_schema(s.as_str()),
1492 None => table_name.starts_with("mz_") || table_name.starts_with("pg_"),
1493 };
1494
1495 let non_system_tables: Vec<String> = collector
1497 .tables
1498 .iter()
1499 .filter(|t| !is_system_table(t))
1500 .map(|(schema, table)| match schema {
1501 Some(s) => format!("{}.{}", s, table),
1502 None => table.clone(),
1503 })
1504 .collect();
1505
1506 if !non_system_tables.is_empty() {
1507 return Err(McpRequestError::QueryValidationFailed(format!(
1508 "Query references non-system tables: {}. Only system catalog tables (mz_*, pg_catalog, information_schema) are allowed.",
1509 non_system_tables.join(", ")
1510 )));
1511 }
1512
1513 use mz_sql_parser::ast::Statement;
1516 let is_select = stmts.iter().any(|s| matches!(&s.ast, Statement::Select(_)));
1517
1518 if is_select && (collector.tables.is_empty() || !collector.tables.iter().any(is_system_table)) {
1519 return Err(McpRequestError::QueryValidationFailed(
1520 "Query must reference at least one system catalog table".to_string(),
1521 ));
1522 }
1523
1524 Ok(())
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529 use super::*;
1530 use crate::http::sql::{Description, SqlError};
1531
1532 fn rows_result(rows: Vec<Vec<serde_json::Value>>) -> SqlResult {
1533 SqlResult::Rows {
1534 tag: String::new(),
1535 rows,
1536 desc: Description { columns: vec![] },
1537 notices: vec![],
1538 }
1539 }
1540
1541 fn ok_result() -> SqlResult {
1542 SqlResult::Ok {
1543 ok: String::new(),
1544 notices: vec![],
1545 parameters: vec![],
1546 }
1547 }
1548
1549 fn err_result(message: &str) -> SqlResult {
1550 SqlResult::Err {
1551 error: SqlError {
1552 message: message.to_string(),
1553 code: String::new(),
1554 detail: None,
1555 hint: None,
1556 position: None,
1557 },
1558 notices: vec![],
1559 }
1560 }
1561
1562 #[mz_ore::test]
1565 fn test_select_single_rows_extracts_rows() {
1566 let rows = vec![vec![serde_json::json!(1)]];
1567 let results = vec![
1568 ok_result(),
1569 ok_result(),
1570 rows_result(rows.clone()),
1571 ok_result(),
1572 ];
1573 assert_eq!(select_single_rows(results).unwrap(), rows);
1574 }
1575
1576 #[mz_ore::test]
1578 fn test_select_single_rows_requires_rows() {
1579 let err = select_single_rows(vec![ok_result(), ok_result()]).unwrap_err();
1580 assert!(
1581 matches!(err, McpRequestError::QueryExecutionFailed(_)),
1582 "{err:?}"
1583 );
1584 }
1585
1586 #[mz_ore::test]
1589 fn test_select_single_rows_rejects_multiple() {
1590 let results = vec![rows_result(vec![]), rows_result(vec![])];
1591 let err = select_single_rows(results).unwrap_err();
1592 assert!(matches!(err, McpRequestError::Internal(_)), "{err:?}");
1593 }
1594
1595 #[mz_ore::test]
1597 fn test_select_single_rows_surfaces_error() {
1598 let err = select_single_rows(vec![ok_result(), err_result("boom")]).unwrap_err();
1599 match err {
1600 McpRequestError::QueryExecutionFailed(msg) => assert_eq!(msg, "boom"),
1601 other => panic!("unexpected error: {other:?}"),
1602 }
1603 }
1604
1605 #[mz_ore::test]
1608 fn test_validate_origin() {
1609 let allowed = [HeaderValue::from_static("https://good.example")];
1610
1611 assert!(validate_origin(&HeaderMap::new(), &allowed).is_none());
1612
1613 let mut ok = HeaderMap::new();
1614 ok.insert(http::header::ORIGIN, allowed[0].clone());
1615 assert!(validate_origin(&ok, &allowed).is_none());
1616
1617 let mut bad = HeaderMap::new();
1618 bad.insert(
1619 http::header::ORIGIN,
1620 HeaderValue::from_static("https://evil.example"),
1621 );
1622 let rejected = validate_origin(&bad, &allowed);
1623 assert_eq!(
1624 rejected
1625 .expect("disallowed origin must be rejected")
1626 .status(),
1627 StatusCode::FORBIDDEN,
1628 );
1629 }
1630
1631 #[mz_ore::test]
1634 fn test_mcp_response_constructors() {
1635 let id = serde_json::json!(1);
1636
1637 let ok = McpResponse::success(
1638 id.clone(),
1639 McpResult::ToolContent(ToolContentResult {
1640 content: vec![],
1641 is_error: false,
1642 }),
1643 );
1644 assert_eq!(ok.jsonrpc, JSONRPC_VERSION);
1645 assert!(ok.result.is_some());
1646 assert!(ok.error.is_none());
1647
1648 let err = McpResponse::error(id, McpRequestError::ToolNotFound("t".to_string()).into());
1649 assert_eq!(err.jsonrpc, JSONRPC_VERSION);
1650 assert!(err.result.is_none());
1651 assert!(err.error.is_some());
1652 }
1653
1654 #[mz_ore::test]
1655 fn test_validate_readonly_query_select() {
1656 assert!(validate_readonly_query("SELECT * FROM mz_tables").is_ok());
1657 assert!(validate_readonly_query("SELECT 1 + 2").is_ok());
1658 assert!(validate_readonly_query(" SELECT 1 ").is_ok());
1659 }
1660
1661 #[mz_ore::test]
1662 fn test_validate_readonly_query_subqueries() {
1663 assert!(
1665 validate_readonly_query(
1666 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
1667 )
1668 .is_ok()
1669 );
1670
1671 assert!(
1673 validate_readonly_query(
1674 "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
1675 )
1676 .is_ok()
1677 );
1678
1679 assert!(validate_readonly_query(
1681 "SELECT * FROM mz_tables t WHERE EXISTS (SELECT 1 FROM mz_columns c WHERE c.id = t.id)"
1682 )
1683 .is_ok());
1684
1685 assert!(validate_readonly_query(
1687 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns WHERE type IN (SELECT name FROM mz_types))"
1688 )
1689 .is_ok());
1690
1691 assert!(
1693 validate_readonly_query(
1694 "SELECT * FROM mz_tables WHERE id = (SELECT MAX(id) FROM mz_columns)"
1695 )
1696 .is_ok()
1697 );
1698 }
1699
1700 #[mz_ore::test]
1701 fn test_validate_readonly_query_show() {
1702 assert!(validate_readonly_query("SHOW CLUSTERS").is_ok());
1703 assert!(validate_readonly_query("SHOW TABLES").is_ok());
1704 }
1705
1706 #[mz_ore::test]
1707 fn test_validate_readonly_query_explain() {
1708 assert!(validate_readonly_query("EXPLAIN SELECT 1").is_ok());
1713 assert!(
1714 validate_readonly_query("EXPLAIN FILTER PUSHDOWN FOR SELECT * FROM mz_tables").is_ok()
1715 );
1716 assert!(validate_readonly_query("EXPLAIN TIMESTAMP FOR SELECT 1").is_ok());
1717 assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR INDEX foo").is_ok());
1718 assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR MATERIALIZED VIEW mv").is_ok());
1719 assert!(validate_readonly_query("EXPLAIN ANALYZE CLUSTER MEMORY").is_ok());
1720 }
1721
1722 #[mz_ore::test]
1723 fn test_validate_readonly_query_rejects_writes() {
1724 assert!(validate_readonly_query("INSERT INTO t VALUES (1)").is_err());
1725 assert!(validate_readonly_query("UPDATE t SET a = 1").is_err());
1726 assert!(validate_readonly_query("DELETE FROM t").is_err());
1727 assert!(validate_readonly_query("CREATE TABLE t (a INT)").is_err());
1728 assert!(validate_readonly_query("DROP TABLE t").is_err());
1729 }
1730
1731 #[mz_ore::test]
1732 fn test_validate_readonly_query_rejects_multiple() {
1733 assert!(validate_readonly_query("SELECT 1; SELECT 2").is_err());
1734 }
1735
1736 #[mz_ore::test]
1737 fn test_validate_readonly_query_rejects_empty() {
1738 assert!(validate_readonly_query("").is_err());
1739 assert!(validate_readonly_query(" ").is_err());
1740 }
1741
1742 #[mz_ore::test]
1749 fn test_validate_readonly_query_enforces_size_limit() {
1750 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
1751 let oversized: String =
1753 "SELECT 1;".repeat((MAX_STATEMENT_BATCH_SIZE / "SELECT 1;".len()) + 1);
1754 let err = validate_readonly_query(&oversized).expect_err("should be rejected");
1755 let msg = err.to_string();
1756 assert!(
1757 msg.contains("statement batch size cannot exceed"),
1758 "expected size-guard error, got: {msg}"
1759 );
1760 }
1761
1762 #[mz_ore::test]
1763 fn test_validate_system_catalog_query_enforces_size_limit() {
1764 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
1765 let stmt = "SELECT * FROM mz_tables;";
1766 let oversized: String = stmt.repeat((MAX_STATEMENT_BATCH_SIZE / stmt.len()) + 1);
1767 let err = validate_system_catalog_query(&oversized).expect_err("should be rejected");
1768 let msg = err.to_string();
1769 assert!(
1770 msg.contains("statement batch size cannot exceed"),
1771 "expected size-guard error, got: {msg}"
1772 );
1773 }
1774
1775 #[mz_ore::test]
1776 fn test_validate_system_catalog_query_accepts_mz_tables() {
1777 assert!(validate_system_catalog_query("SELECT * FROM mz_tables").is_ok());
1778 assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_comments").is_ok());
1779 assert!(
1780 validate_system_catalog_query(
1781 "SELECT * FROM mz_tables t JOIN mz_columns c ON t.id = c.id"
1782 )
1783 .is_ok()
1784 );
1785 }
1786
1787 #[mz_ore::test]
1788 fn test_validate_system_catalog_query_subqueries() {
1789 assert!(
1791 validate_system_catalog_query(
1792 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
1793 )
1794 .is_ok()
1795 );
1796
1797 assert!(validate_system_catalog_query(
1799 "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM mz_columns WHERE type IN (SELECT id FROM mz_types))"
1800 )
1801 .is_ok());
1802
1803 assert!(
1805 validate_system_catalog_query(
1806 "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
1807 )
1808 .is_ok()
1809 );
1810
1811 assert!(
1813 validate_system_catalog_query(
1814 "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM user_data)"
1815 )
1816 .is_err()
1817 );
1818
1819 assert!(validate_system_catalog_query(
1821 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM (SELECT id FROM user_table) AS t)"
1822 )
1823 .is_err());
1824 }
1825
1826 #[mz_ore::test]
1827 fn test_validate_system_catalog_query_rejects_user_tables() {
1828 assert!(validate_system_catalog_query("SELECT * FROM user_data").is_err());
1829 assert!(validate_system_catalog_query("SELECT * FROM my_table").is_err());
1830 assert!(
1832 validate_system_catalog_query("SELECT * FROM user_data WHERE 'mz_' IS NOT NULL")
1833 .is_err()
1834 );
1835 }
1836
1837 #[mz_ore::test]
1838 fn test_validate_system_catalog_query_allows_functions() {
1839 assert!(
1841 validate_system_catalog_query(
1842 "SELECT date_part('year', now())::int4 AS y FROM mz_tables LIMIT 1"
1843 )
1844 .is_ok()
1845 );
1846 assert!(validate_system_catalog_query("SELECT length(name) FROM mz_tables").is_ok());
1847 assert!(
1848 validate_system_catalog_query(
1849 "SELECT count(*) FROM mz_sources WHERE now() > created_at"
1850 )
1851 .is_ok()
1852 );
1853 }
1854
1855 #[mz_ore::test]
1856 fn test_validate_system_catalog_query_unqualified_pg_catalog() {
1857 assert!(validate_system_catalog_query("SELECT * FROM pg_class").is_ok());
1860 assert!(validate_system_catalog_query("SELECT nspname FROM pg_namespace").is_ok());
1861 }
1862
1863 #[mz_ore::test]
1864 fn test_validate_system_catalog_query_schema_qualified() {
1865 assert!(validate_system_catalog_query("SELECT * FROM mz_catalog.mz_tables").is_ok());
1867 assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_sessions").is_ok());
1868 assert!(validate_system_catalog_query("SELECT * FROM pg_catalog.pg_type").is_ok());
1869 assert!(validate_system_catalog_query("SELECT * FROM information_schema.tables").is_ok());
1870
1871 assert!(validate_system_catalog_query("SELECT * FROM public.user_table").is_err());
1873 assert!(validate_system_catalog_query("SELECT * FROM myschema.mytable").is_err());
1874
1875 assert!(
1877 validate_system_catalog_query("SELECT * FROM mz_unsafe.mz_some_table").is_err(),
1878 "mz_unsafe schema should be blocked even though it is a system schema"
1879 );
1880
1881 assert!(
1883 validate_system_catalog_query(
1884 "SELECT * FROM mz_catalog.mz_tables JOIN public.user_data ON true"
1885 )
1886 .is_err()
1887 );
1888 }
1889
1890 #[mz_ore::test]
1891 fn test_validate_system_catalog_query_adversarial_cases() {
1892 assert!(
1894 validate_system_catalog_query(
1895 "WITH user_cte AS (SELECT * FROM user_data) \
1896 SELECT * FROM mz_tables, user_cte"
1897 )
1898 .is_err(),
1899 "Should reject CTE referencing user table"
1900 );
1901
1902 assert!(
1904 validate_system_catalog_query(
1905 "WITH \
1906 cte1 AS (SELECT * FROM mz_tables), \
1907 cte2 AS (SELECT * FROM cte1), \
1908 cte3 AS (SELECT * FROM user_data) \
1909 SELECT * FROM cte2"
1910 )
1911 .is_err(),
1912 "Should reject CTE chain with user table"
1913 );
1914
1915 assert!(
1917 validate_system_catalog_query(
1918 "SELECT * FROM mz_tables t1 \
1919 JOIN user_data u ON t1.id = u.id \
1920 JOIN mz_sources s ON t1.id = s.id"
1921 )
1922 .is_err(),
1923 "Should reject multi-join with user table"
1924 );
1925
1926 assert!(
1928 validate_system_catalog_query(
1929 "SELECT * FROM mz_tables t \
1930 LEFT JOIN user_data u ON t.id = u.table_id \
1931 WHERE u.id IS NULL"
1932 )
1933 .is_err(),
1934 "Should reject LEFT JOIN with user table"
1935 );
1936
1937 assert!(
1939 validate_system_catalog_query(
1940 "SELECT * FROM mz_tables WHERE id IN \
1941 (SELECT table_id FROM (SELECT * FROM user_data) AS u)"
1942 )
1943 .is_err(),
1944 "Should reject nested subquery with user table"
1945 );
1946
1947 assert!(
1949 validate_system_catalog_query(
1950 "SELECT name FROM mz_tables \
1951 UNION \
1952 SELECT name FROM user_data"
1953 )
1954 .is_err(),
1955 "Should reject UNION with user table"
1956 );
1957
1958 assert!(
1960 validate_system_catalog_query(
1961 "SELECT id FROM mz_sources \
1962 UNION ALL \
1963 SELECT id FROM products"
1964 )
1965 .is_err(),
1966 "Should reject UNION ALL with user table"
1967 );
1968
1969 assert!(
1971 validate_system_catalog_query("SELECT * FROM mz_tables CROSS JOIN user_data").is_err(),
1972 "Should reject CROSS JOIN with user table"
1973 );
1974
1975 assert!(
1977 validate_system_catalog_query(
1978 "SELECT t.*, (SELECT COUNT(*) FROM user_data) AS cnt FROM mz_tables t"
1979 )
1980 .is_err(),
1981 "Should reject subquery in SELECT with user table"
1982 );
1983
1984 assert!(
1986 validate_system_catalog_query("SELECT * FROM mz_catalogg.fake_table").is_err(),
1987 "Should reject typo-squatting schema name"
1988 );
1989 assert!(
1990 validate_system_catalog_query("SELECT * FROM mz_catalog_hack.fake_table").is_err(),
1991 "Should reject fake schema with mz_catalog prefix"
1992 );
1993
1994 assert!(
1996 validate_system_catalog_query(
1997 "SELECT * FROM mz_tables t, LATERAL (SELECT * FROM user_data WHERE id = t.id) u"
1998 )
1999 .is_err(),
2000 "Should reject LATERAL join with user table"
2001 );
2002
2003 assert!(
2005 validate_system_catalog_query(
2006 "WITH \
2007 tables AS (SELECT * FROM mz_tables), \
2008 sources AS (SELECT * FROM mz_sources) \
2009 SELECT t.name, s.name \
2010 FROM tables t \
2011 JOIN sources s ON t.id = s.id \
2012 WHERE t.id IN (SELECT id FROM mz_columns)"
2013 )
2014 .is_ok(),
2015 "Should allow complex query with only system tables"
2016 );
2017
2018 assert!(
2020 validate_system_catalog_query(
2021 "SELECT name FROM mz_tables \
2022 UNION \
2023 SELECT name FROM mz_sources"
2024 )
2025 .is_ok(),
2026 "Should allow UNION of system tables"
2027 );
2028 }
2029
2030 #[mz_ore::test]
2031 fn test_validate_system_catalog_query_rejects_constant_queries() {
2032 assert!(
2035 validate_system_catalog_query("SELECT 1").is_err(),
2036 "Should reject constant SELECT with no table references"
2037 );
2038 assert!(
2039 validate_system_catalog_query("SELECT 1 + 2, 'hello'").is_err(),
2040 "Should reject constant expression SELECT"
2041 );
2042 assert!(
2043 validate_system_catalog_query("SELECT now()").is_err(),
2044 "Should reject function-only SELECT with no table references"
2045 );
2046 }
2047
2048 #[mz_ore::test]
2049 fn test_validate_system_catalog_query_rejects_mixed_tables() {
2050 assert!(
2051 validate_system_catalog_query(
2052 "SELECT * FROM mz_tables t JOIN user_data u ON t.id = u.table_id"
2053 )
2054 .is_err()
2055 );
2056 }
2057
2058 #[mz_ore::test]
2059 fn test_validate_system_catalog_query_allows_show() {
2060 assert!(
2062 validate_system_catalog_query("SHOW TABLES FROM mz_internal").is_ok(),
2063 "SHOW TABLES FROM mz_internal should be allowed"
2064 );
2065 assert!(
2066 validate_system_catalog_query("SHOW TABLES FROM mz_catalog").is_ok(),
2067 "SHOW TABLES FROM mz_catalog should be allowed"
2068 );
2069 assert!(
2070 validate_system_catalog_query("SHOW CLUSTERS").is_ok(),
2071 "SHOW CLUSTERS should be allowed"
2072 );
2073 assert!(
2074 validate_system_catalog_query("SHOW SOURCES").is_ok(),
2075 "SHOW SOURCES should be allowed"
2076 );
2077 assert!(
2078 validate_system_catalog_query("SHOW TABLES").is_ok(),
2079 "SHOW TABLES should be allowed"
2080 );
2081 }
2082
2083 #[mz_ore::test]
2084 fn test_validate_system_catalog_query_allows_explain() {
2085 assert!(
2086 validate_system_catalog_query("EXPLAIN SELECT * FROM mz_tables").is_ok(),
2087 "EXPLAIN of system table query should be allowed"
2088 );
2089 assert!(
2090 validate_system_catalog_query("EXPLAIN SELECT 1").is_ok(),
2091 "EXPLAIN SELECT 1 should be allowed"
2092 );
2093 }
2094
2095 #[mz_ore::test(tokio::test)]
2098 async fn test_tools_list_agent_query_tool_disabled() {
2099 let result = handle_tools_list(McpEndpointType::Agent, false, true, 1_000_000).unwrap();
2100 let McpResult::ToolsList(list) = result else {
2101 panic!("Expected ToolsList result");
2102 };
2103 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2104 assert!(
2105 tool_names.contains(&"get_data_products"),
2106 "get_data_products should always be present"
2107 );
2108 assert!(
2109 tool_names.contains(&"get_data_product_details"),
2110 "get_data_product_details should always be present"
2111 );
2112 assert!(
2113 tool_names.contains(&"read_data_product"),
2114 "read_data_product should be present when its flag is on"
2115 );
2116 assert!(
2117 !tool_names.contains(&"query"),
2118 "query tool should be hidden when disabled"
2119 );
2120 }
2121
2122 #[mz_ore::test(tokio::test)]
2123 async fn test_tools_list_agent_query_tool_enabled() {
2124 let result = handle_tools_list(McpEndpointType::Agent, true, true, 1_000_000).unwrap();
2125 let McpResult::ToolsList(list) = result else {
2126 panic!("Expected ToolsList result");
2127 };
2128 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2129 assert!(
2130 tool_names.contains(&"get_data_products"),
2131 "get_data_products should always be present"
2132 );
2133 assert!(
2134 tool_names.contains(&"get_data_product_details"),
2135 "get_data_product_details should always be present"
2136 );
2137 assert!(
2138 tool_names.contains(&"read_data_product"),
2139 "read_data_product should be present when its flag is on"
2140 );
2141 assert!(
2142 tool_names.contains(&"query"),
2143 "query tool should be present when enabled"
2144 );
2145 }
2146
2147 #[mz_ore::test(tokio::test)]
2148 async fn test_tools_list_agent_read_data_product_tool_disabled() {
2149 let result = handle_tools_list(McpEndpointType::Agent, true, false, 1_000_000).unwrap();
2150 let McpResult::ToolsList(list) = result else {
2151 panic!("Expected ToolsList result");
2152 };
2153 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2154 assert!(
2155 tool_names.contains(&"get_data_products"),
2156 "get_data_products should always be present"
2157 );
2158 assert!(
2159 tool_names.contains(&"get_data_product_details"),
2160 "get_data_product_details should always be present"
2161 );
2162 assert!(
2163 !tool_names.contains(&"read_data_product"),
2164 "read_data_product should be hidden when disabled"
2165 );
2166 assert!(
2167 tool_names.contains(&"query"),
2168 "query tool should remain present when enabled"
2169 );
2170 }
2171
2172 #[mz_ore::test(tokio::test)]
2177 async fn test_tools_list_agent_both_read_tools_disabled() {
2178 let result = handle_tools_list(McpEndpointType::Agent, false, false, 1_000_000).unwrap();
2179 let McpResult::ToolsList(list) = result else {
2180 panic!("Expected ToolsList result");
2181 };
2182 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2183 assert_eq!(
2184 tool_names
2185 .iter()
2186 .copied()
2187 .collect::<std::collections::BTreeSet<_>>(),
2188 ["get_data_product_details", "get_data_products"]
2189 .into_iter()
2190 .collect(),
2191 "only discovery tools should be advertised when both read flags are off",
2192 );
2193
2194 let instructions = endpoint_instructions(McpEndpointType::Agent, false, false)
2195 .expect("agent instructions must be present");
2196 assert!(
2197 !instructions.contains("Use the `query` tool"),
2198 "instructions must not point at query when it is hidden: {instructions}",
2199 );
2200 assert!(
2201 !instructions.contains("`read_data_product` automatically"),
2202 "instructions must not point at read_data_product when it is hidden: {instructions}",
2203 );
2204 assert!(
2205 instructions.contains("discovery only"),
2206 "instructions must tell the agent it is discovery-only: {instructions}",
2207 );
2208 }
2209
2210 #[mz_ore::test(tokio::test)]
2211 async fn test_tools_list_developer_query_tool_disabled() {
2212 let result = handle_tools_list(McpEndpointType::Developer, false, true, 1_000_000).unwrap();
2215 let McpResult::ToolsList(list) = result else {
2216 panic!("Expected ToolsList result");
2217 };
2218 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2219 assert!(
2220 tool_names.contains(&"query_system_catalog"),
2221 "query_system_catalog should always be present on developer"
2222 );
2223 assert!(
2224 !tool_names.contains(&"query"),
2225 "query tool should be hidden when disabled"
2226 );
2227 }
2228
2229 #[mz_ore::test(tokio::test)]
2230 async fn test_tools_list_developer_query_tool_enabled() {
2231 let result = handle_tools_list(McpEndpointType::Developer, true, true, 1_000_000).unwrap();
2232 let McpResult::ToolsList(list) = result else {
2233 panic!("Expected ToolsList result");
2234 };
2235 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2236 assert!(
2237 tool_names.contains(&"query_system_catalog"),
2238 "query_system_catalog should always be present on developer"
2239 );
2240 assert!(
2241 tool_names.contains(&"query"),
2242 "query tool should be present on developer when enabled"
2243 );
2244 }
2245
2246 #[mz_ore::test]
2249 fn test_format_rows_response_within_limit() {
2250 let rows = vec![vec![json!("a"), json!(1)], vec![json!("b"), json!(2)]];
2251 let result = format_rows_response(rows, 1_000_000).unwrap();
2252 let McpResult::ToolContent(content) = result else {
2253 panic!("Expected ToolContent");
2254 };
2255 assert_eq!(content.content.len(), 1);
2256 assert!(content.content[0].text.contains("\"a\""));
2257 assert!(content.content[0].text.contains("\"b\""));
2258 }
2259
2260 #[mz_ore::test]
2261 fn test_format_rows_response_errors_when_over_limit() {
2262 let rows: Vec<Vec<serde_json::Value>> = (0..100)
2263 .map(|i| vec![json!(format!("row_{}", i)), json!(i)])
2264 .collect();
2265 let err = format_rows_response(rows, 500).unwrap_err();
2266 let msg = err.to_string();
2267 assert!(
2268 msg.contains("exceeds the 500 byte limit"),
2269 "Error should mention the size limit, got: {msg}"
2270 );
2271 assert!(
2272 msg.contains("Use LIMIT or WHERE"),
2273 "Error should suggest narrowing the query, got: {msg}"
2274 );
2275 }
2276
2277 #[mz_ore::test]
2278 fn test_format_rows_response_empty_rows() {
2279 let rows: Vec<Vec<serde_json::Value>> = vec![];
2280 let result = format_rows_response(rows, 1000).unwrap();
2281 let McpResult::ToolContent(content) = result else {
2282 panic!("Expected ToolContent");
2283 };
2284 assert_eq!(content.content.len(), 1);
2285 assert_eq!(content.content[0].text, "[]");
2286 }
2287
2288 #[mz_ore::test]
2291 fn test_safe_data_product_name_valid() {
2292 assert_eq!(
2294 safe_data_product_name(r#""materialize"."public"."my_view""#).unwrap(),
2295 r#""materialize"."public"."my_view""#
2296 );
2297 assert_eq!(
2299 safe_data_product_name(r#""public"."my_view""#).unwrap(),
2300 r#""public"."my_view""#
2301 );
2302 assert_eq!(safe_data_product_name("my_view").unwrap(), r#""my_view""#);
2304 }
2305
2306 #[mz_ore::test]
2307 fn test_safe_data_product_name_rejects_empty() {
2308 assert!(safe_data_product_name("").is_err());
2309 assert!(safe_data_product_name(" ").is_err());
2310 }
2311
2312 #[mz_ore::test]
2316 fn test_safe_data_product_name_enforces_size_limit() {
2317 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
2318 let oversized: String = "(".repeat(MAX_STATEMENT_BATCH_SIZE + 1);
2319 let err = safe_data_product_name(&oversized).expect_err("should be rejected");
2320 let msg = err.to_string();
2321 assert!(
2322 msg.contains("statement batch size cannot exceed"),
2323 "expected size-guard error, got: {msg}"
2324 );
2325 }
2326
2327 #[mz_ore::test]
2328 fn test_safe_data_product_name_rejects_sql_injection() {
2329 assert!(safe_data_product_name("my_view; DROP TABLE users").is_err());
2331 assert!(safe_data_product_name("my_view UNION SELECT * FROM secrets").is_err());
2333 assert!(safe_data_product_name("my_view, secrets").is_err());
2335 assert!(safe_data_product_name("my_view WHERE 1=1 --").is_err());
2337 }
2338
2339 #[mz_ore::test]
2341 fn test_read_only_txn_comment_cannot_swallow_commit() {
2342 let sql = read_only_txn("SET CLUSTER = 'c'", "SELECT 1 --");
2343 assert!(
2344 sql.contains("\n; COMMIT;"),
2345 "COMMIT must sit on its own line: {sql}",
2346 );
2347 }
2348
2349 #[mz_ore::test]
2353 fn test_query_set_clause_without_replica() {
2354 let clause = query_set_clause("prod_cluster", None);
2355 assert_eq!(clause, "SET CLUSTER = 'prod_cluster'");
2356 }
2357
2358 #[mz_ore::test]
2362 fn test_query_set_clause_with_replica() {
2363 let clause = query_set_clause("prod_cluster", Some("r1"));
2364 assert_eq!(
2365 clause,
2366 "SET CLUSTER = 'prod_cluster'; SET CLUSTER_REPLICA = 'r1'"
2367 );
2368 }
2369
2370 #[mz_ore::test]
2374 fn test_query_set_clause_escapes_replica_name() {
2375 let clause = query_set_clause("c", Some("evil'; DROP TABLE secrets; --"));
2376 assert_eq!(
2377 clause,
2378 "SET CLUSTER = 'c'; SET CLUSTER_REPLICA = 'evil''; DROP TABLE secrets; --'"
2379 );
2380 }
2381
2382 #[mz_ore::test]
2385 fn test_validate_cluster_replica_accepts_none_and_names() {
2386 assert!(validate_cluster_replica(None).is_ok());
2387 assert!(validate_cluster_replica(Some("r1")).is_ok());
2388 }
2389
2390 #[mz_ore::test]
2394 fn test_validate_cluster_replica_rejects_empty() {
2395 for name in ["", " ", "\t\n"] {
2396 assert!(
2397 matches!(
2398 validate_cluster_replica(Some(name)),
2399 Err(McpRequestError::QueryValidationFailed(_))
2400 ),
2401 "expected validation error for {name:?}",
2402 );
2403 }
2404 }
2405
2406 #[mz_ore::test]
2412 fn test_build_read_query_with_cluster() {
2413 let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, Some("prod_cluster"));
2414 assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
2415 assert!(sql.contains("SET CLUSTER = 'prod_cluster'"), "{sql}");
2416 assert!(
2417 sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
2418 "{sql}",
2419 );
2420 assert!(sql.contains("COMMIT"), "{sql}");
2421 }
2422
2423 #[mz_ore::test]
2426 fn test_build_read_query_without_cluster() {
2427 let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, None);
2428 assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
2429 assert!(!sql.contains("SET CLUSTER"), "{sql}");
2430 assert!(
2431 sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
2432 "{sql}",
2433 );
2434 assert!(sql.contains("COMMIT"), "{sql}");
2435 }
2436
2437 #[mz_ore::test]
2442 fn test_build_read_query_escapes_cluster_name() {
2443 let sql = build_read_query(
2444 "\"db\".\"sch\".\"v\"",
2445 10,
2446 Some("evil'; DROP TABLE secrets; --"),
2447 );
2448 assert!(
2450 sql.contains("SET CLUSTER = 'evil''; DROP TABLE secrets; --'"),
2451 "single quote should be doubled inside the literal: {sql}",
2452 );
2453 assert_eq!(
2456 sql.matches("SET CLUSTER").count(),
2457 1,
2458 "exactly one SET CLUSTER statement: {sql}",
2459 );
2460 assert_eq!(
2461 sql.matches("DROP TABLE").count(),
2462 1,
2463 "DROP TABLE should appear once, inside the quoted literal: {sql}",
2464 );
2465 }
2466
2467 #[mz_ore::test]
2468 fn test_mcp_error_codes() {
2469 assert_eq!(
2470 McpRequestError::InvalidJsonRpcVersion.error_code(),
2471 error_codes::INVALID_REQUEST
2472 );
2473 assert_eq!(
2474 McpRequestError::MethodNotFound("test".to_string()).error_code(),
2475 error_codes::METHOD_NOT_FOUND
2476 );
2477 assert_eq!(
2478 McpRequestError::QueryExecutionFailed("test".to_string()).error_code(),
2479 error_codes::INTERNAL_ERROR
2480 );
2481 }
2482}