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")]
134 ToolsList(#[allow(dead_code)] Option<serde_json::Value>),
135 #[serde(rename = "tools/call")]
136 ToolsCall(ToolsCallParams),
137 #[serde(rename = "ping")]
141 Ping(#[allow(dead_code)] Option<serde_json::Value>),
142 #[serde(rename = "notifications/initialized")]
143 NotificationsInitialized(#[allow(dead_code)] Option<serde_json::Value>),
144 #[serde(other)]
146 Unknown,
147}
148
149impl std::fmt::Display for McpMethod {
150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 match self {
152 McpMethod::Initialize(_) => write!(f, "initialize"),
153 McpMethod::ToolsList(_) => write!(f, "tools/list"),
154 McpMethod::ToolsCall(_) => write!(f, "tools/call"),
155 McpMethod::Ping(_) => write!(f, "ping"),
156 McpMethod::NotificationsInitialized(_) => write!(f, "notifications/initialized"),
157 McpMethod::Unknown => write!(f, "unknown"),
158 }
159 }
160}
161
162#[derive(Debug, Deserialize)]
163struct InitializeParams {
164 #[serde(rename = "protocolVersion")]
166 #[allow(dead_code)]
167 protocol_version: String,
168 #[serde(default)]
170 #[allow(dead_code)]
171 capabilities: serde_json::Value,
172 #[serde(rename = "clientInfo")]
174 #[allow(dead_code)]
175 client_info: Option<ClientInfo>,
176}
177
178#[derive(Debug, Deserialize)]
179struct ClientInfo {
180 #[allow(dead_code)]
181 name: String,
182 #[allow(dead_code)]
183 version: String,
184}
185
186#[derive(Debug, Deserialize)]
189#[serde(tag = "name", content = "arguments")]
190#[serde(rename_all = "snake_case")]
191enum ToolsCallParams {
192 GetDataProducts(#[serde(default)] ()),
195 GetDataProductDetails(GetDataProductDetailsParams),
196 ReadDataProduct(ReadDataProductParams),
197 Query(QueryParams),
198 QuerySystemCatalog(QuerySystemCatalogParams),
200}
201
202impl std::fmt::Display for ToolsCallParams {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 match self {
205 ToolsCallParams::GetDataProducts(_) => write!(f, "get_data_products"),
206 ToolsCallParams::GetDataProductDetails(_) => write!(f, "get_data_product_details"),
207 ToolsCallParams::ReadDataProduct(_) => write!(f, "read_data_product"),
208 ToolsCallParams::Query(_) => write!(f, "query"),
209 ToolsCallParams::QuerySystemCatalog(_) => write!(f, "query_system_catalog"),
210 }
211 }
212}
213
214#[derive(Debug, Deserialize)]
215struct GetDataProductDetailsParams {
216 name: String,
217}
218
219#[derive(Debug, Deserialize)]
220struct ReadDataProductParams {
221 name: String,
222 #[serde(default = "default_read_limit")]
223 limit: u32,
224 cluster: Option<String>,
225}
226
227const DEFAULT_READ_LIMIT: u32 = 500;
229
230fn default_read_limit() -> u32 {
231 DEFAULT_READ_LIMIT
232}
233
234#[derive(Debug, Deserialize)]
235struct QueryParams {
236 cluster: String,
237 cluster_replica: Option<String>,
240 sql_query: String,
241}
242
243#[derive(Debug, Deserialize)]
244struct QuerySystemCatalogParams {
245 sql_query: String,
246}
247
248#[derive(Debug, Serialize)]
249struct McpResponse {
250 jsonrpc: String,
251 id: serde_json::Value,
252 #[serde(skip_serializing_if = "Option::is_none")]
253 result: Option<McpResult>,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 error: Option<McpError>,
256}
257
258impl McpResponse {
259 fn success(id: serde_json::Value, result: McpResult) -> Self {
261 Self {
262 jsonrpc: JSONRPC_VERSION.to_string(),
263 id,
264 result: Some(result),
265 error: None,
266 }
267 }
268
269 fn error(id: serde_json::Value, error: McpError) -> Self {
271 Self {
272 jsonrpc: JSONRPC_VERSION.to_string(),
273 id,
274 result: None,
275 error: Some(error),
276 }
277 }
278}
279
280#[derive(Debug, Serialize)]
282#[serde(untagged)]
283enum McpResult {
284 Initialize(InitializeResult),
285 ToolsList(ToolsListResult),
286 ToolContent(ToolContentResult),
287}
288
289#[derive(Debug, Serialize)]
290struct InitializeResult {
291 #[serde(rename = "protocolVersion")]
292 protocol_version: String,
293 capabilities: Capabilities,
294 #[serde(rename = "serverInfo")]
295 server_info: ServerInfo,
296 #[serde(skip_serializing_if = "Option::is_none")]
297 instructions: Option<String>,
298}
299
300#[derive(Debug, Serialize)]
301struct Capabilities {
302 tools: serde_json::Value,
303}
304
305#[derive(Debug, Serialize)]
306struct ServerInfo {
307 name: String,
308 version: String,
309}
310
311#[derive(Debug, Serialize)]
312struct ToolsListResult {
313 tools: Vec<ToolDefinition>,
314}
315
316#[derive(Debug, Serialize)]
317struct ToolDefinition {
318 name: String,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 title: Option<String>,
321 description: String,
322 #[serde(rename = "inputSchema")]
323 input_schema: serde_json::Value,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 annotations: Option<ToolAnnotations>,
326}
327
328#[derive(Debug, Serialize)]
331struct ToolAnnotations {
332 #[serde(rename = "readOnlyHint", skip_serializing_if = "Option::is_none")]
333 read_only_hint: Option<bool>,
334 #[serde(rename = "destructiveHint", skip_serializing_if = "Option::is_none")]
335 destructive_hint: Option<bool>,
336 #[serde(rename = "idempotentHint", skip_serializing_if = "Option::is_none")]
337 idempotent_hint: Option<bool>,
338 #[serde(rename = "openWorldHint", skip_serializing_if = "Option::is_none")]
339 open_world_hint: Option<bool>,
340}
341
342const READ_ONLY_ANNOTATIONS: ToolAnnotations = ToolAnnotations {
344 read_only_hint: Some(true),
345 destructive_hint: Some(false),
346 idempotent_hint: Some(true),
347 open_world_hint: Some(false),
348};
349
350#[derive(Debug, Serialize)]
351struct ToolContentResult {
352 content: Vec<ContentBlock>,
353 #[serde(rename = "isError")]
355 is_error: bool,
356}
357
358#[derive(Debug, Serialize)]
359struct ContentBlock {
360 #[serde(rename = "type")]
361 content_type: String,
362 text: String,
363}
364
365mod error_codes {
367 pub const INVALID_REQUEST: i32 = -32600;
368 pub const METHOD_NOT_FOUND: i32 = -32601;
369 pub const INVALID_PARAMS: i32 = -32602;
370 pub const INTERNAL_ERROR: i32 = -32603;
371}
372
373#[derive(Debug, Serialize)]
374struct McpError {
375 code: i32,
376 message: String,
377 #[serde(skip_serializing_if = "Option::is_none")]
378 data: Option<serde_json::Value>,
379}
380
381impl From<McpRequestError> for McpError {
382 fn from(err: McpRequestError) -> Self {
383 McpError {
384 code: err.error_code(),
385 message: err.to_string(),
386 data: Some(json!({
387 "error_type": err.error_type(),
388 })),
389 }
390 }
391}
392
393#[derive(Debug, Clone, Copy)]
394enum McpEndpointType {
395 Agent,
396 Developer,
397}
398
399impl McpEndpointType {
400 fn as_label(self) -> &'static str {
403 match self {
404 McpEndpointType::Agent => "agent",
405 McpEndpointType::Developer => "developer",
406 }
407 }
408}
409
410impl std::fmt::Display for McpEndpointType {
411 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412 f.write_str(self.as_label())
413 }
414}
415
416pub async fn handle_mcp_method_not_allowed() -> impl IntoResponse {
419 StatusCode::METHOD_NOT_ALLOWED
420}
421
422pub async fn handle_mcp_agent(
424 headers: HeaderMap,
425 Extension(allowed_origins): Extension<Arc<Vec<HeaderValue>>>,
426 Extension(metrics): Extension<McpMetrics>,
427 client: AuthedClient,
428 Json(body): Json<McpRequest>,
429) -> axum::response::Response {
430 if let Some(resp) = validate_origin(&headers, &allowed_origins) {
431 return resp;
432 }
433 handle_mcp_request(client, body, McpEndpointType::Agent, metrics)
434 .await
435 .into_response()
436}
437
438pub async fn handle_mcp_developer(
440 headers: HeaderMap,
441 Extension(allowed_origins): Extension<Arc<Vec<HeaderValue>>>,
442 Extension(metrics): Extension<McpMetrics>,
443 client: AuthedClient,
444 Json(body): Json<McpRequest>,
445) -> axum::response::Response {
446 if let Some(resp) = validate_origin(&headers, &allowed_origins) {
447 return resp;
448 }
449 handle_mcp_request(client, body, McpEndpointType::Developer, metrics)
450 .await
451 .into_response()
452}
453
454fn validate_origin(
463 headers: &HeaderMap,
464 allowed: &[HeaderValue],
465) -> Option<axum::response::Response> {
466 let origin = headers.get(http::header::ORIGIN)?;
467 if mz_http_util::origin_is_allowed(origin, allowed) {
468 return None;
469 }
470 warn!(
471 origin = ?origin,
472 "MCP request rejected: origin not in allowlist",
473 );
474 Some(StatusCode::FORBIDDEN.into_response())
475}
476
477async fn handle_mcp_request(
478 mut client: AuthedClient,
479 request: McpRequest,
480 endpoint_type: McpEndpointType,
481 metrics: McpMetrics,
482) -> impl IntoResponse {
483 let endpoint_label = endpoint_type.as_label();
484 let method_label = request.method.to_string();
485 let record_request =
486 |status: McpCallStatus| metrics.record_request(endpoint_label, &method_label, status);
487
488 let catalog = match tokio::time::timeout(
493 *MCP_REQUEST_TIMEOUT.default(),
494 client.client.catalog_snapshot("mcp"),
495 )
496 .await
497 {
498 Ok(catalog) => catalog,
499 Err(_elapsed) => {
500 warn!(endpoint = %endpoint_type, "MCP catalog snapshot timed out");
501 record_request(McpCallStatus::Timeout);
502 return StatusCode::SERVICE_UNAVAILABLE.into_response();
503 }
504 };
505 let dyncfgs = catalog.system_config().dyncfgs();
506 let enabled = match endpoint_type {
507 McpEndpointType::Agent => ENABLE_MCP_AGENT.get(dyncfgs),
508 McpEndpointType::Developer => ENABLE_MCP_DEVELOPER.get(dyncfgs),
509 };
510 if !enabled {
511 debug!(endpoint = %endpoint_type, "MCP endpoint disabled by feature flag");
512 record_request(McpCallStatus::EndpointDisabled);
513 return StatusCode::SERVICE_UNAVAILABLE.into_response();
514 }
515
516 let query_tool_enabled = match endpoint_type {
521 McpEndpointType::Agent => ENABLE_MCP_AGENT_QUERY_TOOL.get(dyncfgs),
522 McpEndpointType::Developer => ENABLE_MCP_DEVELOPER_QUERY_TOOL.get(dyncfgs),
523 };
524 let read_data_product_tool_enabled = ENABLE_MCP_AGENT_READ_DATA_PRODUCT_TOOL.get(dyncfgs);
528 let max_response_size = MCP_MAX_RESPONSE_SIZE.get(dyncfgs);
529 let request_timeout = MCP_REQUEST_TIMEOUT.get(dyncfgs);
530
531 let app_name = match endpoint_type {
535 McpEndpointType::Agent => "mz_mcp_agents",
536 McpEndpointType::Developer => "mz_mcp_developer",
537 };
538 client
539 .client
540 .session()
541 .vars_mut()
542 .set_default(APPLICATION_NAME.name(), VarInput::Flat(app_name))
543 .expect("application_name is a known session var");
544
545 let user = client.client.session().user().name.clone();
546 let is_notification = request.id.is_none();
547
548 debug!(
549 method = %request.method,
550 endpoint = %endpoint_type,
551 user = %user,
552 is_notification = is_notification,
553 "MCP request received"
554 );
555
556 if is_notification {
559 debug!(method = %request.method, "Received notification (no response will be sent)");
560 record_request(McpCallStatus::Ok);
561 return StatusCode::ACCEPTED.into_response();
562 }
563
564 let request_id = request.id.clone().unwrap_or(serde_json::Value::Null);
565
566 let metrics_inner = metrics.clone();
571 let result = tokio::time::timeout(
572 request_timeout,
573 mz_ore::task::spawn(|| "mcp_request", async move {
574 handle_mcp_request_inner(
575 &mut client,
576 request,
577 endpoint_type,
578 query_tool_enabled,
579 read_data_product_tool_enabled,
580 max_response_size,
581 metrics_inner,
582 )
583 .await
584 })
585 .abort_on_drop(),
586 )
587 .await;
588
589 let (response, status_label): (McpResponse, McpCallStatus) = match result {
590 Ok(inner) => inner,
591 Err(_elapsed) => {
592 warn!(
593 endpoint = %endpoint_type,
594 timeout = ?request_timeout,
595 "MCP request timed out",
596 );
597 let response = McpResponse::error(
598 request_id,
599 McpRequestError::QueryExecutionFailed(format!(
600 "Request timed out after {} seconds.",
601 request_timeout.as_secs(),
602 ))
603 .into(),
604 );
605 (response, McpCallStatus::Timeout)
606 }
607 };
608
609 record_request(status_label);
610 (StatusCode::OK, Json(response)).into_response()
611}
612
613async fn handle_mcp_request_inner(
614 client: &mut AuthedClient,
615 request: McpRequest,
616 endpoint_type: McpEndpointType,
617 query_tool_enabled: bool,
618 read_data_product_tool_enabled: bool,
619 max_response_size: usize,
620 metrics: McpMetrics,
621) -> (McpResponse, McpCallStatus) {
622 let request_id = request.id.clone().unwrap_or(serde_json::Value::Null);
624
625 let result = handle_mcp_method(
626 client,
627 &request,
628 endpoint_type,
629 query_tool_enabled,
630 read_data_product_tool_enabled,
631 max_response_size,
632 &metrics,
633 )
634 .await;
635
636 let status_label = call_status(&result);
637
638 let response = match result {
639 Ok(result_value) => McpResponse::success(request_id, result_value),
640 Err(e) => {
641 if !matches!(
643 e,
644 McpRequestError::MethodNotFound(_) | McpRequestError::InvalidJsonRpcVersion
645 ) {
646 warn!(error = %e, method = %request.method, "MCP method execution failed");
647 }
648 McpResponse::error(request_id, e.into())
649 }
650 };
651
652 (response, status_label)
653}
654
655async fn handle_mcp_method(
656 client: &mut AuthedClient,
657 request: &McpRequest,
658 endpoint_type: McpEndpointType,
659 query_tool_enabled: bool,
660 read_data_product_tool_enabled: bool,
661 max_response_size: usize,
662 metrics: &McpMetrics,
663) -> Result<McpResult, McpRequestError> {
664 if request.jsonrpc != JSONRPC_VERSION {
666 return Err(McpRequestError::InvalidJsonRpcVersion);
667 }
668
669 match &request.method {
671 McpMethod::Initialize(_) => {
672 debug!(endpoint = %endpoint_type, "Processing initialize");
673 handle_initialize(
674 endpoint_type,
675 query_tool_enabled,
676 read_data_product_tool_enabled,
677 )
678 }
679 McpMethod::ToolsList(_) => {
680 debug!(endpoint = %endpoint_type, "Processing tools/list");
681 handle_tools_list(
682 endpoint_type,
683 query_tool_enabled,
684 read_data_product_tool_enabled,
685 max_response_size,
686 )
687 }
688 McpMethod::ToolsCall(params) => {
689 debug!(tool = %params, endpoint = %endpoint_type, "Processing tools/call");
690 handle_tools_call(
691 client,
692 params,
693 endpoint_type,
694 query_tool_enabled,
695 read_data_product_tool_enabled,
696 max_response_size,
697 metrics,
698 )
699 .await
700 }
701 McpMethod::Ping(_) | McpMethod::NotificationsInitialized(_) | McpMethod::Unknown => Err(
702 McpRequestError::MethodNotFound("unknown method".to_string()),
703 ),
704 }
705}
706
707fn endpoint_instructions(
710 endpoint_type: McpEndpointType,
711 query_tool_enabled: bool,
712 read_data_product_tool_enabled: bool,
713) -> Option<String> {
714 match endpoint_type {
715 McpEndpointType::Agent => {
716 let read_paragraph = match (read_data_product_tool_enabled, query_tool_enabled) {
721 (true, _) => {
722 "`read_data_product` automatically routes the \
723 read to the cluster recorded in the data product catalog so indexes are used; \
724 you only need to set the `cluster` parameter if you intentionally want the \
725 read to run on a different cluster (e.g. one with larger or more replicas). \
726 A null `cluster` in discovery means your role lacks USAGE on the object's \
727 index/compute cluster; `read_data_product` without an override then reads on \
728 your session's default cluster (safe: only materialized views appear this way, \
729 and they serve from persist). "
730 }
731 (false, true) => {
732 "Use the `query` tool to read data products, passing the cluster from \
733 `get_data_product_details` so indexed reads hit the arrangement. If \
734 `get_data_product_details` returns a null `cluster`, your role lacks USAGE on \
735 the object's index/compute cluster; run the `query` against any cluster your \
736 role can use (the read still works, just without the index arrangement). "
737 }
738 (false, false) => {
739 "This server is configured for discovery only: no read tool is exposed. \
740 Use `get_data_products` and `get_data_product_details` to inspect what \
741 is available. "
742 }
743 };
744 Some(format!(
745 "You have access to Materialize data products via MCP. \
746 Prefer indexed objects (served from memory) over unindexed materialized views \
747 (read from persistent storage). {read_paragraph}\
748 `get_data_product_details` returns a `hydration` object with `hydrated`, \
749 `replica_count`, and `hydrated_replica_count` fields. Reads never return \
750 partial data: a read against a not-yet-hydrated product blocks until the \
751 dataflow catches up, and may hit the request timeout. Check `hydrated` \
752 before reading: if it is false and `replica_count` is greater than 0, the \
753 dataflow is still warming up, so wait and retry; if `replica_count` is 0 the \
754 cluster has no replicas and the read cannot make progress until one is added.",
755 ))
756 }
757 McpEndpointType::Developer => {
758 let query_tool_line = if query_tool_enabled {
762 "- 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"
763 } else {
764 ""
765 };
766 let introspection_rule = if query_tool_enabled {
770 "- mz_introspection relations (for example mz_dataflow_arrangement_sizes) are cluster-scoped and query_system_catalog answers about the session's default cluster: read them only through the query tool with its cluster argument (and cluster_replica on a multi-replica cluster), never through query_system_catalog"
771 } else {
772 "- mz_introspection relations (for example mz_dataflow_arrangement_sizes) are cluster-scoped and query_system_catalog answers about the session's default cluster only; cluster-targeted introspection is unavailable on this server (the query tool is disabled), so say so rather than reporting another cluster's numbers"
773 };
774 Some(format!(
775 "You are connected to the Materialize developer MCP server for troubleshooting and observability.\n\n\
776 Tools:\n\
777 - 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\
778 {query_tool_line}\n\
779 IMPORTANT: Before writing queries, discover table schemas using the mz_ontology tables:\n\
780 - mz_internal.mz_ontology_entity_types: what catalog entities exist and which tables they map to\n\
781 - mz_internal.mz_ontology_link_types: relationships between entities (foreign keys, metrics, etc.)\n\
782 - mz_internal.mz_ontology_properties: column names, types, and descriptions for each entity\n\
783 - mz_internal.mz_ontology_semantic_types: typed ID domains (CatalogItemId, ReplicaId, etc.)\n\n\
784 Use these to find the correct tables, join paths, and column names instead of guessing.\n\n\
785 Key rules:\n\
786 - mz_source_statuses and mz_sink_statuses use `last_status_change_at` (NOT `updated_at`)\n\
787 - mz_cluster_replica_utilization only has `replica_id` — JOIN with mz_cluster_replicas and mz_clusters to get names\n\
788 {introspection_rule}\n\
789 - mz_dataflow_arrangement_sizes.id and mz_dataflows.id are dataflow ids (uint8), not catalog ids: do not JOIN them to mz_catalog.mz_objects.id (text); join through mz_introspection.mz_compute_exports (dataflow_id to export_id) instead, or match the name column, which reads Dataflow: <database>.<schema>.<object>\n\
790 - Use SHOW COLUMNS FROM <table> to verify column names if unsure",
791 ))
792 }
793 }
794}
795
796fn handle_initialize(
797 endpoint_type: McpEndpointType,
798 query_tool_enabled: bool,
799 read_data_product_tool_enabled: bool,
800) -> Result<McpResult, McpRequestError> {
801 Ok(McpResult::Initialize(InitializeResult {
802 protocol_version: MCP_PROTOCOL_VERSION.to_string(),
803 capabilities: Capabilities { tools: json!({}) },
804 server_info: ServerInfo {
805 name: format!("materialize-mcp-{}", endpoint_type),
806 version: env!("CARGO_PKG_VERSION").to_string(),
807 },
808 instructions: endpoint_instructions(
809 endpoint_type,
810 query_tool_enabled,
811 read_data_product_tool_enabled,
812 ),
813 }))
814}
815
816fn handle_tools_list(
817 endpoint_type: McpEndpointType,
818 query_tool_enabled: bool,
819 read_data_product_tool_enabled: bool,
820 max_response_size: usize,
821) -> Result<McpResult, McpRequestError> {
822 let size_hint = format!(
823 "Response limit: {:.1} MB.",
824 f64::cast_lossy(max_response_size) / 1_000_000.0
825 );
826
827 let tools = match endpoint_type {
828 McpEndpointType::Agent => {
829 let mut tools = vec![
830 ToolDefinition {
831 name: "get_data_products".to_string(),
832 title: Some("List Data Products".to_string()),
833 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(),
834 input_schema: json!({
835 "type": "object",
836 "properties": {},
837 "required": []
838 }),
839 annotations: Some(READ_ONLY_ANNOTATIONS),
840 },
841 ToolDefinition {
842 name: "get_data_product_details".to_string(),
843 title: Some("Get Data Product Details".to_string()),
844 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(),
845 input_schema: json!({
846 "type": "object",
847 "properties": {
848 "name": {
849 "type": "string",
850 "description": "Exact name of the data product from get_data_products() list"
851 }
852 },
853 "required": ["name"]
854 }),
855 annotations: Some(READ_ONLY_ANNOTATIONS),
856 },
857 ];
858 if read_data_product_tool_enabled {
859 tools.push(ToolDefinition {
860 name: "read_data_product".to_string(),
861 title: Some("Read Data Product".to_string()),
862 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}"),
863 input_schema: json!({
864 "type": "object",
865 "properties": {
866 "name": {
867 "type": "string",
868 "description": "Exact fully-qualified name of the data product (e.g. '\"materialize\".\"schema\".\"view_name\"')"
869 },
870 "limit": {
871 "type": "integer",
872 "description": format!("Maximum number of rows to return (default {DEFAULT_READ_LIMIT})"),
873 "default": DEFAULT_READ_LIMIT
874 },
875 "cluster": {
876 "type": "string",
877 "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."
878 }
879 },
880 "required": ["name"]
881 }),
882 annotations: Some(READ_ONLY_ANNOTATIONS),
883 });
884 }
885 if query_tool_enabled {
886 tools.push(ToolDefinition {
887 name: "query".to_string(),
888 title: Some("Query Data Products".to_string()),
889 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}"),
890 input_schema: json!({
891 "type": "object",
892 "properties": {
893 "cluster": {
894 "type": "string",
895 "description": "Exact cluster name from the data product details - required for query execution"
896 },
897 "sql_query": {
898 "type": "string",
899 "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."
900 }
901 },
902 "required": ["cluster", "sql_query"]
903 }),
904 annotations: Some(READ_ONLY_ANNOTATIONS),
905 });
906 }
907 tools
908 }
909 McpEndpointType::Developer => {
910 let mut tools = vec![ToolDefinition {
911 name: "query_system_catalog".to_string(),
912 title: Some("Query System Catalog".to_string()),
913 description: concat!(
914 "Query Materialize system catalog tables for troubleshooting and observability. ",
915 "Only mz_*, pg_catalog, and information_schema tables are accessible. ",
916 "Use the mz_internal.mz_ontology_* tables to discover tables, columns, and join paths before writing queries.",
917 ).to_owned() + &format!(" {size_hint}"),
918 input_schema: json!({
919 "type": "object",
920 "properties": {
921 "sql_query": {
922 "type": "string",
923 "description": "PostgreSQL-compatible SELECT, SHOW, or EXPLAIN query referencing mz_*, pg_catalog, or information_schema tables"
924 }
925 },
926 "required": ["sql_query"]
927 }),
928 annotations: Some(READ_ONLY_ANNOTATIONS),
929 }];
930 if query_tool_enabled {
931 tools.push(ToolDefinition {
932 name: "query".to_string(),
933 title: Some("Query".to_string()),
934 description: format!(
935 "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}",
936 ),
937 input_schema: json!({
938 "type": "object",
939 "properties": {
940 "cluster": {
941 "type": "string",
942 "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."
943 },
944 "cluster_replica": {
945 "type": "string",
946 "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."
947 },
948 "sql_query": {
949 "type": "string",
950 "description": "PostgreSQL-compatible SELECT, SHOW, or EXPLAIN statement. Multi-statement queries are rejected."
951 }
952 },
953 "required": ["cluster", "sql_query"]
954 }),
955 annotations: Some(READ_ONLY_ANNOTATIONS),
956 });
957 }
958 tools
959 }
960 };
961
962 Ok(McpResult::ToolsList(ToolsListResult { tools }))
963}
964
965async fn handle_tools_call(
966 client: &mut AuthedClient,
967 params: &ToolsCallParams,
968 endpoint_type: McpEndpointType,
969 query_tool_enabled: bool,
970 read_data_product_tool_enabled: bool,
971 max_response_size: usize,
972 metrics: &McpMetrics,
973) -> Result<McpResult, McpRequestError> {
974 let mut guard = ToolCallGuard::new(metrics, endpoint_type.as_label(), params.to_string());
976
977 let result = match (endpoint_type, params) {
978 (McpEndpointType::Agent, ToolsCallParams::GetDataProducts(_)) => {
979 get_data_products(client, max_response_size).await
980 }
981 (McpEndpointType::Agent, ToolsCallParams::GetDataProductDetails(p)) => {
982 get_data_product_details(client, &p.name, max_response_size).await
983 }
984 (McpEndpointType::Agent, ToolsCallParams::ReadDataProduct(_))
985 if !read_data_product_tool_enabled =>
986 {
987 Err(McpRequestError::ToolNotFound(
988 "read_data_product tool is not available. Use the query tool to read data products."
989 .to_string(),
990 ))
991 }
992 (McpEndpointType::Agent, ToolsCallParams::ReadDataProduct(p)) => {
993 read_data_product(
994 client,
995 &p.name,
996 p.limit,
997 p.cluster.as_deref(),
998 max_response_size,
999 )
1000 .await
1001 }
1002 (McpEndpointType::Agent, ToolsCallParams::Query(_)) if !query_tool_enabled => {
1003 Err(McpRequestError::ToolNotFound(
1004 "query tool is not available. Use get_data_products, get_data_product_details, and read_data_product instead.".to_string(),
1005 ))
1006 }
1007 (McpEndpointType::Agent, ToolsCallParams::Query(p)) => {
1008 execute_query(client, &p.cluster, None, &p.sql_query, max_response_size).await
1011 }
1012 (McpEndpointType::Developer, ToolsCallParams::QuerySystemCatalog(p)) => {
1013 query_system_catalog(client, &p.sql_query, max_response_size).await
1014 }
1015 (McpEndpointType::Developer, ToolsCallParams::Query(_)) if !query_tool_enabled => {
1016 Err(McpRequestError::ToolNotFound(
1017 "query tool is not available. Use query_system_catalog instead.".to_string(),
1018 ))
1019 }
1020 (McpEndpointType::Developer, ToolsCallParams::Query(p)) => {
1021 execute_query(
1022 client,
1023 &p.cluster,
1024 p.cluster_replica.as_deref(),
1025 &p.sql_query,
1026 max_response_size,
1027 )
1028 .await
1029 }
1030 (endpoint, tool) => Err(McpRequestError::ToolNotFound(format!(
1032 "{} is not available on {} endpoint",
1033 tool, endpoint
1034 ))),
1035 };
1036
1037 guard.set_status(call_status(&result));
1038
1039 result
1040}
1041
1042fn call_status<T>(result: &Result<T, McpRequestError>) -> McpCallStatus {
1045 match result {
1046 Ok(_) => McpCallStatus::Ok,
1047 Err(e) => McpCallStatus::Error(e.error_type()),
1048 }
1049}
1050
1051async fn execute_sql(
1053 client: &mut AuthedClient,
1054 query: &str,
1055) -> Result<Vec<Box<serde_json::value::RawValue>>, McpRequestError> {
1056 let mut response = SqlResponse::new();
1057
1058 execute_request(
1059 client,
1060 SqlRequest::Simple {
1061 query: mz_ore::sql::Sql::trusted_external_request(query.to_string()),
1062 },
1063 &mut response,
1064 )
1065 .await
1066 .map_err(|e| McpRequestError::QueryExecutionFailed(e.to_string()))?;
1067
1068 select_single_rows(response.results)
1069}
1070
1071fn select_single_rows(
1077 results: Vec<SqlResult>,
1078) -> Result<Vec<Box<serde_json::value::RawValue>>, McpRequestError> {
1079 let mut rows = None;
1080 for result in results {
1081 match result {
1082 SqlResult::Rows { rows: r, .. } => {
1083 if rows.is_some() {
1084 return Err(McpRequestError::Internal(anyhow!(
1085 "MCP query returned multiple row-producing statements"
1086 )));
1087 }
1088 rows = Some(r);
1089 }
1090 SqlResult::Err { error, .. } => {
1091 return Err(McpRequestError::QueryExecutionFailed(error.message));
1092 }
1093 SqlResult::Ok { .. } => continue,
1094 }
1095 }
1096
1097 rows.ok_or_else(|| {
1098 McpRequestError::QueryExecutionFailed("Query did not return any results".to_string())
1099 })
1100}
1101
1102fn format_rows_response(
1109 rows: Vec<Box<serde_json::value::RawValue>>,
1110 max_size: usize,
1111) -> Result<McpResult, McpRequestError> {
1112 let text =
1115 serde_json::to_string_pretty(&rows).map_err(|e| McpRequestError::Internal(anyhow!(e)))?;
1116
1117 if text.len() > max_size {
1118 return Err(McpRequestError::QueryExecutionFailed(format!(
1119 "Response size ({} bytes) exceeds the {} byte limit. \
1120 Use LIMIT or WHERE to narrow your query.",
1121 text.len(),
1122 max_size,
1123 )));
1124 }
1125
1126 Ok(McpResult::ToolContent(ToolContentResult {
1127 content: vec![ContentBlock {
1128 content_type: "text".to_string(),
1129 text,
1130 }],
1131 is_error: false,
1132 }))
1133}
1134
1135async fn get_data_products(
1136 client: &mut AuthedClient,
1137 max_response_size: usize,
1138) -> Result<McpResult, McpRequestError> {
1139 debug!("Executing get_data_products");
1140 let rows = execute_sql(client, DISCOVERY_QUERY).await?;
1141 debug!("get_data_products returned {} rows", rows.len());
1142
1143 format_rows_response(rows, max_response_size)
1144}
1145
1146async fn get_data_product_details(
1147 client: &mut AuthedClient,
1148 name: &str,
1149 max_response_size: usize,
1150) -> Result<McpResult, McpRequestError> {
1151 debug!(name = %name, "Executing get_data_product_details");
1152
1153 let query = format!("{}{}", DETAILS_QUERY_PREFIX, escaped_string_literal(name));
1154
1155 let rows = execute_sql(client, &query).await?;
1156
1157 if rows.is_empty() {
1158 return Err(McpRequestError::DataProductNotFound(name.to_string()));
1159 }
1160
1161 format_rows_response(rows, max_response_size)
1162}
1163
1164fn safe_data_product_name(name: &str) -> Result<String, McpRequestError> {
1171 let name = name.trim();
1172 if name.is_empty() {
1173 return Err(McpRequestError::QueryValidationFailed(
1174 "Data product name cannot be empty".to_string(),
1175 ));
1176 }
1177
1178 let parsed = parse_item_name_with_limit(name)
1181 .map_err(McpRequestError::QueryValidationFailed)?
1182 .map_err(|_| {
1183 McpRequestError::QueryValidationFailed(format!(
1184 "Invalid data product name: {}. Expected a valid object name, \
1185 e.g. '\"database\".\"schema\".\"name\"' or 'my_view'",
1186 name
1187 ))
1188 })?;
1189
1190 Ok(parsed.to_ast_string_stable())
1193}
1194
1195async fn read_data_product(
1214 client: &mut AuthedClient,
1215 name: &str,
1216 limit: u32,
1217 cluster_override: Option<&str>,
1218 max_response_size: usize,
1219) -> Result<McpResult, McpRequestError> {
1220 debug!(name = %name, limit = limit, cluster_override = ?cluster_override, "Executing read_data_product");
1221
1222 let safe_name = safe_data_product_name(name)?;
1224
1225 let lookup_query = format!(
1231 "SELECT dp.cluster FROM mz_internal.mz_mcp_data_products dp \
1232 WHERE dp.object_name = {} \
1233 ORDER BY dp.cluster NULLS LAST \
1234 LIMIT 1",
1235 escaped_string_literal(name)
1236 );
1237 let lookup_rows = execute_sql(client, &lookup_query).await?;
1238 if lookup_rows.is_empty() {
1239 return Err(McpRequestError::DataProductNotFound(name.to_string()));
1240 }
1241 let catalog_cluster: Option<String> = lookup_rows
1244 .first()
1245 .and_then(|row| serde_json::from_str::<Vec<serde_json::Value>>(row.get()).ok())
1246 .and_then(|row| row.into_iter().next())
1247 .and_then(|v| v.as_str().map(|s| s.to_string()));
1248
1249 let target_cluster: Option<&str> = cluster_override.or(catalog_cluster.as_deref());
1255
1256 let read_query = build_read_query(&safe_name, limit, target_cluster);
1261
1262 let rows = execute_sql(client, &read_query).await?;
1263
1264 format_rows_response(rows, max_response_size)
1265}
1266
1267fn build_read_query(safe_name: &str, limit: u32, target_cluster: Option<&str>) -> String {
1276 let body = format!("SELECT * FROM {safe_name} LIMIT {limit}");
1277 match target_cluster {
1278 Some(cluster) => read_only_txn(
1279 &format!("SET CLUSTER = {}", escaped_string_literal(cluster)),
1280 &body,
1281 ),
1282 None => format!("BEGIN READ ONLY; {body}\n; COMMIT;"),
1283 }
1284}
1285
1286fn read_only_txn(set_clause: &str, body: &str) -> String {
1292 format!("BEGIN READ ONLY; {set_clause}; {body}\n; COMMIT;")
1293}
1294
1295fn validate_readonly_query(sql: &str) -> Result<(), McpRequestError> {
1297 let sql = sql.trim();
1298 if sql.is_empty() {
1299 return Err(McpRequestError::QueryValidationFailed(
1300 "Empty query".to_string(),
1301 ));
1302 }
1303
1304 let stmts = parse_with_limit(sql)
1308 .map_err(McpRequestError::QueryValidationFailed)?
1309 .map_err(|e| {
1310 McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
1311 })?;
1312
1313 if stmts.len() != 1 {
1315 return Err(McpRequestError::QueryValidationFailed(format!(
1316 "Only one query allowed at a time. Found {} statements.",
1317 stmts.len()
1318 )));
1319 }
1320
1321 let stmt = &stmts[0];
1327 use mz_sql_parser::ast::Statement;
1328
1329 match &stmt.ast {
1330 Statement::Select(_)
1331 | Statement::Show(_)
1332 | Statement::ExplainPlan(_)
1333 | Statement::ExplainPushdown(_)
1334 | Statement::ExplainTimestamp(_)
1335 | Statement::ExplainSinkSchema(_)
1336 | Statement::ExplainAnalyzeObject(_)
1337 | Statement::ExplainAnalyzeCluster(_) => Ok(()),
1338 _ => Err(McpRequestError::QueryValidationFailed(
1339 "Only SELECT, SHOW, and EXPLAIN statements are allowed".to_string(),
1340 )),
1341 }
1342}
1343
1344async fn execute_query(
1345 client: &mut AuthedClient,
1346 cluster: &str,
1347 cluster_replica: Option<&str>,
1348 sql_query: &str,
1349 max_response_size: usize,
1350) -> Result<McpResult, McpRequestError> {
1351 debug!(cluster = %cluster, cluster_replica = ?cluster_replica, "Executing user query");
1352
1353 validate_readonly_query(sql_query)?;
1354 validate_cluster_replica(cluster_replica)?;
1355
1356 let combined_query = read_only_txn(&query_set_clause(cluster, cluster_replica), sql_query);
1359
1360 let rows = execute_sql(client, &combined_query).await?;
1361
1362 format_rows_response(rows, max_response_size)
1363}
1364
1365fn query_set_clause(cluster: &str, cluster_replica: Option<&str>) -> String {
1371 let mut set_clause = format!("SET CLUSTER = {}", escaped_string_literal(cluster));
1372 if let Some(replica) = cluster_replica {
1373 set_clause.push_str(&format!(
1374 "; SET CLUSTER_REPLICA = {}",
1375 escaped_string_literal(replica)
1376 ));
1377 }
1378 set_clause
1379}
1380
1381fn validate_cluster_replica(cluster_replica: Option<&str>) -> Result<(), McpRequestError> {
1386 if let Some(replica) = cluster_replica {
1387 if replica.trim().is_empty() {
1388 return Err(McpRequestError::QueryValidationFailed(
1389 "cluster_replica must not be empty or whitespace-only".to_string(),
1390 ));
1391 }
1392 }
1393 Ok(())
1394}
1395
1396async fn query_system_catalog(
1397 client: &mut AuthedClient,
1398 sql_query: &str,
1399 max_response_size: usize,
1400) -> Result<McpResult, McpRequestError> {
1401 debug!("Executing query_system_catalog");
1402
1403 validate_readonly_query(sql_query)?;
1405
1406 validate_system_catalog_query(sql_query)?;
1408
1409 let combined_query = read_only_txn(
1415 "SET search_path = mz_catalog, mz_internal, pg_catalog, information_schema",
1416 sql_query,
1417 );
1418
1419 let rows = execute_sql(client, &combined_query).await?;
1420
1421 format_rows_response(rows, max_response_size)
1422}
1423
1424struct TableReferenceCollector {
1426 tables: Vec<(Option<String>, String)>,
1428 cte_names: std::collections::BTreeSet<String>,
1430}
1431
1432impl TableReferenceCollector {
1433 fn new() -> Self {
1434 Self {
1435 tables: Vec::new(),
1436 cte_names: std::collections::BTreeSet::new(),
1437 }
1438 }
1439}
1440
1441impl<'ast> Visit<'ast, Raw> for TableReferenceCollector {
1442 fn visit_cte(&mut self, cte: &'ast mz_sql_parser::ast::Cte<Raw>) {
1443 self.cte_names
1445 .insert(cte.alias.name.as_str().to_lowercase());
1446 visit::visit_cte(self, cte);
1447 }
1448
1449 fn visit_table_factor(&mut self, table_factor: &'ast mz_sql_parser::ast::TableFactor<Raw>) {
1450 if let mz_sql_parser::ast::TableFactor::Table { name, .. } = table_factor {
1452 match name {
1453 RawItemName::Name(n) | RawItemName::Id(_, n, _) => {
1454 let parts = &n.0;
1455 if !parts.is_empty() {
1456 let table_name = parts.last().unwrap().as_str().to_lowercase();
1457
1458 if self.cte_names.contains(&table_name) {
1460 visit::visit_table_factor(self, table_factor);
1461 return;
1462 }
1463
1464 let schema = if parts.len() >= 2 {
1466 Some(parts[parts.len() - 2].as_str().to_lowercase())
1467 } else {
1468 None
1469 };
1470 self.tables.push((schema, table_name));
1471 }
1472 }
1473 }
1474 }
1475 visit::visit_table_factor(self, table_factor);
1476 }
1477}
1478
1479fn validate_system_catalog_query(sql: &str) -> Result<(), McpRequestError> {
1487 let stmts = parse_with_limit(sql)
1490 .map_err(McpRequestError::QueryValidationFailed)?
1491 .map_err(|e| {
1492 McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
1493 })?;
1494
1495 if stmts.is_empty() {
1496 return Err(McpRequestError::QueryValidationFailed(
1497 "Empty query".to_string(),
1498 ));
1499 }
1500
1501 let mut collector = TableReferenceCollector::new();
1503 for stmt in &stmts {
1504 collector.visit_statement(&stmt.ast);
1505 }
1506
1507 let is_allowed_schema =
1510 |s: &str| SYSTEM_SCHEMAS.contains(&s) && s != namespaces::MZ_UNSAFE_SCHEMA;
1511
1512 let is_system_table = |(schema, table_name): &(Option<String>, String)| match schema {
1517 Some(s) => is_allowed_schema(s.as_str()),
1518 None => table_name.starts_with("mz_") || table_name.starts_with("pg_"),
1519 };
1520
1521 let non_system_tables: Vec<String> = collector
1523 .tables
1524 .iter()
1525 .filter(|t| !is_system_table(t))
1526 .map(|(schema, table)| match schema {
1527 Some(s) => format!("{}.{}", s, table),
1528 None => table.clone(),
1529 })
1530 .collect();
1531
1532 if !non_system_tables.is_empty() {
1533 return Err(McpRequestError::QueryValidationFailed(format!(
1534 "Query references non-system tables: {}. Only system catalog tables (mz_*, pg_catalog, information_schema) are allowed.",
1535 non_system_tables.join(", ")
1536 )));
1537 }
1538
1539 use mz_sql_parser::ast::Statement;
1542 let is_select = stmts.iter().any(|s| matches!(&s.ast, Statement::Select(_)));
1543
1544 if is_select && (collector.tables.is_empty() || !collector.tables.iter().any(is_system_table)) {
1545 return Err(McpRequestError::QueryValidationFailed(
1546 "Query must reference at least one system catalog table".to_string(),
1547 ));
1548 }
1549
1550 Ok(())
1551}
1552
1553#[cfg(test)]
1554mod tests {
1555 use super::*;
1556 use crate::http::sql::{Description, SqlError};
1557
1558 fn raw_rows(rows: Vec<Vec<serde_json::Value>>) -> Vec<Box<serde_json::value::RawValue>> {
1561 rows.iter()
1562 .map(|r| serde_json::value::to_raw_value(r).unwrap())
1563 .collect()
1564 }
1565
1566 fn rows_result(rows: Vec<Vec<serde_json::Value>>) -> SqlResult {
1567 SqlResult::Rows {
1568 tag: String::new(),
1569 rows: raw_rows(rows),
1570 desc: Description { columns: vec![] },
1571 notices: vec![],
1572 }
1573 }
1574
1575 fn ok_result() -> SqlResult {
1576 SqlResult::Ok {
1577 ok: String::new(),
1578 notices: vec![],
1579 parameters: vec![],
1580 }
1581 }
1582
1583 fn err_result(message: &str) -> SqlResult {
1584 SqlResult::Err {
1585 error: SqlError {
1586 message: message.to_string(),
1587 code: String::new(),
1588 detail: None,
1589 hint: None,
1590 position: None,
1591 },
1592 notices: vec![],
1593 }
1594 }
1595
1596 #[mz_ore::test]
1599 fn test_select_single_rows_extracts_rows() {
1600 let rows = vec![vec![serde_json::json!(1)]];
1601 let results = vec![
1602 ok_result(),
1603 ok_result(),
1604 rows_result(rows.clone()),
1605 ok_result(),
1606 ];
1607 let got = select_single_rows(results).unwrap();
1608 let got: Vec<&str> = got.iter().map(|r| r.get()).collect();
1609 assert_eq!(got, vec!["[1]"]);
1610 }
1611
1612 #[mz_ore::test]
1614 fn test_select_single_rows_requires_rows() {
1615 let err = select_single_rows(vec![ok_result(), ok_result()]).unwrap_err();
1616 assert!(
1617 matches!(err, McpRequestError::QueryExecutionFailed(_)),
1618 "{err:?}"
1619 );
1620 }
1621
1622 #[mz_ore::test]
1625 fn test_select_single_rows_rejects_multiple() {
1626 let results = vec![rows_result(vec![]), rows_result(vec![])];
1627 let err = select_single_rows(results).unwrap_err();
1628 assert!(matches!(err, McpRequestError::Internal(_)), "{err:?}");
1629 }
1630
1631 #[mz_ore::test]
1633 fn test_select_single_rows_surfaces_error() {
1634 let err = select_single_rows(vec![ok_result(), err_result("boom")]).unwrap_err();
1635 match err {
1636 McpRequestError::QueryExecutionFailed(msg) => assert_eq!(msg, "boom"),
1637 other => panic!("unexpected error: {other:?}"),
1638 }
1639 }
1640
1641 #[mz_ore::test]
1644 fn test_validate_origin() {
1645 let allowed = [HeaderValue::from_static("https://good.example")];
1646
1647 assert!(validate_origin(&HeaderMap::new(), &allowed).is_none());
1648
1649 let mut ok = HeaderMap::new();
1650 ok.insert(http::header::ORIGIN, allowed[0].clone());
1651 assert!(validate_origin(&ok, &allowed).is_none());
1652
1653 let mut bad = HeaderMap::new();
1654 bad.insert(
1655 http::header::ORIGIN,
1656 HeaderValue::from_static("https://evil.example"),
1657 );
1658 let rejected = validate_origin(&bad, &allowed);
1659 assert_eq!(
1660 rejected
1661 .expect("disallowed origin must be rejected")
1662 .status(),
1663 StatusCode::FORBIDDEN,
1664 );
1665 }
1666
1667 #[mz_ore::test]
1670 fn test_mcp_response_constructors() {
1671 let id = serde_json::json!(1);
1672
1673 let ok = McpResponse::success(
1674 id.clone(),
1675 McpResult::ToolContent(ToolContentResult {
1676 content: vec![],
1677 is_error: false,
1678 }),
1679 );
1680 assert_eq!(ok.jsonrpc, JSONRPC_VERSION);
1681 assert!(ok.result.is_some());
1682 assert!(ok.error.is_none());
1683
1684 let err = McpResponse::error(id, McpRequestError::ToolNotFound("t".to_string()).into());
1685 assert_eq!(err.jsonrpc, JSONRPC_VERSION);
1686 assert!(err.result.is_none());
1687 assert!(err.error.is_some());
1688 }
1689
1690 #[mz_ore::test]
1691 fn test_validate_readonly_query_select() {
1692 assert!(validate_readonly_query("SELECT * FROM mz_tables").is_ok());
1693 assert!(validate_readonly_query("SELECT 1 + 2").is_ok());
1694 assert!(validate_readonly_query(" SELECT 1 ").is_ok());
1695 }
1696
1697 #[mz_ore::test]
1698 fn test_validate_readonly_query_subqueries() {
1699 assert!(
1701 validate_readonly_query(
1702 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
1703 )
1704 .is_ok()
1705 );
1706
1707 assert!(
1709 validate_readonly_query(
1710 "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
1711 )
1712 .is_ok()
1713 );
1714
1715 assert!(validate_readonly_query(
1717 "SELECT * FROM mz_tables t WHERE EXISTS (SELECT 1 FROM mz_columns c WHERE c.id = t.id)"
1718 )
1719 .is_ok());
1720
1721 assert!(validate_readonly_query(
1723 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns WHERE type IN (SELECT name FROM mz_types))"
1724 )
1725 .is_ok());
1726
1727 assert!(
1729 validate_readonly_query(
1730 "SELECT * FROM mz_tables WHERE id = (SELECT MAX(id) FROM mz_columns)"
1731 )
1732 .is_ok()
1733 );
1734 }
1735
1736 #[mz_ore::test]
1737 fn test_validate_readonly_query_show() {
1738 assert!(validate_readonly_query("SHOW CLUSTERS").is_ok());
1739 assert!(validate_readonly_query("SHOW TABLES").is_ok());
1740 }
1741
1742 #[mz_ore::test]
1743 fn test_validate_readonly_query_explain() {
1744 assert!(validate_readonly_query("EXPLAIN SELECT 1").is_ok());
1749 assert!(
1750 validate_readonly_query("EXPLAIN FILTER PUSHDOWN FOR SELECT * FROM mz_tables").is_ok()
1751 );
1752 assert!(validate_readonly_query("EXPLAIN TIMESTAMP FOR SELECT 1").is_ok());
1753 assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR INDEX foo").is_ok());
1754 assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR MATERIALIZED VIEW mv").is_ok());
1755 assert!(validate_readonly_query("EXPLAIN ANALYZE CLUSTER MEMORY").is_ok());
1756 }
1757
1758 #[mz_ore::test]
1759 fn test_validate_readonly_query_rejects_writes() {
1760 assert!(validate_readonly_query("INSERT INTO t VALUES (1)").is_err());
1761 assert!(validate_readonly_query("UPDATE t SET a = 1").is_err());
1762 assert!(validate_readonly_query("DELETE FROM t").is_err());
1763 assert!(validate_readonly_query("CREATE TABLE t (a INT)").is_err());
1764 assert!(validate_readonly_query("DROP TABLE t").is_err());
1765 }
1766
1767 #[mz_ore::test]
1768 fn test_validate_readonly_query_rejects_multiple() {
1769 assert!(validate_readonly_query("SELECT 1; SELECT 2").is_err());
1770 }
1771
1772 #[mz_ore::test]
1773 fn test_validate_readonly_query_rejects_empty() {
1774 assert!(validate_readonly_query("").is_err());
1775 assert!(validate_readonly_query(" ").is_err());
1776 }
1777
1778 #[mz_ore::test]
1785 fn test_validate_readonly_query_enforces_size_limit() {
1786 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
1787 let oversized: String =
1789 "SELECT 1;".repeat((MAX_STATEMENT_BATCH_SIZE / "SELECT 1;".len()) + 1);
1790 let err = validate_readonly_query(&oversized).expect_err("should be rejected");
1791 let msg = err.to_string();
1792 assert!(
1793 msg.contains("statement batch size cannot exceed"),
1794 "expected size-guard error, got: {msg}"
1795 );
1796 }
1797
1798 #[mz_ore::test]
1799 fn test_validate_system_catalog_query_enforces_size_limit() {
1800 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
1801 let stmt = "SELECT * FROM mz_tables;";
1802 let oversized: String = stmt.repeat((MAX_STATEMENT_BATCH_SIZE / stmt.len()) + 1);
1803 let err = validate_system_catalog_query(&oversized).expect_err("should be rejected");
1804 let msg = err.to_string();
1805 assert!(
1806 msg.contains("statement batch size cannot exceed"),
1807 "expected size-guard error, got: {msg}"
1808 );
1809 }
1810
1811 #[mz_ore::test]
1812 fn test_validate_system_catalog_query_accepts_mz_tables() {
1813 assert!(validate_system_catalog_query("SELECT * FROM mz_tables").is_ok());
1814 assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_comments").is_ok());
1815 assert!(
1816 validate_system_catalog_query(
1817 "SELECT * FROM mz_tables t JOIN mz_columns c ON t.id = c.id"
1818 )
1819 .is_ok()
1820 );
1821 }
1822
1823 #[mz_ore::test]
1824 fn test_validate_system_catalog_query_subqueries() {
1825 assert!(
1827 validate_system_catalog_query(
1828 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
1829 )
1830 .is_ok()
1831 );
1832
1833 assert!(validate_system_catalog_query(
1835 "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM mz_columns WHERE type IN (SELECT id FROM mz_types))"
1836 )
1837 .is_ok());
1838
1839 assert!(
1841 validate_system_catalog_query(
1842 "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
1843 )
1844 .is_ok()
1845 );
1846
1847 assert!(
1849 validate_system_catalog_query(
1850 "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM user_data)"
1851 )
1852 .is_err()
1853 );
1854
1855 assert!(validate_system_catalog_query(
1857 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM (SELECT id FROM user_table) AS t)"
1858 )
1859 .is_err());
1860 }
1861
1862 #[mz_ore::test]
1863 fn test_validate_system_catalog_query_rejects_user_tables() {
1864 assert!(validate_system_catalog_query("SELECT * FROM user_data").is_err());
1865 assert!(validate_system_catalog_query("SELECT * FROM my_table").is_err());
1866 assert!(
1868 validate_system_catalog_query("SELECT * FROM user_data WHERE 'mz_' IS NOT NULL")
1869 .is_err()
1870 );
1871 }
1872
1873 #[mz_ore::test]
1874 fn test_validate_system_catalog_query_allows_functions() {
1875 assert!(
1877 validate_system_catalog_query(
1878 "SELECT date_part('year', now())::int4 AS y FROM mz_tables LIMIT 1"
1879 )
1880 .is_ok()
1881 );
1882 assert!(validate_system_catalog_query("SELECT length(name) FROM mz_tables").is_ok());
1883 assert!(
1884 validate_system_catalog_query(
1885 "SELECT count(*) FROM mz_sources WHERE now() > created_at"
1886 )
1887 .is_ok()
1888 );
1889 }
1890
1891 #[mz_ore::test]
1892 fn test_validate_system_catalog_query_unqualified_pg_catalog() {
1893 assert!(validate_system_catalog_query("SELECT * FROM pg_class").is_ok());
1896 assert!(validate_system_catalog_query("SELECT nspname FROM pg_namespace").is_ok());
1897 }
1898
1899 #[mz_ore::test]
1900 fn test_validate_system_catalog_query_schema_qualified() {
1901 assert!(validate_system_catalog_query("SELECT * FROM mz_catalog.mz_tables").is_ok());
1903 assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_sessions").is_ok());
1904 assert!(validate_system_catalog_query("SELECT * FROM pg_catalog.pg_type").is_ok());
1905 assert!(validate_system_catalog_query("SELECT * FROM information_schema.tables").is_ok());
1906
1907 assert!(validate_system_catalog_query("SELECT * FROM public.user_table").is_err());
1909 assert!(validate_system_catalog_query("SELECT * FROM myschema.mytable").is_err());
1910
1911 assert!(
1913 validate_system_catalog_query("SELECT * FROM mz_unsafe.mz_some_table").is_err(),
1914 "mz_unsafe schema should be blocked even though it is a system schema"
1915 );
1916
1917 assert!(
1919 validate_system_catalog_query(
1920 "SELECT * FROM mz_catalog.mz_tables JOIN public.user_data ON true"
1921 )
1922 .is_err()
1923 );
1924 }
1925
1926 #[mz_ore::test]
1927 fn test_validate_system_catalog_query_adversarial_cases() {
1928 assert!(
1930 validate_system_catalog_query(
1931 "WITH user_cte AS (SELECT * FROM user_data) \
1932 SELECT * FROM mz_tables, user_cte"
1933 )
1934 .is_err(),
1935 "Should reject CTE referencing user table"
1936 );
1937
1938 assert!(
1940 validate_system_catalog_query(
1941 "WITH \
1942 cte1 AS (SELECT * FROM mz_tables), \
1943 cte2 AS (SELECT * FROM cte1), \
1944 cte3 AS (SELECT * FROM user_data) \
1945 SELECT * FROM cte2"
1946 )
1947 .is_err(),
1948 "Should reject CTE chain with user table"
1949 );
1950
1951 assert!(
1953 validate_system_catalog_query(
1954 "SELECT * FROM mz_tables t1 \
1955 JOIN user_data u ON t1.id = u.id \
1956 JOIN mz_sources s ON t1.id = s.id"
1957 )
1958 .is_err(),
1959 "Should reject multi-join with user table"
1960 );
1961
1962 assert!(
1964 validate_system_catalog_query(
1965 "SELECT * FROM mz_tables t \
1966 LEFT JOIN user_data u ON t.id = u.table_id \
1967 WHERE u.id IS NULL"
1968 )
1969 .is_err(),
1970 "Should reject LEFT JOIN with user table"
1971 );
1972
1973 assert!(
1975 validate_system_catalog_query(
1976 "SELECT * FROM mz_tables WHERE id IN \
1977 (SELECT table_id FROM (SELECT * FROM user_data) AS u)"
1978 )
1979 .is_err(),
1980 "Should reject nested subquery with user table"
1981 );
1982
1983 assert!(
1985 validate_system_catalog_query(
1986 "SELECT name FROM mz_tables \
1987 UNION \
1988 SELECT name FROM user_data"
1989 )
1990 .is_err(),
1991 "Should reject UNION with user table"
1992 );
1993
1994 assert!(
1996 validate_system_catalog_query(
1997 "SELECT id FROM mz_sources \
1998 UNION ALL \
1999 SELECT id FROM products"
2000 )
2001 .is_err(),
2002 "Should reject UNION ALL with user table"
2003 );
2004
2005 assert!(
2007 validate_system_catalog_query("SELECT * FROM mz_tables CROSS JOIN user_data").is_err(),
2008 "Should reject CROSS JOIN with user table"
2009 );
2010
2011 assert!(
2013 validate_system_catalog_query(
2014 "SELECT t.*, (SELECT COUNT(*) FROM user_data) AS cnt FROM mz_tables t"
2015 )
2016 .is_err(),
2017 "Should reject subquery in SELECT with user table"
2018 );
2019
2020 assert!(
2022 validate_system_catalog_query("SELECT * FROM mz_catalogg.fake_table").is_err(),
2023 "Should reject typo-squatting schema name"
2024 );
2025 assert!(
2026 validate_system_catalog_query("SELECT * FROM mz_catalog_hack.fake_table").is_err(),
2027 "Should reject fake schema with mz_catalog prefix"
2028 );
2029
2030 assert!(
2032 validate_system_catalog_query(
2033 "SELECT * FROM mz_tables t, LATERAL (SELECT * FROM user_data WHERE id = t.id) u"
2034 )
2035 .is_err(),
2036 "Should reject LATERAL join with user table"
2037 );
2038
2039 assert!(
2041 validate_system_catalog_query(
2042 "WITH \
2043 tables AS (SELECT * FROM mz_tables), \
2044 sources AS (SELECT * FROM mz_sources) \
2045 SELECT t.name, s.name \
2046 FROM tables t \
2047 JOIN sources s ON t.id = s.id \
2048 WHERE t.id IN (SELECT id FROM mz_columns)"
2049 )
2050 .is_ok(),
2051 "Should allow complex query with only system tables"
2052 );
2053
2054 assert!(
2056 validate_system_catalog_query(
2057 "SELECT name FROM mz_tables \
2058 UNION \
2059 SELECT name FROM mz_sources"
2060 )
2061 .is_ok(),
2062 "Should allow UNION of system tables"
2063 );
2064 }
2065
2066 #[mz_ore::test]
2067 fn test_validate_system_catalog_query_rejects_constant_queries() {
2068 assert!(
2071 validate_system_catalog_query("SELECT 1").is_err(),
2072 "Should reject constant SELECT with no table references"
2073 );
2074 assert!(
2075 validate_system_catalog_query("SELECT 1 + 2, 'hello'").is_err(),
2076 "Should reject constant expression SELECT"
2077 );
2078 assert!(
2079 validate_system_catalog_query("SELECT now()").is_err(),
2080 "Should reject function-only SELECT with no table references"
2081 );
2082 }
2083
2084 #[mz_ore::test]
2085 fn test_validate_system_catalog_query_rejects_mixed_tables() {
2086 assert!(
2087 validate_system_catalog_query(
2088 "SELECT * FROM mz_tables t JOIN user_data u ON t.id = u.table_id"
2089 )
2090 .is_err()
2091 );
2092 }
2093
2094 #[mz_ore::test]
2095 fn test_validate_system_catalog_query_allows_show() {
2096 assert!(
2098 validate_system_catalog_query("SHOW TABLES FROM mz_internal").is_ok(),
2099 "SHOW TABLES FROM mz_internal should be allowed"
2100 );
2101 assert!(
2102 validate_system_catalog_query("SHOW TABLES FROM mz_catalog").is_ok(),
2103 "SHOW TABLES FROM mz_catalog should be allowed"
2104 );
2105 assert!(
2106 validate_system_catalog_query("SHOW CLUSTERS").is_ok(),
2107 "SHOW CLUSTERS should be allowed"
2108 );
2109 assert!(
2110 validate_system_catalog_query("SHOW SOURCES").is_ok(),
2111 "SHOW SOURCES should be allowed"
2112 );
2113 assert!(
2114 validate_system_catalog_query("SHOW TABLES").is_ok(),
2115 "SHOW TABLES should be allowed"
2116 );
2117 }
2118
2119 #[mz_ore::test]
2120 fn test_validate_system_catalog_query_allows_explain() {
2121 assert!(
2122 validate_system_catalog_query("EXPLAIN SELECT * FROM mz_tables").is_ok(),
2123 "EXPLAIN of system table query should be allowed"
2124 );
2125 assert!(
2126 validate_system_catalog_query("EXPLAIN SELECT 1").is_ok(),
2127 "EXPLAIN SELECT 1 should be allowed"
2128 );
2129 }
2130
2131 #[mz_ore::test(tokio::test)]
2134 async fn test_tools_list_agent_query_tool_disabled() {
2135 let result = handle_tools_list(McpEndpointType::Agent, false, true, 1_000_000).unwrap();
2136 let McpResult::ToolsList(list) = result else {
2137 panic!("Expected ToolsList result");
2138 };
2139 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2140 assert!(
2141 tool_names.contains(&"get_data_products"),
2142 "get_data_products should always be present"
2143 );
2144 assert!(
2145 tool_names.contains(&"get_data_product_details"),
2146 "get_data_product_details should always be present"
2147 );
2148 assert!(
2149 tool_names.contains(&"read_data_product"),
2150 "read_data_product should be present when its flag is on"
2151 );
2152 assert!(
2153 !tool_names.contains(&"query"),
2154 "query tool should be hidden when disabled"
2155 );
2156 }
2157
2158 #[mz_ore::test(tokio::test)]
2159 async fn test_tools_list_agent_query_tool_enabled() {
2160 let result = handle_tools_list(McpEndpointType::Agent, true, true, 1_000_000).unwrap();
2161 let McpResult::ToolsList(list) = result else {
2162 panic!("Expected ToolsList result");
2163 };
2164 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2165 assert!(
2166 tool_names.contains(&"get_data_products"),
2167 "get_data_products should always be present"
2168 );
2169 assert!(
2170 tool_names.contains(&"get_data_product_details"),
2171 "get_data_product_details should always be present"
2172 );
2173 assert!(
2174 tool_names.contains(&"read_data_product"),
2175 "read_data_product should be present when its flag is on"
2176 );
2177 assert!(
2178 tool_names.contains(&"query"),
2179 "query tool should be present when enabled"
2180 );
2181 }
2182
2183 #[mz_ore::test(tokio::test)]
2184 async fn test_tools_list_agent_read_data_product_tool_disabled() {
2185 let result = handle_tools_list(McpEndpointType::Agent, true, false, 1_000_000).unwrap();
2186 let McpResult::ToolsList(list) = result else {
2187 panic!("Expected ToolsList result");
2188 };
2189 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2190 assert!(
2191 tool_names.contains(&"get_data_products"),
2192 "get_data_products should always be present"
2193 );
2194 assert!(
2195 tool_names.contains(&"get_data_product_details"),
2196 "get_data_product_details should always be present"
2197 );
2198 assert!(
2199 !tool_names.contains(&"read_data_product"),
2200 "read_data_product should be hidden when disabled"
2201 );
2202 assert!(
2203 tool_names.contains(&"query"),
2204 "query tool should remain present when enabled"
2205 );
2206 }
2207
2208 #[mz_ore::test(tokio::test)]
2213 async fn test_tools_list_agent_both_read_tools_disabled() {
2214 let result = handle_tools_list(McpEndpointType::Agent, false, false, 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_eq!(
2220 tool_names
2221 .iter()
2222 .copied()
2223 .collect::<std::collections::BTreeSet<_>>(),
2224 ["get_data_product_details", "get_data_products"]
2225 .into_iter()
2226 .collect(),
2227 "only discovery tools should be advertised when both read flags are off",
2228 );
2229
2230 let instructions = endpoint_instructions(McpEndpointType::Agent, false, false)
2231 .expect("agent instructions must be present");
2232 assert!(
2233 !instructions.contains("Use the `query` tool"),
2234 "instructions must not point at query when it is hidden: {instructions}",
2235 );
2236 assert!(
2237 !instructions.contains("`read_data_product` automatically"),
2238 "instructions must not point at read_data_product when it is hidden: {instructions}",
2239 );
2240 assert!(
2241 instructions.contains("discovery only"),
2242 "instructions must tell the agent it is discovery-only: {instructions}",
2243 );
2244 }
2245
2246 #[mz_ore::test(tokio::test)]
2247 async fn test_tools_list_developer_query_tool_disabled() {
2248 let result = handle_tools_list(McpEndpointType::Developer, false, true, 1_000_000).unwrap();
2251 let McpResult::ToolsList(list) = result else {
2252 panic!("Expected ToolsList result");
2253 };
2254 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2255 assert!(
2256 tool_names.contains(&"query_system_catalog"),
2257 "query_system_catalog should always be present on developer"
2258 );
2259 assert!(
2260 !tool_names.contains(&"query"),
2261 "query tool should be hidden when disabled"
2262 );
2263 }
2264
2265 #[mz_ore::test(tokio::test)]
2266 async fn test_tools_list_developer_query_tool_enabled() {
2267 let result = handle_tools_list(McpEndpointType::Developer, true, true, 1_000_000).unwrap();
2268 let McpResult::ToolsList(list) = result else {
2269 panic!("Expected ToolsList result");
2270 };
2271 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2272 assert!(
2273 tool_names.contains(&"query_system_catalog"),
2274 "query_system_catalog should always be present on developer"
2275 );
2276 assert!(
2277 tool_names.contains(&"query"),
2278 "query tool should be present on developer when enabled"
2279 );
2280
2281 let instructions = endpoint_instructions(McpEndpointType::Developer, true, true)
2282 .expect("developer instructions must be present");
2283 assert!(
2284 instructions.contains("through the query tool with its cluster argument"),
2285 "instructions must route mz_introspection through query: {instructions}",
2286 );
2287 }
2288
2289 #[mz_ore::test]
2290 fn test_developer_instructions_query_tool_disabled() {
2291 let instructions = endpoint_instructions(McpEndpointType::Developer, false, true)
2292 .expect("developer instructions must be present");
2293 assert!(
2294 instructions.contains("cluster-targeted introspection is unavailable")
2295 && !instructions.contains("through the query tool"),
2296 "instructions must not route to the hidden query tool: {instructions}",
2297 );
2298 }
2299
2300 #[mz_ore::test]
2303 fn test_tools_list_accepts_optional_params() {
2304 for body in [
2305 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
2306 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":null}"#,
2307 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#,
2308 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"progressToken":0}}}"#,
2309 ] {
2310 let req: McpRequest = serde_json::from_str(body)
2311 .unwrap_or_else(|e| panic!("tools/list must deserialize: {body}: {e}"));
2312 assert!(
2313 matches!(req.method, McpMethod::ToolsList(_)),
2314 "expected ToolsList for {body}"
2315 );
2316 }
2317 }
2318
2319 #[mz_ore::test]
2323 fn test_named_methods_accept_optional_params() {
2324 for (body, want_ping) in [
2325 (r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#, true),
2326 (
2327 r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}"#,
2328 true,
2329 ),
2330 (
2331 r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{"_meta":{"progressToken":0}}}"#,
2332 true,
2333 ),
2334 (
2335 r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
2336 false,
2337 ),
2338 (
2339 r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{"_meta":{"x":1}}}"#,
2340 false,
2341 ),
2342 ] {
2343 let req: McpRequest = serde_json::from_str(body)
2344 .unwrap_or_else(|e| panic!("must deserialize: {body}: {e}"));
2345 if want_ping {
2346 assert!(matches!(req.method, McpMethod::Ping(_)), "for {body}");
2347 } else {
2348 assert!(
2349 matches!(req.method, McpMethod::NotificationsInitialized(_)),
2350 "for {body}"
2351 );
2352 }
2353 }
2354 }
2355
2356 #[mz_ore::test]
2359 fn test_format_rows_response_within_limit() {
2360 let rows = vec![vec![json!("a"), json!(1)], vec![json!("b"), json!(2)]];
2361 let result = format_rows_response(raw_rows(rows), 1_000_000).unwrap();
2362 let McpResult::ToolContent(content) = result else {
2363 panic!("Expected ToolContent");
2364 };
2365 assert_eq!(content.content.len(), 1);
2366 assert!(content.content[0].text.contains("\"a\""));
2367 assert!(content.content[0].text.contains("\"b\""));
2368 }
2369
2370 #[mz_ore::test]
2371 fn test_format_rows_response_errors_when_over_limit() {
2372 let rows: Vec<Vec<serde_json::Value>> = (0..100)
2373 .map(|i| vec![json!(format!("row_{}", i)), json!(i)])
2374 .collect();
2375 let err = format_rows_response(raw_rows(rows), 500).unwrap_err();
2376 let msg = err.to_string();
2377 assert!(
2378 msg.contains("exceeds the 500 byte limit"),
2379 "Error should mention the size limit, got: {msg}"
2380 );
2381 assert!(
2382 msg.contains("Use LIMIT or WHERE"),
2383 "Error should suggest narrowing the query, got: {msg}"
2384 );
2385 }
2386
2387 #[mz_ore::test]
2388 fn test_format_rows_response_empty_rows() {
2389 let rows: Vec<Vec<serde_json::Value>> = vec![];
2390 let result = format_rows_response(raw_rows(rows), 1000).unwrap();
2391 let McpResult::ToolContent(content) = result else {
2392 panic!("Expected ToolContent");
2393 };
2394 assert_eq!(content.content.len(), 1);
2395 assert_eq!(content.content[0].text, "[]");
2396 }
2397
2398 #[mz_ore::test]
2401 fn test_safe_data_product_name_valid() {
2402 assert_eq!(
2404 safe_data_product_name(r#""materialize"."public"."my_view""#).unwrap(),
2405 r#""materialize"."public"."my_view""#
2406 );
2407 assert_eq!(
2409 safe_data_product_name(r#""public"."my_view""#).unwrap(),
2410 r#""public"."my_view""#
2411 );
2412 assert_eq!(safe_data_product_name("my_view").unwrap(), r#""my_view""#);
2414 }
2415
2416 #[mz_ore::test]
2417 fn test_safe_data_product_name_rejects_empty() {
2418 assert!(safe_data_product_name("").is_err());
2419 assert!(safe_data_product_name(" ").is_err());
2420 }
2421
2422 #[mz_ore::test]
2426 fn test_safe_data_product_name_enforces_size_limit() {
2427 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
2428 let oversized: String = "(".repeat(MAX_STATEMENT_BATCH_SIZE + 1);
2429 let err = safe_data_product_name(&oversized).expect_err("should be rejected");
2430 let msg = err.to_string();
2431 assert!(
2432 msg.contains("statement batch size cannot exceed"),
2433 "expected size-guard error, got: {msg}"
2434 );
2435 }
2436
2437 #[mz_ore::test]
2438 fn test_safe_data_product_name_rejects_sql_injection() {
2439 assert!(safe_data_product_name("my_view; DROP TABLE users").is_err());
2441 assert!(safe_data_product_name("my_view UNION SELECT * FROM secrets").is_err());
2443 assert!(safe_data_product_name("my_view, secrets").is_err());
2445 assert!(safe_data_product_name("my_view WHERE 1=1 --").is_err());
2447 }
2448
2449 #[mz_ore::test]
2451 fn test_read_only_txn_comment_cannot_swallow_commit() {
2452 let sql = read_only_txn("SET CLUSTER = 'c'", "SELECT 1 --");
2453 assert!(
2454 sql.contains("\n; COMMIT;"),
2455 "COMMIT must sit on its own line: {sql}",
2456 );
2457 }
2458
2459 #[mz_ore::test]
2463 fn test_query_set_clause_without_replica() {
2464 let clause = query_set_clause("prod_cluster", None);
2465 assert_eq!(clause, "SET CLUSTER = 'prod_cluster'");
2466 }
2467
2468 #[mz_ore::test]
2472 fn test_query_set_clause_with_replica() {
2473 let clause = query_set_clause("prod_cluster", Some("r1"));
2474 assert_eq!(
2475 clause,
2476 "SET CLUSTER = 'prod_cluster'; SET CLUSTER_REPLICA = 'r1'"
2477 );
2478 }
2479
2480 #[mz_ore::test]
2484 fn test_query_set_clause_escapes_replica_name() {
2485 let clause = query_set_clause("c", Some("evil'; DROP TABLE secrets; --"));
2486 assert_eq!(
2487 clause,
2488 "SET CLUSTER = 'c'; SET CLUSTER_REPLICA = 'evil''; DROP TABLE secrets; --'"
2489 );
2490 }
2491
2492 #[mz_ore::test]
2495 fn test_validate_cluster_replica_accepts_none_and_names() {
2496 assert!(validate_cluster_replica(None).is_ok());
2497 assert!(validate_cluster_replica(Some("r1")).is_ok());
2498 }
2499
2500 #[mz_ore::test]
2504 fn test_validate_cluster_replica_rejects_empty() {
2505 for name in ["", " ", "\t\n"] {
2506 assert!(
2507 matches!(
2508 validate_cluster_replica(Some(name)),
2509 Err(McpRequestError::QueryValidationFailed(_))
2510 ),
2511 "expected validation error for {name:?}",
2512 );
2513 }
2514 }
2515
2516 #[mz_ore::test]
2522 fn test_build_read_query_with_cluster() {
2523 let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, Some("prod_cluster"));
2524 assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
2525 assert!(sql.contains("SET CLUSTER = 'prod_cluster'"), "{sql}");
2526 assert!(
2527 sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
2528 "{sql}",
2529 );
2530 assert!(sql.contains("COMMIT"), "{sql}");
2531 }
2532
2533 #[mz_ore::test]
2536 fn test_build_read_query_without_cluster() {
2537 let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, None);
2538 assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
2539 assert!(!sql.contains("SET CLUSTER"), "{sql}");
2540 assert!(
2541 sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
2542 "{sql}",
2543 );
2544 assert!(sql.contains("COMMIT"), "{sql}");
2545 }
2546
2547 #[mz_ore::test]
2552 fn test_build_read_query_escapes_cluster_name() {
2553 let sql = build_read_query(
2554 "\"db\".\"sch\".\"v\"",
2555 10,
2556 Some("evil'; DROP TABLE secrets; --"),
2557 );
2558 assert!(
2560 sql.contains("SET CLUSTER = 'evil''; DROP TABLE secrets; --'"),
2561 "single quote should be doubled inside the literal: {sql}",
2562 );
2563 assert_eq!(
2566 sql.matches("SET CLUSTER").count(),
2567 1,
2568 "exactly one SET CLUSTER statement: {sql}",
2569 );
2570 assert_eq!(
2571 sql.matches("DROP TABLE").count(),
2572 1,
2573 "DROP TABLE should appear once, inside the quoted literal: {sql}",
2574 );
2575 }
2576
2577 #[mz_ore::test]
2578 fn test_mcp_error_codes() {
2579 assert_eq!(
2580 McpRequestError::InvalidJsonRpcVersion.error_code(),
2581 error_codes::INVALID_REQUEST
2582 );
2583 assert_eq!(
2584 McpRequestError::MethodNotFound("test".to_string()).error_code(),
2585 error_codes::METHOD_NOT_FOUND
2586 );
2587 assert_eq!(
2588 McpRequestError::QueryExecutionFailed("test".to_string()).error_code(),
2589 error_codes::INTERNAL_ERROR
2590 );
2591 }
2592}