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<Box<serde_json::value::RawValue>>, McpRequestError> {
1035 let mut response = SqlResponse::new();
1036
1037 execute_request(
1038 client,
1039 SqlRequest::Simple {
1040 query: mz_ore::sql::Sql::trusted_external_request(query.to_string()),
1041 },
1042 &mut response,
1043 )
1044 .await
1045 .map_err(|e| McpRequestError::QueryExecutionFailed(e.to_string()))?;
1046
1047 select_single_rows(response.results)
1048}
1049
1050fn select_single_rows(
1056 results: Vec<SqlResult>,
1057) -> Result<Vec<Box<serde_json::value::RawValue>>, McpRequestError> {
1058 let mut rows = None;
1059 for result in results {
1060 match result {
1061 SqlResult::Rows { rows: r, .. } => {
1062 if rows.is_some() {
1063 return Err(McpRequestError::Internal(anyhow!(
1064 "MCP query returned multiple row-producing statements"
1065 )));
1066 }
1067 rows = Some(r);
1068 }
1069 SqlResult::Err { error, .. } => {
1070 return Err(McpRequestError::QueryExecutionFailed(error.message));
1071 }
1072 SqlResult::Ok { .. } => continue,
1073 }
1074 }
1075
1076 rows.ok_or_else(|| {
1077 McpRequestError::QueryExecutionFailed("Query did not return any results".to_string())
1078 })
1079}
1080
1081fn format_rows_response(
1088 rows: Vec<Box<serde_json::value::RawValue>>,
1089 max_size: usize,
1090) -> Result<McpResult, McpRequestError> {
1091 let text =
1094 serde_json::to_string_pretty(&rows).map_err(|e| McpRequestError::Internal(anyhow!(e)))?;
1095
1096 if text.len() > max_size {
1097 return Err(McpRequestError::QueryExecutionFailed(format!(
1098 "Response size ({} bytes) exceeds the {} byte limit. \
1099 Use LIMIT or WHERE to narrow your query.",
1100 text.len(),
1101 max_size,
1102 )));
1103 }
1104
1105 Ok(McpResult::ToolContent(ToolContentResult {
1106 content: vec![ContentBlock {
1107 content_type: "text".to_string(),
1108 text,
1109 }],
1110 is_error: false,
1111 }))
1112}
1113
1114async fn get_data_products(
1115 client: &mut AuthedClient,
1116 max_response_size: usize,
1117) -> Result<McpResult, McpRequestError> {
1118 debug!("Executing get_data_products");
1119 let rows = execute_sql(client, DISCOVERY_QUERY).await?;
1120 debug!("get_data_products returned {} rows", rows.len());
1121
1122 format_rows_response(rows, max_response_size)
1123}
1124
1125async fn get_data_product_details(
1126 client: &mut AuthedClient,
1127 name: &str,
1128 max_response_size: usize,
1129) -> Result<McpResult, McpRequestError> {
1130 debug!(name = %name, "Executing get_data_product_details");
1131
1132 let query = format!("{}{}", DETAILS_QUERY_PREFIX, escaped_string_literal(name));
1133
1134 let rows = execute_sql(client, &query).await?;
1135
1136 if rows.is_empty() {
1137 return Err(McpRequestError::DataProductNotFound(name.to_string()));
1138 }
1139
1140 format_rows_response(rows, max_response_size)
1141}
1142
1143fn safe_data_product_name(name: &str) -> Result<String, McpRequestError> {
1150 let name = name.trim();
1151 if name.is_empty() {
1152 return Err(McpRequestError::QueryValidationFailed(
1153 "Data product name cannot be empty".to_string(),
1154 ));
1155 }
1156
1157 let parsed = parse_item_name_with_limit(name)
1160 .map_err(McpRequestError::QueryValidationFailed)?
1161 .map_err(|_| {
1162 McpRequestError::QueryValidationFailed(format!(
1163 "Invalid data product name: {}. Expected a valid object name, \
1164 e.g. '\"database\".\"schema\".\"name\"' or 'my_view'",
1165 name
1166 ))
1167 })?;
1168
1169 Ok(parsed.to_ast_string_stable())
1172}
1173
1174async fn read_data_product(
1193 client: &mut AuthedClient,
1194 name: &str,
1195 limit: u32,
1196 cluster_override: Option<&str>,
1197 max_response_size: usize,
1198) -> Result<McpResult, McpRequestError> {
1199 debug!(name = %name, limit = limit, cluster_override = ?cluster_override, "Executing read_data_product");
1200
1201 let safe_name = safe_data_product_name(name)?;
1203
1204 let lookup_query = format!(
1210 "SELECT dp.cluster FROM mz_internal.mz_mcp_data_products dp \
1211 WHERE dp.object_name = {} \
1212 ORDER BY dp.cluster NULLS LAST \
1213 LIMIT 1",
1214 escaped_string_literal(name)
1215 );
1216 let lookup_rows = execute_sql(client, &lookup_query).await?;
1217 if lookup_rows.is_empty() {
1218 return Err(McpRequestError::DataProductNotFound(name.to_string()));
1219 }
1220 let catalog_cluster: Option<String> = lookup_rows
1223 .first()
1224 .and_then(|row| serde_json::from_str::<Vec<serde_json::Value>>(row.get()).ok())
1225 .and_then(|row| row.into_iter().next())
1226 .and_then(|v| v.as_str().map(|s| s.to_string()));
1227
1228 let target_cluster: Option<&str> = cluster_override.or(catalog_cluster.as_deref());
1234
1235 let read_query = build_read_query(&safe_name, limit, target_cluster);
1240
1241 let rows = execute_sql(client, &read_query).await?;
1242
1243 format_rows_response(rows, max_response_size)
1244}
1245
1246fn build_read_query(safe_name: &str, limit: u32, target_cluster: Option<&str>) -> String {
1255 let body = format!("SELECT * FROM {safe_name} LIMIT {limit}");
1256 match target_cluster {
1257 Some(cluster) => read_only_txn(
1258 &format!("SET CLUSTER = {}", escaped_string_literal(cluster)),
1259 &body,
1260 ),
1261 None => format!("BEGIN READ ONLY; {body}\n; COMMIT;"),
1262 }
1263}
1264
1265fn read_only_txn(set_clause: &str, body: &str) -> String {
1271 format!("BEGIN READ ONLY; {set_clause}; {body}\n; COMMIT;")
1272}
1273
1274fn validate_readonly_query(sql: &str) -> Result<(), McpRequestError> {
1276 let sql = sql.trim();
1277 if sql.is_empty() {
1278 return Err(McpRequestError::QueryValidationFailed(
1279 "Empty query".to_string(),
1280 ));
1281 }
1282
1283 let stmts = parse_with_limit(sql)
1287 .map_err(McpRequestError::QueryValidationFailed)?
1288 .map_err(|e| {
1289 McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
1290 })?;
1291
1292 if stmts.len() != 1 {
1294 return Err(McpRequestError::QueryValidationFailed(format!(
1295 "Only one query allowed at a time. Found {} statements.",
1296 stmts.len()
1297 )));
1298 }
1299
1300 let stmt = &stmts[0];
1306 use mz_sql_parser::ast::Statement;
1307
1308 match &stmt.ast {
1309 Statement::Select(_)
1310 | Statement::Show(_)
1311 | Statement::ExplainPlan(_)
1312 | Statement::ExplainPushdown(_)
1313 | Statement::ExplainTimestamp(_)
1314 | Statement::ExplainSinkSchema(_)
1315 | Statement::ExplainAnalyzeObject(_)
1316 | Statement::ExplainAnalyzeCluster(_) => Ok(()),
1317 _ => Err(McpRequestError::QueryValidationFailed(
1318 "Only SELECT, SHOW, and EXPLAIN statements are allowed".to_string(),
1319 )),
1320 }
1321}
1322
1323async fn execute_query(
1324 client: &mut AuthedClient,
1325 cluster: &str,
1326 cluster_replica: Option<&str>,
1327 sql_query: &str,
1328 max_response_size: usize,
1329) -> Result<McpResult, McpRequestError> {
1330 debug!(cluster = %cluster, cluster_replica = ?cluster_replica, "Executing user query");
1331
1332 validate_readonly_query(sql_query)?;
1333 validate_cluster_replica(cluster_replica)?;
1334
1335 let combined_query = read_only_txn(&query_set_clause(cluster, cluster_replica), sql_query);
1338
1339 let rows = execute_sql(client, &combined_query).await?;
1340
1341 format_rows_response(rows, max_response_size)
1342}
1343
1344fn query_set_clause(cluster: &str, cluster_replica: Option<&str>) -> String {
1350 let mut set_clause = format!("SET CLUSTER = {}", escaped_string_literal(cluster));
1351 if let Some(replica) = cluster_replica {
1352 set_clause.push_str(&format!(
1353 "; SET CLUSTER_REPLICA = {}",
1354 escaped_string_literal(replica)
1355 ));
1356 }
1357 set_clause
1358}
1359
1360fn validate_cluster_replica(cluster_replica: Option<&str>) -> Result<(), McpRequestError> {
1365 if let Some(replica) = cluster_replica {
1366 if replica.trim().is_empty() {
1367 return Err(McpRequestError::QueryValidationFailed(
1368 "cluster_replica must not be empty or whitespace-only".to_string(),
1369 ));
1370 }
1371 }
1372 Ok(())
1373}
1374
1375async fn query_system_catalog(
1376 client: &mut AuthedClient,
1377 sql_query: &str,
1378 max_response_size: usize,
1379) -> Result<McpResult, McpRequestError> {
1380 debug!("Executing query_system_catalog");
1381
1382 validate_readonly_query(sql_query)?;
1384
1385 validate_system_catalog_query(sql_query)?;
1387
1388 let combined_query = read_only_txn(
1394 "SET search_path = mz_catalog, mz_internal, pg_catalog, information_schema",
1395 sql_query,
1396 );
1397
1398 let rows = execute_sql(client, &combined_query).await?;
1399
1400 format_rows_response(rows, max_response_size)
1401}
1402
1403struct TableReferenceCollector {
1405 tables: Vec<(Option<String>, String)>,
1407 cte_names: std::collections::BTreeSet<String>,
1409}
1410
1411impl TableReferenceCollector {
1412 fn new() -> Self {
1413 Self {
1414 tables: Vec::new(),
1415 cte_names: std::collections::BTreeSet::new(),
1416 }
1417 }
1418}
1419
1420impl<'ast> Visit<'ast, Raw> for TableReferenceCollector {
1421 fn visit_cte(&mut self, cte: &'ast mz_sql_parser::ast::Cte<Raw>) {
1422 self.cte_names
1424 .insert(cte.alias.name.as_str().to_lowercase());
1425 visit::visit_cte(self, cte);
1426 }
1427
1428 fn visit_table_factor(&mut self, table_factor: &'ast mz_sql_parser::ast::TableFactor<Raw>) {
1429 if let mz_sql_parser::ast::TableFactor::Table { name, .. } = table_factor {
1431 match name {
1432 RawItemName::Name(n) | RawItemName::Id(_, n, _) => {
1433 let parts = &n.0;
1434 if !parts.is_empty() {
1435 let table_name = parts.last().unwrap().as_str().to_lowercase();
1436
1437 if self.cte_names.contains(&table_name) {
1439 visit::visit_table_factor(self, table_factor);
1440 return;
1441 }
1442
1443 let schema = if parts.len() >= 2 {
1445 Some(parts[parts.len() - 2].as_str().to_lowercase())
1446 } else {
1447 None
1448 };
1449 self.tables.push((schema, table_name));
1450 }
1451 }
1452 }
1453 }
1454 visit::visit_table_factor(self, table_factor);
1455 }
1456}
1457
1458fn validate_system_catalog_query(sql: &str) -> Result<(), McpRequestError> {
1466 let stmts = parse_with_limit(sql)
1469 .map_err(McpRequestError::QueryValidationFailed)?
1470 .map_err(|e| {
1471 McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
1472 })?;
1473
1474 if stmts.is_empty() {
1475 return Err(McpRequestError::QueryValidationFailed(
1476 "Empty query".to_string(),
1477 ));
1478 }
1479
1480 let mut collector = TableReferenceCollector::new();
1482 for stmt in &stmts {
1483 collector.visit_statement(&stmt.ast);
1484 }
1485
1486 let is_allowed_schema =
1489 |s: &str| SYSTEM_SCHEMAS.contains(&s) && s != namespaces::MZ_UNSAFE_SCHEMA;
1490
1491 let is_system_table = |(schema, table_name): &(Option<String>, String)| match schema {
1496 Some(s) => is_allowed_schema(s.as_str()),
1497 None => table_name.starts_with("mz_") || table_name.starts_with("pg_"),
1498 };
1499
1500 let non_system_tables: Vec<String> = collector
1502 .tables
1503 .iter()
1504 .filter(|t| !is_system_table(t))
1505 .map(|(schema, table)| match schema {
1506 Some(s) => format!("{}.{}", s, table),
1507 None => table.clone(),
1508 })
1509 .collect();
1510
1511 if !non_system_tables.is_empty() {
1512 return Err(McpRequestError::QueryValidationFailed(format!(
1513 "Query references non-system tables: {}. Only system catalog tables (mz_*, pg_catalog, information_schema) are allowed.",
1514 non_system_tables.join(", ")
1515 )));
1516 }
1517
1518 use mz_sql_parser::ast::Statement;
1521 let is_select = stmts.iter().any(|s| matches!(&s.ast, Statement::Select(_)));
1522
1523 if is_select && (collector.tables.is_empty() || !collector.tables.iter().any(is_system_table)) {
1524 return Err(McpRequestError::QueryValidationFailed(
1525 "Query must reference at least one system catalog table".to_string(),
1526 ));
1527 }
1528
1529 Ok(())
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534 use super::*;
1535 use crate::http::sql::{Description, SqlError};
1536
1537 fn raw_rows(rows: Vec<Vec<serde_json::Value>>) -> Vec<Box<serde_json::value::RawValue>> {
1540 rows.iter()
1541 .map(|r| serde_json::value::to_raw_value(r).unwrap())
1542 .collect()
1543 }
1544
1545 fn rows_result(rows: Vec<Vec<serde_json::Value>>) -> SqlResult {
1546 SqlResult::Rows {
1547 tag: String::new(),
1548 rows: raw_rows(rows),
1549 desc: Description { columns: vec![] },
1550 notices: vec![],
1551 }
1552 }
1553
1554 fn ok_result() -> SqlResult {
1555 SqlResult::Ok {
1556 ok: String::new(),
1557 notices: vec![],
1558 parameters: vec![],
1559 }
1560 }
1561
1562 fn err_result(message: &str) -> SqlResult {
1563 SqlResult::Err {
1564 error: SqlError {
1565 message: message.to_string(),
1566 code: String::new(),
1567 detail: None,
1568 hint: None,
1569 position: None,
1570 },
1571 notices: vec![],
1572 }
1573 }
1574
1575 #[mz_ore::test]
1578 fn test_select_single_rows_extracts_rows() {
1579 let rows = vec![vec![serde_json::json!(1)]];
1580 let results = vec![
1581 ok_result(),
1582 ok_result(),
1583 rows_result(rows.clone()),
1584 ok_result(),
1585 ];
1586 let got = select_single_rows(results).unwrap();
1587 let got: Vec<&str> = got.iter().map(|r| r.get()).collect();
1588 assert_eq!(got, vec!["[1]"]);
1589 }
1590
1591 #[mz_ore::test]
1593 fn test_select_single_rows_requires_rows() {
1594 let err = select_single_rows(vec![ok_result(), ok_result()]).unwrap_err();
1595 assert!(
1596 matches!(err, McpRequestError::QueryExecutionFailed(_)),
1597 "{err:?}"
1598 );
1599 }
1600
1601 #[mz_ore::test]
1604 fn test_select_single_rows_rejects_multiple() {
1605 let results = vec![rows_result(vec![]), rows_result(vec![])];
1606 let err = select_single_rows(results).unwrap_err();
1607 assert!(matches!(err, McpRequestError::Internal(_)), "{err:?}");
1608 }
1609
1610 #[mz_ore::test]
1612 fn test_select_single_rows_surfaces_error() {
1613 let err = select_single_rows(vec![ok_result(), err_result("boom")]).unwrap_err();
1614 match err {
1615 McpRequestError::QueryExecutionFailed(msg) => assert_eq!(msg, "boom"),
1616 other => panic!("unexpected error: {other:?}"),
1617 }
1618 }
1619
1620 #[mz_ore::test]
1623 fn test_validate_origin() {
1624 let allowed = [HeaderValue::from_static("https://good.example")];
1625
1626 assert!(validate_origin(&HeaderMap::new(), &allowed).is_none());
1627
1628 let mut ok = HeaderMap::new();
1629 ok.insert(http::header::ORIGIN, allowed[0].clone());
1630 assert!(validate_origin(&ok, &allowed).is_none());
1631
1632 let mut bad = HeaderMap::new();
1633 bad.insert(
1634 http::header::ORIGIN,
1635 HeaderValue::from_static("https://evil.example"),
1636 );
1637 let rejected = validate_origin(&bad, &allowed);
1638 assert_eq!(
1639 rejected
1640 .expect("disallowed origin must be rejected")
1641 .status(),
1642 StatusCode::FORBIDDEN,
1643 );
1644 }
1645
1646 #[mz_ore::test]
1649 fn test_mcp_response_constructors() {
1650 let id = serde_json::json!(1);
1651
1652 let ok = McpResponse::success(
1653 id.clone(),
1654 McpResult::ToolContent(ToolContentResult {
1655 content: vec![],
1656 is_error: false,
1657 }),
1658 );
1659 assert_eq!(ok.jsonrpc, JSONRPC_VERSION);
1660 assert!(ok.result.is_some());
1661 assert!(ok.error.is_none());
1662
1663 let err = McpResponse::error(id, McpRequestError::ToolNotFound("t".to_string()).into());
1664 assert_eq!(err.jsonrpc, JSONRPC_VERSION);
1665 assert!(err.result.is_none());
1666 assert!(err.error.is_some());
1667 }
1668
1669 #[mz_ore::test]
1670 fn test_validate_readonly_query_select() {
1671 assert!(validate_readonly_query("SELECT * FROM mz_tables").is_ok());
1672 assert!(validate_readonly_query("SELECT 1 + 2").is_ok());
1673 assert!(validate_readonly_query(" SELECT 1 ").is_ok());
1674 }
1675
1676 #[mz_ore::test]
1677 fn test_validate_readonly_query_subqueries() {
1678 assert!(
1680 validate_readonly_query(
1681 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
1682 )
1683 .is_ok()
1684 );
1685
1686 assert!(
1688 validate_readonly_query(
1689 "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
1690 )
1691 .is_ok()
1692 );
1693
1694 assert!(validate_readonly_query(
1696 "SELECT * FROM mz_tables t WHERE EXISTS (SELECT 1 FROM mz_columns c WHERE c.id = t.id)"
1697 )
1698 .is_ok());
1699
1700 assert!(validate_readonly_query(
1702 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns WHERE type IN (SELECT name FROM mz_types))"
1703 )
1704 .is_ok());
1705
1706 assert!(
1708 validate_readonly_query(
1709 "SELECT * FROM mz_tables WHERE id = (SELECT MAX(id) FROM mz_columns)"
1710 )
1711 .is_ok()
1712 );
1713 }
1714
1715 #[mz_ore::test]
1716 fn test_validate_readonly_query_show() {
1717 assert!(validate_readonly_query("SHOW CLUSTERS").is_ok());
1718 assert!(validate_readonly_query("SHOW TABLES").is_ok());
1719 }
1720
1721 #[mz_ore::test]
1722 fn test_validate_readonly_query_explain() {
1723 assert!(validate_readonly_query("EXPLAIN SELECT 1").is_ok());
1728 assert!(
1729 validate_readonly_query("EXPLAIN FILTER PUSHDOWN FOR SELECT * FROM mz_tables").is_ok()
1730 );
1731 assert!(validate_readonly_query("EXPLAIN TIMESTAMP FOR SELECT 1").is_ok());
1732 assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR INDEX foo").is_ok());
1733 assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR MATERIALIZED VIEW mv").is_ok());
1734 assert!(validate_readonly_query("EXPLAIN ANALYZE CLUSTER MEMORY").is_ok());
1735 }
1736
1737 #[mz_ore::test]
1738 fn test_validate_readonly_query_rejects_writes() {
1739 assert!(validate_readonly_query("INSERT INTO t VALUES (1)").is_err());
1740 assert!(validate_readonly_query("UPDATE t SET a = 1").is_err());
1741 assert!(validate_readonly_query("DELETE FROM t").is_err());
1742 assert!(validate_readonly_query("CREATE TABLE t (a INT)").is_err());
1743 assert!(validate_readonly_query("DROP TABLE t").is_err());
1744 }
1745
1746 #[mz_ore::test]
1747 fn test_validate_readonly_query_rejects_multiple() {
1748 assert!(validate_readonly_query("SELECT 1; SELECT 2").is_err());
1749 }
1750
1751 #[mz_ore::test]
1752 fn test_validate_readonly_query_rejects_empty() {
1753 assert!(validate_readonly_query("").is_err());
1754 assert!(validate_readonly_query(" ").is_err());
1755 }
1756
1757 #[mz_ore::test]
1764 fn test_validate_readonly_query_enforces_size_limit() {
1765 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
1766 let oversized: String =
1768 "SELECT 1;".repeat((MAX_STATEMENT_BATCH_SIZE / "SELECT 1;".len()) + 1);
1769 let err = validate_readonly_query(&oversized).expect_err("should be rejected");
1770 let msg = err.to_string();
1771 assert!(
1772 msg.contains("statement batch size cannot exceed"),
1773 "expected size-guard error, got: {msg}"
1774 );
1775 }
1776
1777 #[mz_ore::test]
1778 fn test_validate_system_catalog_query_enforces_size_limit() {
1779 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
1780 let stmt = "SELECT * FROM mz_tables;";
1781 let oversized: String = stmt.repeat((MAX_STATEMENT_BATCH_SIZE / stmt.len()) + 1);
1782 let err = validate_system_catalog_query(&oversized).expect_err("should be rejected");
1783 let msg = err.to_string();
1784 assert!(
1785 msg.contains("statement batch size cannot exceed"),
1786 "expected size-guard error, got: {msg}"
1787 );
1788 }
1789
1790 #[mz_ore::test]
1791 fn test_validate_system_catalog_query_accepts_mz_tables() {
1792 assert!(validate_system_catalog_query("SELECT * FROM mz_tables").is_ok());
1793 assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_comments").is_ok());
1794 assert!(
1795 validate_system_catalog_query(
1796 "SELECT * FROM mz_tables t JOIN mz_columns c ON t.id = c.id"
1797 )
1798 .is_ok()
1799 );
1800 }
1801
1802 #[mz_ore::test]
1803 fn test_validate_system_catalog_query_subqueries() {
1804 assert!(
1806 validate_system_catalog_query(
1807 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
1808 )
1809 .is_ok()
1810 );
1811
1812 assert!(validate_system_catalog_query(
1814 "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM mz_columns WHERE type IN (SELECT id FROM mz_types))"
1815 )
1816 .is_ok());
1817
1818 assert!(
1820 validate_system_catalog_query(
1821 "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
1822 )
1823 .is_ok()
1824 );
1825
1826 assert!(
1828 validate_system_catalog_query(
1829 "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM user_data)"
1830 )
1831 .is_err()
1832 );
1833
1834 assert!(validate_system_catalog_query(
1836 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM (SELECT id FROM user_table) AS t)"
1837 )
1838 .is_err());
1839 }
1840
1841 #[mz_ore::test]
1842 fn test_validate_system_catalog_query_rejects_user_tables() {
1843 assert!(validate_system_catalog_query("SELECT * FROM user_data").is_err());
1844 assert!(validate_system_catalog_query("SELECT * FROM my_table").is_err());
1845 assert!(
1847 validate_system_catalog_query("SELECT * FROM user_data WHERE 'mz_' IS NOT NULL")
1848 .is_err()
1849 );
1850 }
1851
1852 #[mz_ore::test]
1853 fn test_validate_system_catalog_query_allows_functions() {
1854 assert!(
1856 validate_system_catalog_query(
1857 "SELECT date_part('year', now())::int4 AS y FROM mz_tables LIMIT 1"
1858 )
1859 .is_ok()
1860 );
1861 assert!(validate_system_catalog_query("SELECT length(name) FROM mz_tables").is_ok());
1862 assert!(
1863 validate_system_catalog_query(
1864 "SELECT count(*) FROM mz_sources WHERE now() > created_at"
1865 )
1866 .is_ok()
1867 );
1868 }
1869
1870 #[mz_ore::test]
1871 fn test_validate_system_catalog_query_unqualified_pg_catalog() {
1872 assert!(validate_system_catalog_query("SELECT * FROM pg_class").is_ok());
1875 assert!(validate_system_catalog_query("SELECT nspname FROM pg_namespace").is_ok());
1876 }
1877
1878 #[mz_ore::test]
1879 fn test_validate_system_catalog_query_schema_qualified() {
1880 assert!(validate_system_catalog_query("SELECT * FROM mz_catalog.mz_tables").is_ok());
1882 assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_sessions").is_ok());
1883 assert!(validate_system_catalog_query("SELECT * FROM pg_catalog.pg_type").is_ok());
1884 assert!(validate_system_catalog_query("SELECT * FROM information_schema.tables").is_ok());
1885
1886 assert!(validate_system_catalog_query("SELECT * FROM public.user_table").is_err());
1888 assert!(validate_system_catalog_query("SELECT * FROM myschema.mytable").is_err());
1889
1890 assert!(
1892 validate_system_catalog_query("SELECT * FROM mz_unsafe.mz_some_table").is_err(),
1893 "mz_unsafe schema should be blocked even though it is a system schema"
1894 );
1895
1896 assert!(
1898 validate_system_catalog_query(
1899 "SELECT * FROM mz_catalog.mz_tables JOIN public.user_data ON true"
1900 )
1901 .is_err()
1902 );
1903 }
1904
1905 #[mz_ore::test]
1906 fn test_validate_system_catalog_query_adversarial_cases() {
1907 assert!(
1909 validate_system_catalog_query(
1910 "WITH user_cte AS (SELECT * FROM user_data) \
1911 SELECT * FROM mz_tables, user_cte"
1912 )
1913 .is_err(),
1914 "Should reject CTE referencing user table"
1915 );
1916
1917 assert!(
1919 validate_system_catalog_query(
1920 "WITH \
1921 cte1 AS (SELECT * FROM mz_tables), \
1922 cte2 AS (SELECT * FROM cte1), \
1923 cte3 AS (SELECT * FROM user_data) \
1924 SELECT * FROM cte2"
1925 )
1926 .is_err(),
1927 "Should reject CTE chain with user table"
1928 );
1929
1930 assert!(
1932 validate_system_catalog_query(
1933 "SELECT * FROM mz_tables t1 \
1934 JOIN user_data u ON t1.id = u.id \
1935 JOIN mz_sources s ON t1.id = s.id"
1936 )
1937 .is_err(),
1938 "Should reject multi-join with user table"
1939 );
1940
1941 assert!(
1943 validate_system_catalog_query(
1944 "SELECT * FROM mz_tables t \
1945 LEFT JOIN user_data u ON t.id = u.table_id \
1946 WHERE u.id IS NULL"
1947 )
1948 .is_err(),
1949 "Should reject LEFT JOIN with user table"
1950 );
1951
1952 assert!(
1954 validate_system_catalog_query(
1955 "SELECT * FROM mz_tables WHERE id IN \
1956 (SELECT table_id FROM (SELECT * FROM user_data) AS u)"
1957 )
1958 .is_err(),
1959 "Should reject nested subquery with user table"
1960 );
1961
1962 assert!(
1964 validate_system_catalog_query(
1965 "SELECT name FROM mz_tables \
1966 UNION \
1967 SELECT name FROM user_data"
1968 )
1969 .is_err(),
1970 "Should reject UNION with user table"
1971 );
1972
1973 assert!(
1975 validate_system_catalog_query(
1976 "SELECT id FROM mz_sources \
1977 UNION ALL \
1978 SELECT id FROM products"
1979 )
1980 .is_err(),
1981 "Should reject UNION ALL with user table"
1982 );
1983
1984 assert!(
1986 validate_system_catalog_query("SELECT * FROM mz_tables CROSS JOIN user_data").is_err(),
1987 "Should reject CROSS JOIN with user table"
1988 );
1989
1990 assert!(
1992 validate_system_catalog_query(
1993 "SELECT t.*, (SELECT COUNT(*) FROM user_data) AS cnt FROM mz_tables t"
1994 )
1995 .is_err(),
1996 "Should reject subquery in SELECT with user table"
1997 );
1998
1999 assert!(
2001 validate_system_catalog_query("SELECT * FROM mz_catalogg.fake_table").is_err(),
2002 "Should reject typo-squatting schema name"
2003 );
2004 assert!(
2005 validate_system_catalog_query("SELECT * FROM mz_catalog_hack.fake_table").is_err(),
2006 "Should reject fake schema with mz_catalog prefix"
2007 );
2008
2009 assert!(
2011 validate_system_catalog_query(
2012 "SELECT * FROM mz_tables t, LATERAL (SELECT * FROM user_data WHERE id = t.id) u"
2013 )
2014 .is_err(),
2015 "Should reject LATERAL join with user table"
2016 );
2017
2018 assert!(
2020 validate_system_catalog_query(
2021 "WITH \
2022 tables AS (SELECT * FROM mz_tables), \
2023 sources AS (SELECT * FROM mz_sources) \
2024 SELECT t.name, s.name \
2025 FROM tables t \
2026 JOIN sources s ON t.id = s.id \
2027 WHERE t.id IN (SELECT id FROM mz_columns)"
2028 )
2029 .is_ok(),
2030 "Should allow complex query with only system tables"
2031 );
2032
2033 assert!(
2035 validate_system_catalog_query(
2036 "SELECT name FROM mz_tables \
2037 UNION \
2038 SELECT name FROM mz_sources"
2039 )
2040 .is_ok(),
2041 "Should allow UNION of system tables"
2042 );
2043 }
2044
2045 #[mz_ore::test]
2046 fn test_validate_system_catalog_query_rejects_constant_queries() {
2047 assert!(
2050 validate_system_catalog_query("SELECT 1").is_err(),
2051 "Should reject constant SELECT with no table references"
2052 );
2053 assert!(
2054 validate_system_catalog_query("SELECT 1 + 2, 'hello'").is_err(),
2055 "Should reject constant expression SELECT"
2056 );
2057 assert!(
2058 validate_system_catalog_query("SELECT now()").is_err(),
2059 "Should reject function-only SELECT with no table references"
2060 );
2061 }
2062
2063 #[mz_ore::test]
2064 fn test_validate_system_catalog_query_rejects_mixed_tables() {
2065 assert!(
2066 validate_system_catalog_query(
2067 "SELECT * FROM mz_tables t JOIN user_data u ON t.id = u.table_id"
2068 )
2069 .is_err()
2070 );
2071 }
2072
2073 #[mz_ore::test]
2074 fn test_validate_system_catalog_query_allows_show() {
2075 assert!(
2077 validate_system_catalog_query("SHOW TABLES FROM mz_internal").is_ok(),
2078 "SHOW TABLES FROM mz_internal should be allowed"
2079 );
2080 assert!(
2081 validate_system_catalog_query("SHOW TABLES FROM mz_catalog").is_ok(),
2082 "SHOW TABLES FROM mz_catalog should be allowed"
2083 );
2084 assert!(
2085 validate_system_catalog_query("SHOW CLUSTERS").is_ok(),
2086 "SHOW CLUSTERS should be allowed"
2087 );
2088 assert!(
2089 validate_system_catalog_query("SHOW SOURCES").is_ok(),
2090 "SHOW SOURCES should be allowed"
2091 );
2092 assert!(
2093 validate_system_catalog_query("SHOW TABLES").is_ok(),
2094 "SHOW TABLES should be allowed"
2095 );
2096 }
2097
2098 #[mz_ore::test]
2099 fn test_validate_system_catalog_query_allows_explain() {
2100 assert!(
2101 validate_system_catalog_query("EXPLAIN SELECT * FROM mz_tables").is_ok(),
2102 "EXPLAIN of system table query should be allowed"
2103 );
2104 assert!(
2105 validate_system_catalog_query("EXPLAIN SELECT 1").is_ok(),
2106 "EXPLAIN SELECT 1 should be allowed"
2107 );
2108 }
2109
2110 #[mz_ore::test(tokio::test)]
2113 async fn test_tools_list_agent_query_tool_disabled() {
2114 let result = handle_tools_list(McpEndpointType::Agent, false, true, 1_000_000).unwrap();
2115 let McpResult::ToolsList(list) = result else {
2116 panic!("Expected ToolsList result");
2117 };
2118 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2119 assert!(
2120 tool_names.contains(&"get_data_products"),
2121 "get_data_products should always be present"
2122 );
2123 assert!(
2124 tool_names.contains(&"get_data_product_details"),
2125 "get_data_product_details should always be present"
2126 );
2127 assert!(
2128 tool_names.contains(&"read_data_product"),
2129 "read_data_product should be present when its flag is on"
2130 );
2131 assert!(
2132 !tool_names.contains(&"query"),
2133 "query tool should be hidden when disabled"
2134 );
2135 }
2136
2137 #[mz_ore::test(tokio::test)]
2138 async fn test_tools_list_agent_query_tool_enabled() {
2139 let result = handle_tools_list(McpEndpointType::Agent, true, true, 1_000_000).unwrap();
2140 let McpResult::ToolsList(list) = result else {
2141 panic!("Expected ToolsList result");
2142 };
2143 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2144 assert!(
2145 tool_names.contains(&"get_data_products"),
2146 "get_data_products should always be present"
2147 );
2148 assert!(
2149 tool_names.contains(&"get_data_product_details"),
2150 "get_data_product_details should always be present"
2151 );
2152 assert!(
2153 tool_names.contains(&"read_data_product"),
2154 "read_data_product should be present when its flag is on"
2155 );
2156 assert!(
2157 tool_names.contains(&"query"),
2158 "query tool should be present when enabled"
2159 );
2160 }
2161
2162 #[mz_ore::test(tokio::test)]
2163 async fn test_tools_list_agent_read_data_product_tool_disabled() {
2164 let result = handle_tools_list(McpEndpointType::Agent, true, false, 1_000_000).unwrap();
2165 let McpResult::ToolsList(list) = result else {
2166 panic!("Expected ToolsList result");
2167 };
2168 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2169 assert!(
2170 tool_names.contains(&"get_data_products"),
2171 "get_data_products should always be present"
2172 );
2173 assert!(
2174 tool_names.contains(&"get_data_product_details"),
2175 "get_data_product_details should always be present"
2176 );
2177 assert!(
2178 !tool_names.contains(&"read_data_product"),
2179 "read_data_product should be hidden when disabled"
2180 );
2181 assert!(
2182 tool_names.contains(&"query"),
2183 "query tool should remain present when enabled"
2184 );
2185 }
2186
2187 #[mz_ore::test(tokio::test)]
2192 async fn test_tools_list_agent_both_read_tools_disabled() {
2193 let result = handle_tools_list(McpEndpointType::Agent, false, false, 1_000_000).unwrap();
2194 let McpResult::ToolsList(list) = result else {
2195 panic!("Expected ToolsList result");
2196 };
2197 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2198 assert_eq!(
2199 tool_names
2200 .iter()
2201 .copied()
2202 .collect::<std::collections::BTreeSet<_>>(),
2203 ["get_data_product_details", "get_data_products"]
2204 .into_iter()
2205 .collect(),
2206 "only discovery tools should be advertised when both read flags are off",
2207 );
2208
2209 let instructions = endpoint_instructions(McpEndpointType::Agent, false, false)
2210 .expect("agent instructions must be present");
2211 assert!(
2212 !instructions.contains("Use the `query` tool"),
2213 "instructions must not point at query when it is hidden: {instructions}",
2214 );
2215 assert!(
2216 !instructions.contains("`read_data_product` automatically"),
2217 "instructions must not point at read_data_product when it is hidden: {instructions}",
2218 );
2219 assert!(
2220 instructions.contains("discovery only"),
2221 "instructions must tell the agent it is discovery-only: {instructions}",
2222 );
2223 }
2224
2225 #[mz_ore::test(tokio::test)]
2226 async fn test_tools_list_developer_query_tool_disabled() {
2227 let result = handle_tools_list(McpEndpointType::Developer, false, true, 1_000_000).unwrap();
2230 let McpResult::ToolsList(list) = result else {
2231 panic!("Expected ToolsList result");
2232 };
2233 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2234 assert!(
2235 tool_names.contains(&"query_system_catalog"),
2236 "query_system_catalog should always be present on developer"
2237 );
2238 assert!(
2239 !tool_names.contains(&"query"),
2240 "query tool should be hidden when disabled"
2241 );
2242 }
2243
2244 #[mz_ore::test(tokio::test)]
2245 async fn test_tools_list_developer_query_tool_enabled() {
2246 let result = handle_tools_list(McpEndpointType::Developer, true, true, 1_000_000).unwrap();
2247 let McpResult::ToolsList(list) = result else {
2248 panic!("Expected ToolsList result");
2249 };
2250 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2251 assert!(
2252 tool_names.contains(&"query_system_catalog"),
2253 "query_system_catalog should always be present on developer"
2254 );
2255 assert!(
2256 tool_names.contains(&"query"),
2257 "query tool should be present on developer when enabled"
2258 );
2259 }
2260
2261 #[mz_ore::test]
2264 fn test_format_rows_response_within_limit() {
2265 let rows = vec![vec![json!("a"), json!(1)], vec![json!("b"), json!(2)]];
2266 let result = format_rows_response(raw_rows(rows), 1_000_000).unwrap();
2267 let McpResult::ToolContent(content) = result else {
2268 panic!("Expected ToolContent");
2269 };
2270 assert_eq!(content.content.len(), 1);
2271 assert!(content.content[0].text.contains("\"a\""));
2272 assert!(content.content[0].text.contains("\"b\""));
2273 }
2274
2275 #[mz_ore::test]
2276 fn test_format_rows_response_errors_when_over_limit() {
2277 let rows: Vec<Vec<serde_json::Value>> = (0..100)
2278 .map(|i| vec![json!(format!("row_{}", i)), json!(i)])
2279 .collect();
2280 let err = format_rows_response(raw_rows(rows), 500).unwrap_err();
2281 let msg = err.to_string();
2282 assert!(
2283 msg.contains("exceeds the 500 byte limit"),
2284 "Error should mention the size limit, got: {msg}"
2285 );
2286 assert!(
2287 msg.contains("Use LIMIT or WHERE"),
2288 "Error should suggest narrowing the query, got: {msg}"
2289 );
2290 }
2291
2292 #[mz_ore::test]
2293 fn test_format_rows_response_empty_rows() {
2294 let rows: Vec<Vec<serde_json::Value>> = vec![];
2295 let result = format_rows_response(raw_rows(rows), 1000).unwrap();
2296 let McpResult::ToolContent(content) = result else {
2297 panic!("Expected ToolContent");
2298 };
2299 assert_eq!(content.content.len(), 1);
2300 assert_eq!(content.content[0].text, "[]");
2301 }
2302
2303 #[mz_ore::test]
2306 fn test_safe_data_product_name_valid() {
2307 assert_eq!(
2309 safe_data_product_name(r#""materialize"."public"."my_view""#).unwrap(),
2310 r#""materialize"."public"."my_view""#
2311 );
2312 assert_eq!(
2314 safe_data_product_name(r#""public"."my_view""#).unwrap(),
2315 r#""public"."my_view""#
2316 );
2317 assert_eq!(safe_data_product_name("my_view").unwrap(), r#""my_view""#);
2319 }
2320
2321 #[mz_ore::test]
2322 fn test_safe_data_product_name_rejects_empty() {
2323 assert!(safe_data_product_name("").is_err());
2324 assert!(safe_data_product_name(" ").is_err());
2325 }
2326
2327 #[mz_ore::test]
2331 fn test_safe_data_product_name_enforces_size_limit() {
2332 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
2333 let oversized: String = "(".repeat(MAX_STATEMENT_BATCH_SIZE + 1);
2334 let err = safe_data_product_name(&oversized).expect_err("should be rejected");
2335 let msg = err.to_string();
2336 assert!(
2337 msg.contains("statement batch size cannot exceed"),
2338 "expected size-guard error, got: {msg}"
2339 );
2340 }
2341
2342 #[mz_ore::test]
2343 fn test_safe_data_product_name_rejects_sql_injection() {
2344 assert!(safe_data_product_name("my_view; DROP TABLE users").is_err());
2346 assert!(safe_data_product_name("my_view UNION SELECT * FROM secrets").is_err());
2348 assert!(safe_data_product_name("my_view, secrets").is_err());
2350 assert!(safe_data_product_name("my_view WHERE 1=1 --").is_err());
2352 }
2353
2354 #[mz_ore::test]
2356 fn test_read_only_txn_comment_cannot_swallow_commit() {
2357 let sql = read_only_txn("SET CLUSTER = 'c'", "SELECT 1 --");
2358 assert!(
2359 sql.contains("\n; COMMIT;"),
2360 "COMMIT must sit on its own line: {sql}",
2361 );
2362 }
2363
2364 #[mz_ore::test]
2368 fn test_query_set_clause_without_replica() {
2369 let clause = query_set_clause("prod_cluster", None);
2370 assert_eq!(clause, "SET CLUSTER = 'prod_cluster'");
2371 }
2372
2373 #[mz_ore::test]
2377 fn test_query_set_clause_with_replica() {
2378 let clause = query_set_clause("prod_cluster", Some("r1"));
2379 assert_eq!(
2380 clause,
2381 "SET CLUSTER = 'prod_cluster'; SET CLUSTER_REPLICA = 'r1'"
2382 );
2383 }
2384
2385 #[mz_ore::test]
2389 fn test_query_set_clause_escapes_replica_name() {
2390 let clause = query_set_clause("c", Some("evil'; DROP TABLE secrets; --"));
2391 assert_eq!(
2392 clause,
2393 "SET CLUSTER = 'c'; SET CLUSTER_REPLICA = 'evil''; DROP TABLE secrets; --'"
2394 );
2395 }
2396
2397 #[mz_ore::test]
2400 fn test_validate_cluster_replica_accepts_none_and_names() {
2401 assert!(validate_cluster_replica(None).is_ok());
2402 assert!(validate_cluster_replica(Some("r1")).is_ok());
2403 }
2404
2405 #[mz_ore::test]
2409 fn test_validate_cluster_replica_rejects_empty() {
2410 for name in ["", " ", "\t\n"] {
2411 assert!(
2412 matches!(
2413 validate_cluster_replica(Some(name)),
2414 Err(McpRequestError::QueryValidationFailed(_))
2415 ),
2416 "expected validation error for {name:?}",
2417 );
2418 }
2419 }
2420
2421 #[mz_ore::test]
2427 fn test_build_read_query_with_cluster() {
2428 let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, Some("prod_cluster"));
2429 assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
2430 assert!(sql.contains("SET CLUSTER = 'prod_cluster'"), "{sql}");
2431 assert!(
2432 sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
2433 "{sql}",
2434 );
2435 assert!(sql.contains("COMMIT"), "{sql}");
2436 }
2437
2438 #[mz_ore::test]
2441 fn test_build_read_query_without_cluster() {
2442 let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, None);
2443 assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
2444 assert!(!sql.contains("SET CLUSTER"), "{sql}");
2445 assert!(
2446 sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
2447 "{sql}",
2448 );
2449 assert!(sql.contains("COMMIT"), "{sql}");
2450 }
2451
2452 #[mz_ore::test]
2457 fn test_build_read_query_escapes_cluster_name() {
2458 let sql = build_read_query(
2459 "\"db\".\"sch\".\"v\"",
2460 10,
2461 Some("evil'; DROP TABLE secrets; --"),
2462 );
2463 assert!(
2465 sql.contains("SET CLUSTER = 'evil''; DROP TABLE secrets; --'"),
2466 "single quote should be doubled inside the literal: {sql}",
2467 );
2468 assert_eq!(
2471 sql.matches("SET CLUSTER").count(),
2472 1,
2473 "exactly one SET CLUSTER statement: {sql}",
2474 );
2475 assert_eq!(
2476 sql.matches("DROP TABLE").count(),
2477 1,
2478 "DROP TABLE should appear once, inside the quoted literal: {sql}",
2479 );
2480 }
2481
2482 #[mz_ore::test]
2483 fn test_mcp_error_codes() {
2484 assert_eq!(
2485 McpRequestError::InvalidJsonRpcVersion.error_code(),
2486 error_codes::INVALID_REQUEST
2487 );
2488 assert_eq!(
2489 McpRequestError::MethodNotFound("test".to_string()).error_code(),
2490 error_codes::METHOD_NOT_FOUND
2491 );
2492 assert_eq!(
2493 McpRequestError::QueryExecutionFailed("test".to_string()).error_code(),
2494 error_codes::INTERNAL_ERROR
2495 );
2496 }
2497}