1use std::sync::Arc;
29
30use std::borrow::Cow;
31
32use anyhow::anyhow;
33use axum::Extension;
34use axum::Json;
35use axum::body::Bytes;
36use axum::extract::rejection::{JsonRejection, MissingJsonContentType};
37use axum::response::IntoResponse;
38use base64::Engine;
39use base64::engine::general_purpose::STANDARD as BASE64;
40use http::{HeaderMap, HeaderValue, StatusCode, header};
41use mz_adapter_types::dyncfgs::{
42 ENABLE_MCP_AGENT, ENABLE_MCP_AGENT_QUERY_TOOL, ENABLE_MCP_AGENT_READ_DATA_PRODUCT_TOOL,
43 ENABLE_MCP_DEVELOPER, ENABLE_MCP_DEVELOPER_QUERY_TOOL, ENABLE_MCP_PROTOCOL_2026_07_28,
44 MCP_MAX_RESPONSE_SIZE, MCP_REQUEST_TIMEOUT,
45};
46use mz_ore::cast::CastLossy;
47use mz_repr::namespaces::{self, SYSTEM_SCHEMAS};
48use mz_sql::parse::{parse_item_name_with_limit, parse_with_limit};
49use mz_sql::session::metadata::SessionMetadata;
50use mz_sql::session::vars::{APPLICATION_NAME, Var, VarInput};
51use mz_sql_parser::ast::display::{AstDisplay, escaped_string_literal};
52use mz_sql_parser::ast::visit::{self, Visit};
53use mz_sql_parser::ast::{Raw, RawItemName};
54use serde::{Deserialize, Serialize};
55use serde_json::json;
56use thiserror::Error;
57use tracing::{debug, warn};
58
59use crate::http::AuthedClient;
60use crate::http::mcp_metrics::{McpCallStatus, McpMetrics, ToolCallGuard};
61use crate::http::sql::{SqlRequest, SqlResponse, SqlResult, execute_request};
62
63const JSONRPC_VERSION: &str = "2.0";
67
68const MCP_PROTOCOL_VERSION: &str = ProtocolVersion::V2025_11_25.as_str();
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74enum ProtocolVersion {
75 V2025_11_25,
77 V2026_07_28,
79}
80
81impl ProtocolVersion {
82 fn select(values: &[HeaderValue], v2026_07_28_enabled: bool) -> Self {
93 if v2026_07_28_enabled && asks_for_2026_07_28(values) {
94 Self::V2026_07_28
95 } else {
96 Self::V2025_11_25
97 }
98 }
99
100 const fn as_str(self) -> &'static str {
101 match self {
102 Self::V2025_11_25 => "2025-11-25",
103 Self::V2026_07_28 => "2026-07-28",
104 }
105 }
106
107 fn is_modern(self) -> bool {
112 matches!(self, Self::V2026_07_28)
113 }
114}
115
116#[derive(Debug, Default, Deserialize)]
119struct RawRequest {
120 jsonrpc: Option<serde_json::Value>,
121 #[serde(default, deserialize_with = "present_id")]
122 id: Option<serde_json::Value>,
123 method: Option<serde_json::Value>,
124 params: Option<serde_json::Value>,
125}
126
127impl RawRequest {
128 fn parse(body: &[u8]) -> Option<Self> {
130 serde_json::from_slice(body).ok()
131 }
132
133 fn method(&self) -> Option<&str> {
134 self.method.as_ref()?.as_str()
135 }
136
137 fn tool(&self) -> ToolName<'_> {
138 if self.method() != Some("tools/call") {
139 return ToolName::NotToolsCall;
140 }
141 match self.params.as_ref().and_then(|params| params.get("name")) {
142 Some(serde_json::Value::String(name)) => ToolName::Name(name),
143 _ => ToolName::Missing,
144 }
145 }
146}
147
148#[derive(Debug, Clone, Copy)]
150enum ToolName<'a> {
151 NotToolsCall,
153 Missing,
155 Name(&'a str),
156}
157
158fn check_modern_request(headers: &McpHeaders, raw: &RawRequest) -> Result<(), McpRequestError> {
161 let method = raw
162 .method()
163 .filter(|_| raw.jsonrpc.as_ref().and_then(|v| v.as_str()) == Some(JSONRPC_VERSION))
164 .ok_or_else(|| McpRequestError::InvalidRequest("not a JSON-RPC 2.0 request".into()))?;
165 headers.check(method, raw.tool())?;
166 check_request_meta(raw.params.as_ref())
167}
168
169fn asks_for_2026_07_28<'a>(values: impl IntoIterator<Item = &'a HeaderValue>) -> bool {
173 let modern = ProtocolVersion::V2026_07_28.as_str().as_bytes();
175 values
176 .into_iter()
177 .flat_map(|value| value.as_bytes().split(|byte| *byte == b','))
178 .any(|version| version.trim_ascii() == modern)
179}
180
181fn check_request_meta(params: Option<&serde_json::Value>) -> Result<(), McpRequestError> {
184 let invalid = |message: String| McpRequestError::InvalidParams(message);
185 let meta = match params.and_then(|params| params.get("_meta")) {
186 None => return Err(invalid("missing params._meta".into())),
187 Some(serde_json::Value::Object(meta)) => meta,
188 Some(_) => return Err(invalid("params._meta must be an object".into())),
189 };
190
191 let version_key = "io.modelcontextprotocol/protocolVersion";
192 let body_version = match meta.get(version_key) {
193 None => return Err(invalid(format!("missing {version_key} in params._meta"))),
194 Some(serde_json::Value::String(version)) => version,
195 Some(_) => {
196 return Err(invalid(format!(
197 "{version_key} in params._meta must be a string"
198 )));
199 }
200 };
201 let header_version = ProtocolVersion::V2026_07_28.as_str();
203 if body_version != header_version {
204 return Err(McpRequestError::HeaderMismatch(format!(
205 "MCP-Protocol-Version header value '{header_version}' does not match body value '{body_version}'"
206 )));
207 }
208
209 let capabilities_key = "io.modelcontextprotocol/clientCapabilities";
210 match meta.get(capabilities_key) {
211 None => Err(invalid(format!(
212 "missing {capabilities_key} in params._meta"
213 ))),
214 Some(serde_json::Value::Object(_)) => Ok(()),
215 Some(_) => Err(invalid(format!(
216 "{capabilities_key} in params._meta must be an object"
217 ))),
218 }
219}
220
221struct McpHeaders {
224 protocol_version: Vec<HeaderValue>,
225 method: Vec<HeaderValue>,
226 name: Vec<HeaderValue>,
227}
228
229impl McpHeaders {
230 fn from_map(headers: &HeaderMap) -> Self {
231 let all = |name| headers.get_all(name).iter().cloned().collect();
232 Self {
233 protocol_version: all("mcp-protocol-version"),
234 method: all("mcp-method"),
235 name: all("mcp-name"),
236 }
237 }
238
239 fn check(&self, method: &str, tool: ToolName<'_>) -> Result<(), McpRequestError> {
242 let version = header_text(&self.protocol_version, "MCP-Protocol-Version")?;
243 let modern = ProtocolVersion::V2026_07_28.as_str();
244 if version != modern {
245 return Err(McpRequestError::HeaderMismatch(format!(
246 "MCP-Protocol-Version header value '{version}' is not '{modern}'"
247 )));
248 }
249 let header_method = header_text(&self.method, "Mcp-Method")?;
250 if header_method != method {
251 return Err(McpRequestError::HeaderMismatch(format!(
252 "Mcp-Method header value '{header_method}' does not match body value '{method}'"
253 )));
254 }
255 let tool = match tool {
256 ToolName::NotToolsCall => return Ok(()),
257 ToolName::Missing => None,
258 ToolName::Name(name) => Some(name),
259 };
260 let header_name = decode_mcp_name(header_text(&self.name, "Mcp-Name")?)?;
261 if Some(header_name.as_ref()) != tool {
262 return Err(McpRequestError::HeaderMismatch(format!(
263 "Mcp-Name header value '{header_name}' does not match body value '{}'",
264 tool.unwrap_or_default()
265 )));
266 }
267 Ok(())
268 }
269}
270
271fn header_text<'a>(values: &'a [HeaderValue], name: &str) -> Result<&'a str, McpRequestError> {
272 let value = match values {
273 [value] => value,
274 [] => {
275 return Err(McpRequestError::HeaderMismatch(format!(
276 "missing {name} header"
277 )));
278 }
279 _ => {
280 return Err(McpRequestError::HeaderMismatch(format!(
281 "repeated {name} header"
282 )));
283 }
284 };
285 value.to_str().map_err(|_| {
286 McpRequestError::HeaderMismatch(format!("{name} header has invalid characters"))
287 })
288}
289
290fn decode_mcp_name(value: &str) -> Result<Cow<'_, str>, McpRequestError> {
293 let Some(encoded) = value
294 .strip_prefix("=?base64?")
295 .and_then(|rest| rest.strip_suffix("?="))
296 else {
297 return Ok(Cow::Borrowed(value));
298 };
299 let invalid = || McpRequestError::HeaderMismatch("Mcp-Name header has invalid Base64".into());
300 let bytes = BASE64.decode(encoded).map_err(|_| invalid())?;
301 String::from_utf8(bytes)
302 .map(Cow::Owned)
303 .map_err(|_| invalid())
304}
305
306const DISCOVERY_QUERY: &str = "SELECT * FROM mz_internal.mz_mcp_data_products";
308const DETAILS_QUERY_PREFIX: &str =
309 "SELECT * FROM mz_internal.mz_mcp_data_product_details WHERE object_name = ";
310
311#[derive(Debug, Error)]
313enum McpRequestError {
314 #[error("Invalid JSON-RPC version: expected 2.0")]
315 InvalidJsonRpcVersion,
316 #[error("Method not found: {0}")]
317 MethodNotFound(String),
318 #[error("Tool not found: {0}")]
319 ToolNotFound(String),
320 #[error("Data product not found: {0}")]
321 DataProductNotFound(String),
322 #[error("Query validation failed: {0}")]
323 QueryValidationFailed(String),
324 #[error("Query execution failed: {0}")]
325 QueryExecutionFailed(String),
326 #[error("Internal error: {0}")]
327 Internal(#[from] anyhow::Error),
328 #[error("Header mismatch: {0}")]
329 HeaderMismatch(String),
330 #[error("Invalid params: {0}")]
331 InvalidParams(String),
332 #[error("Invalid request: {0}")]
333 InvalidRequest(String),
334 #[error("Parse error: {0}")]
335 ParseError(String),
336}
337
338impl McpRequestError {
339 fn error_code(&self) -> i32 {
340 match self {
341 Self::InvalidJsonRpcVersion => error_codes::INVALID_REQUEST,
342 Self::MethodNotFound(_) => error_codes::METHOD_NOT_FOUND,
343 Self::ToolNotFound(_) => error_codes::INVALID_PARAMS,
344 Self::DataProductNotFound(_) => error_codes::INVALID_PARAMS,
345 Self::QueryValidationFailed(_) => error_codes::INVALID_PARAMS,
346 Self::QueryExecutionFailed(_) | Self::Internal(_) => error_codes::INTERNAL_ERROR,
347 Self::HeaderMismatch(_) => error_codes::HEADER_MISMATCH,
348 Self::InvalidParams(_) => error_codes::INVALID_PARAMS,
349 Self::InvalidRequest(_) => error_codes::INVALID_REQUEST,
350 Self::ParseError(_) => error_codes::PARSE_ERROR,
351 }
352 }
353
354 fn error_type(&self) -> &'static str {
355 match self {
356 Self::InvalidJsonRpcVersion => "InvalidRequest",
357 Self::MethodNotFound(_) => "MethodNotFound",
358 Self::ToolNotFound(_) => "ToolNotFound",
359 Self::DataProductNotFound(_) => "DataProductNotFound",
360 Self::QueryValidationFailed(_) => "ValidationError",
361 Self::QueryExecutionFailed(_) => "ExecutionError",
362 Self::Internal(_) => "InternalError",
363 Self::HeaderMismatch(_) => "HeaderMismatch",
364 Self::InvalidParams(_) => "InvalidParams",
365 Self::InvalidRequest(_) => "InvalidRequest",
366 Self::ParseError(_) => "ParseError",
367 }
368 }
369}
370
371#[derive(Debug, Deserialize)]
373pub(crate) struct McpRequest {
374 jsonrpc: String,
375 #[serde(default, deserialize_with = "present_id")]
376 id: Option<serde_json::Value>,
377 #[serde(flatten)]
378 method: McpMethod,
379}
380
381fn present_id<'de, D>(deserializer: D) -> Result<Option<serde_json::Value>, D::Error>
384where
385 D: serde::Deserializer<'de>,
386{
387 serde_json::Value::deserialize(deserializer).map(Some)
388}
389
390fn is_valid_request_id(id: &serde_json::Value) -> bool {
393 matches!(
394 id,
395 serde_json::Value::String(_) | serde_json::Value::Number(_)
396 )
397}
398
399fn invalid_id() -> McpRequestError {
400 McpRequestError::InvalidRequest("id must be a string or a number".into())
401}
402
403#[derive(Debug, Deserialize)]
405#[serde(tag = "method", content = "params")]
406enum McpMethod {
407 #[serde(rename = "initialize")]
409 Initialize(#[allow(dead_code)] InitializeParams),
410 #[serde(rename = "tools/list")]
413 ToolsList(#[allow(dead_code)] Option<serde_json::Value>),
414 #[serde(rename = "tools/call")]
415 ToolsCall(#[serde(deserialize_with = "deserialize_tools_call")] ToolsCallParams),
416 #[serde(rename = "ping")]
420 Ping(#[allow(dead_code)] Option<serde_json::Value>),
421 #[serde(rename = "notifications/initialized")]
422 NotificationsInitialized(#[allow(dead_code)] Option<serde_json::Value>),
423 #[serde(rename = "server/discover")]
425 ServerDiscover(#[allow(dead_code)] Option<serde_json::Value>),
426 #[serde(other)]
428 Unknown,
429}
430
431impl std::fmt::Display for McpMethod {
432 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433 match self {
434 McpMethod::Initialize(_) => write!(f, "initialize"),
435 McpMethod::ToolsList(_) => write!(f, "tools/list"),
436 McpMethod::ToolsCall(_) => write!(f, "tools/call"),
437 McpMethod::Ping(_) => write!(f, "ping"),
438 McpMethod::NotificationsInitialized(_) => write!(f, "notifications/initialized"),
439 McpMethod::ServerDiscover(_) => write!(f, "server/discover"),
440 McpMethod::Unknown => write!(f, "unknown"),
441 }
442 }
443}
444
445#[derive(Debug, Deserialize)]
446struct InitializeParams {
447 #[serde(rename = "protocolVersion")]
449 #[allow(dead_code)]
450 protocol_version: String,
451 #[serde(default)]
453 #[allow(dead_code)]
454 capabilities: serde_json::Value,
455 #[serde(rename = "clientInfo")]
457 #[allow(dead_code)]
458 client_info: Option<ClientInfo>,
459}
460
461#[derive(Debug, Deserialize)]
462struct ClientInfo {
463 #[allow(dead_code)]
464 name: String,
465 #[allow(dead_code)]
466 version: String,
467}
468
469#[derive(Debug, Deserialize)]
472#[serde(tag = "name", content = "arguments")]
473#[serde(rename_all = "snake_case")]
474enum ToolsCallParams {
475 GetDataProducts(NoArguments),
477 GetDataProductDetails(GetDataProductDetailsParams),
478 ReadDataProduct(ReadDataProductParams),
479 Query(QueryParams),
480 QuerySystemCatalog(QuerySystemCatalogParams),
482}
483
484#[derive(Debug, Deserialize)]
489#[serde(deny_unknown_fields)]
490struct NoArguments {}
491
492fn deserialize_tools_call<'de, D>(deserializer: D) -> Result<ToolsCallParams, D::Error>
495where
496 D: serde::Deserializer<'de>,
497{
498 let mut params = serde_json::Value::deserialize(deserializer)?;
499 if let serde_json::Value::Object(fields) = &mut params {
500 let arguments = fields.entry("arguments").or_insert(serde_json::Value::Null);
501 if arguments.is_null() {
502 *arguments = json!({});
503 }
504 }
505 serde_json::from_value(params).map_err(serde::de::Error::custom)
506}
507
508impl std::fmt::Display for ToolsCallParams {
509 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510 match self {
511 ToolsCallParams::GetDataProducts(_) => write!(f, "get_data_products"),
512 ToolsCallParams::GetDataProductDetails(_) => write!(f, "get_data_product_details"),
513 ToolsCallParams::ReadDataProduct(_) => write!(f, "read_data_product"),
514 ToolsCallParams::Query(_) => write!(f, "query"),
515 ToolsCallParams::QuerySystemCatalog(_) => write!(f, "query_system_catalog"),
516 }
517 }
518}
519
520#[derive(Debug, Deserialize)]
521struct GetDataProductDetailsParams {
522 name: String,
523}
524
525#[derive(Debug, Deserialize)]
526struct ReadDataProductParams {
527 name: String,
528 #[serde(default = "default_read_limit")]
529 limit: u32,
530 cluster: Option<String>,
531}
532
533const DEFAULT_READ_LIMIT: u32 = 500;
535
536fn default_read_limit() -> u32 {
537 DEFAULT_READ_LIMIT
538}
539
540#[derive(Debug, Deserialize)]
541struct QueryParams {
542 cluster: String,
543 cluster_replica: Option<String>,
546 sql_query: String,
547}
548
549#[derive(Debug, Deserialize)]
550struct QuerySystemCatalogParams {
551 sql_query: String,
552}
553
554#[derive(Debug, Serialize)]
555struct McpResponse {
556 jsonrpc: String,
557 #[serde(skip_serializing_if = "Option::is_none")]
559 id: Option<serde_json::Value>,
560 #[serde(skip_serializing_if = "Option::is_none")]
561 result: Option<McpResultBody>,
562 #[serde(skip_serializing_if = "Option::is_none")]
563 error: Option<McpError>,
564}
565
566impl McpResponse {
567 fn success(id: serde_json::Value, result: McpResultBody) -> Self {
569 Self {
570 jsonrpc: JSONRPC_VERSION.to_string(),
571 id: Some(id),
572 result: Some(result),
573 error: None,
574 }
575 }
576
577 fn error(id: impl Into<Option<serde_json::Value>>, error: McpError) -> Self {
579 Self {
580 jsonrpc: JSONRPC_VERSION.to_string(),
581 id: id.into(),
582 result: None,
583 error: Some(error),
584 }
585 }
586}
587
588#[derive(Debug, Serialize)]
590#[serde(untagged)]
591enum McpResult {
592 Initialize(InitializeResult),
593 Discover(DiscoverResult),
594 ToolsList(ToolsListResult),
595 ToolContent(ToolContentResult),
596}
597
598impl McpResult {
599 fn into_body(self, protocol: ProtocolVersion, endpoint_type: McpEndpointType) -> McpResultBody {
600 let common = protocol.is_modern().then(|| CommonResultFields {
601 result_type: "complete",
602 cache: self.cache_hints(),
603 meta: ResultMeta {
604 server_info: server_info(endpoint_type),
605 },
606 });
607 McpResultBody {
608 result: self,
609 common,
610 }
611 }
612
613 fn cache_hints(&self) -> Option<CacheHints> {
614 match self {
615 McpResult::Discover(_) | McpResult::ToolsList(_) => Some(CacheHints {
618 ttl_ms: CACHE_TTL_MS,
619 cache_scope: "private",
620 }),
621 McpResult::Initialize(_) | McpResult::ToolContent(_) => None,
622 }
623 }
624}
625
626const CACHE_TTL_MS: u64 = 60_000;
629
630#[derive(Debug, Serialize)]
633struct McpResultBody {
634 #[serde(flatten)]
635 result: McpResult,
636 #[serde(flatten)]
637 common: Option<CommonResultFields>,
638}
639
640#[derive(Debug, Serialize)]
641struct CommonResultFields {
642 #[serde(rename = "resultType")]
643 result_type: &'static str,
644 #[serde(flatten)]
645 cache: Option<CacheHints>,
646 #[serde(rename = "_meta")]
647 meta: ResultMeta,
648}
649
650#[derive(Debug, Serialize)]
652struct CacheHints {
653 #[serde(rename = "ttlMs")]
654 ttl_ms: u64,
655 #[serde(rename = "cacheScope")]
656 cache_scope: &'static str,
657}
658
659#[derive(Debug, Serialize)]
660struct ResultMeta {
661 #[serde(rename = "io.modelcontextprotocol/serverInfo")]
662 server_info: ServerInfo,
663}
664
665#[derive(Debug, Serialize)]
666struct InitializeResult {
667 #[serde(rename = "protocolVersion")]
668 protocol_version: String,
669 capabilities: Capabilities,
670 #[serde(rename = "serverInfo")]
671 server_info: ServerInfo,
672 #[serde(skip_serializing_if = "Option::is_none")]
673 instructions: Option<String>,
674}
675
676#[derive(Debug, Serialize)]
677struct DiscoverResult {
678 #[serde(rename = "supportedVersions")]
679 supported_versions: [&'static str; 2],
680 capabilities: Capabilities,
681 #[serde(skip_serializing_if = "Option::is_none")]
682 instructions: Option<String>,
683}
684
685#[derive(Debug, Serialize)]
686struct Capabilities {
687 tools: serde_json::Value,
688}
689
690#[derive(Debug, Serialize)]
691struct ServerInfo {
692 name: String,
693 version: String,
694}
695
696#[derive(Debug, Serialize)]
697struct ToolsListResult {
698 tools: Vec<ToolDefinition>,
699}
700
701#[derive(Debug, Serialize)]
702struct ToolDefinition {
703 name: String,
704 #[serde(skip_serializing_if = "Option::is_none")]
705 title: Option<String>,
706 description: String,
707 #[serde(rename = "inputSchema")]
708 input_schema: serde_json::Value,
709 #[serde(skip_serializing_if = "Option::is_none")]
710 annotations: Option<ToolAnnotations>,
711}
712
713#[derive(Debug, Serialize)]
716struct ToolAnnotations {
717 #[serde(rename = "readOnlyHint", skip_serializing_if = "Option::is_none")]
718 read_only_hint: Option<bool>,
719 #[serde(rename = "destructiveHint", skip_serializing_if = "Option::is_none")]
720 destructive_hint: Option<bool>,
721 #[serde(rename = "idempotentHint", skip_serializing_if = "Option::is_none")]
722 idempotent_hint: Option<bool>,
723 #[serde(rename = "openWorldHint", skip_serializing_if = "Option::is_none")]
724 open_world_hint: Option<bool>,
725}
726
727const READ_ONLY_ANNOTATIONS: ToolAnnotations = ToolAnnotations {
729 read_only_hint: Some(true),
730 destructive_hint: Some(false),
731 idempotent_hint: Some(true),
732 open_world_hint: Some(false),
733};
734
735#[derive(Debug, Serialize)]
736struct ToolContentResult {
737 content: Vec<ContentBlock>,
738 #[serde(rename = "isError")]
740 is_error: bool,
741}
742
743#[derive(Debug, Serialize)]
744struct ContentBlock {
745 #[serde(rename = "type")]
746 content_type: String,
747 text: String,
748}
749
750mod error_codes {
752 pub const PARSE_ERROR: i32 = -32700;
753 pub const INVALID_REQUEST: i32 = -32600;
754 pub const METHOD_NOT_FOUND: i32 = -32601;
755 pub const INVALID_PARAMS: i32 = -32602;
756 pub const INTERNAL_ERROR: i32 = -32603;
757 pub const HEADER_MISMATCH: i32 = -32020;
758}
759
760#[derive(Debug, Serialize)]
761struct McpError {
762 code: i32,
763 message: String,
764 #[serde(skip_serializing_if = "Option::is_none")]
765 data: Option<serde_json::Value>,
766}
767
768impl From<McpRequestError> for McpError {
769 fn from(err: McpRequestError) -> Self {
770 McpError {
771 code: err.error_code(),
772 message: err.to_string(),
773 data: Some(json!({
774 "error_type": err.error_type(),
775 })),
776 }
777 }
778}
779
780#[derive(Debug, Clone, Copy)]
781enum McpEndpointType {
782 Agent,
783 Developer,
784}
785
786impl McpEndpointType {
787 fn as_label(self) -> &'static str {
790 match self {
791 McpEndpointType::Agent => "agent",
792 McpEndpointType::Developer => "developer",
793 }
794 }
795}
796
797impl std::fmt::Display for McpEndpointType {
798 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
799 f.write_str(self.as_label())
800 }
801}
802
803pub async fn handle_mcp_method_not_allowed() -> impl IntoResponse {
806 StatusCode::METHOD_NOT_ALLOWED
807}
808
809pub async fn handle_mcp_agent(
811 headers: HeaderMap,
812 Extension(allowed_origins): Extension<Arc<Vec<HeaderValue>>>,
813 Extension(metrics): Extension<McpMetrics>,
814 client: AuthedClient,
815 body: Bytes,
816) -> axum::response::Response {
817 if let Some(resp) = validate_origin(&headers, &allowed_origins) {
818 return resp;
819 }
820 handle_mcp_request(
821 client,
822 parse_mcp_request(&headers, &body),
823 McpHeaders::from_map(&headers),
824 body,
825 McpEndpointType::Agent,
826 metrics,
827 )
828 .await
829 .into_response()
830}
831
832pub async fn handle_mcp_developer(
834 headers: HeaderMap,
835 Extension(allowed_origins): Extension<Arc<Vec<HeaderValue>>>,
836 Extension(metrics): Extension<McpMetrics>,
837 client: AuthedClient,
838 body: Bytes,
839) -> axum::response::Response {
840 if let Some(resp) = validate_origin(&headers, &allowed_origins) {
841 return resp;
842 }
843 handle_mcp_request(
844 client,
845 parse_mcp_request(&headers, &body),
846 McpHeaders::from_map(&headers),
847 body,
848 McpEndpointType::Developer,
849 metrics,
850 )
851 .await
852 .into_response()
853}
854
855fn parse_mcp_request(headers: &HeaderMap, body: &[u8]) -> Result<McpRequest, JsonRejection> {
861 if !is_json_content_type(headers) {
862 return Err(MissingJsonContentType::default().into());
863 }
864 match Json::<McpRequest>::from_bytes(body) {
865 Ok(Json(request)) => Ok(request),
866 Err(JsonRejection::JsonDataError(rejection)) => {
867 as_unknown_method(body).ok_or_else(|| rejection.into())
868 }
869 Err(rejection) => Err(rejection),
870 }
871}
872
873fn is_json_content_type(headers: &HeaderMap) -> bool {
875 let Some(mime) = headers
876 .get(header::CONTENT_TYPE)
877 .and_then(|value| value.to_str().ok())
878 .and_then(|value| value.parse::<mime::Mime>().ok())
879 else {
880 return false;
881 };
882 mime.type_() == "application"
883 && (mime.subtype() == "json" || mime.suffix().is_some_and(|name| name == "json"))
884}
885
886fn as_unknown_method(body: &[u8]) -> Option<McpRequest> {
892 #[derive(Deserialize)]
893 struct Envelope {
894 jsonrpc: String,
895 #[serde(default, deserialize_with = "present_id")]
896 id: Option<serde_json::Value>,
897 method: String,
898 }
899 let envelope: Envelope = serde_json::from_slice(body).ok()?;
900 let method: McpMethod = serde_json::from_value(json!({ "method": envelope.method })).ok()?;
903 matches!(method, McpMethod::Unknown).then(|| McpRequest {
904 jsonrpc: envelope.jsonrpc,
905 id: envelope.id,
906 method,
907 })
908}
909
910fn validate_origin(
919 headers: &HeaderMap,
920 allowed: &[HeaderValue],
921) -> Option<axum::response::Response> {
922 let origin = headers.get(http::header::ORIGIN)?;
923 if mz_http_util::origin_is_allowed(origin, allowed) {
924 return None;
925 }
926 warn!(
927 origin = ?origin,
928 "MCP request rejected: origin not in allowlist",
929 );
930 Some(StatusCode::FORBIDDEN.into_response())
931}
932
933async fn handle_mcp_request(
934 mut client: AuthedClient,
935 request: Result<McpRequest, JsonRejection>,
936 mcp_headers: McpHeaders,
937 body: Bytes,
938 endpoint_type: McpEndpointType,
939 metrics: McpMetrics,
940) -> impl IntoResponse {
941 let request =
944 match request {
945 Err(
946 rejection @ (JsonRejection::JsonDataError(_) | JsonRejection::JsonSyntaxError(_)),
947 ) if asks_for_2026_07_28(&mcp_headers.protocol_version) => Err(rejection),
948 Err(rejection) => return rejection.into_response(),
949 Ok(request) => Ok(request),
950 };
951 let endpoint_label = endpoint_type.as_label();
952 let method_label = match &request {
953 Ok(request) => request.method.to_string(),
954 Err(_) => "invalid".to_string(),
955 };
956 let record_request =
957 |status: McpCallStatus| metrics.record_request(endpoint_label, &method_label, status);
958
959 let catalog = match tokio::time::timeout(
964 *MCP_REQUEST_TIMEOUT.default(),
965 client.client.catalog_snapshot("mcp"),
966 )
967 .await
968 {
969 Ok(catalog) => catalog,
970 Err(_elapsed) => {
971 if let Err(rejection) = request {
973 return rejection.into_response();
974 }
975 warn!(endpoint = %endpoint_type, "MCP catalog snapshot timed out");
976 record_request(McpCallStatus::Timeout);
977 return StatusCode::SERVICE_UNAVAILABLE.into_response();
978 }
979 };
980 let dyncfgs = catalog.system_config().dyncfgs();
981 let protocol = ProtocolVersion::select(
982 &mcp_headers.protocol_version,
983 ENABLE_MCP_PROTOCOL_2026_07_28.get(dyncfgs),
984 );
985 let request = match request {
988 Err(rejection) if !protocol.is_modern() => return rejection.into_response(),
989 request => request,
990 };
991 let enabled = match endpoint_type {
992 McpEndpointType::Agent => ENABLE_MCP_AGENT.get(dyncfgs),
993 McpEndpointType::Developer => ENABLE_MCP_DEVELOPER.get(dyncfgs),
994 };
995 if !enabled {
996 debug!(endpoint = %endpoint_type, "MCP endpoint disabled by feature flag");
997 record_request(McpCallStatus::EndpointDisabled);
998 return StatusCode::SERVICE_UNAVAILABLE.into_response();
999 }
1000
1001 let query_tool_enabled = match endpoint_type {
1006 McpEndpointType::Agent => ENABLE_MCP_AGENT_QUERY_TOOL.get(dyncfgs),
1007 McpEndpointType::Developer => ENABLE_MCP_DEVELOPER_QUERY_TOOL.get(dyncfgs),
1008 };
1009 let read_data_product_tool_enabled = ENABLE_MCP_AGENT_READ_DATA_PRODUCT_TOOL.get(dyncfgs);
1013 let max_response_size = MCP_MAX_RESPONSE_SIZE.get(dyncfgs);
1014 let request_timeout = MCP_REQUEST_TIMEOUT.get(dyncfgs);
1015
1016 let request = match request {
1017 Ok(request) => request,
1018 Err(rejection) => {
1019 return answer_parse_failure(rejection, &body, &mcp_headers, &record_request);
1020 }
1021 };
1022
1023 let app_name = match endpoint_type {
1027 McpEndpointType::Agent => "mz_mcp_agents",
1028 McpEndpointType::Developer => "mz_mcp_developer",
1029 };
1030 client
1031 .client
1032 .session()
1033 .vars_mut()
1034 .set_default(APPLICATION_NAME.name(), VarInput::Flat(app_name))
1035 .expect("application_name is a known session var");
1036
1037 let user = client.client.session().user().name.clone();
1038 let is_notification = request.id.is_none();
1039
1040 debug!(
1041 method = %request.method,
1042 endpoint = %endpoint_type,
1043 user = %user,
1044 is_notification = is_notification,
1045 ?protocol,
1046 "MCP request received"
1047 );
1048
1049 if is_notification {
1052 if protocol.is_modern() && request.jsonrpc != JSONRPC_VERSION {
1054 let err = McpRequestError::InvalidRequest("not a JSON-RPC 2.0 request".into());
1055 record_request(McpCallStatus::Error(err.error_type()));
1056 let response = McpResponse::error(None, err.into());
1057 return (StatusCode::BAD_REQUEST, Json(response)).into_response();
1058 }
1059 debug!(method = %request.method, "Received notification (no response will be sent)");
1060 record_request(McpCallStatus::Ok);
1061 return StatusCode::ACCEPTED.into_response();
1062 }
1063
1064 if !request.id.as_ref().is_some_and(is_valid_request_id) {
1065 let err = invalid_id();
1066 record_request(McpCallStatus::Error(err.error_type()));
1067 let status = if protocol.is_modern() {
1068 StatusCode::BAD_REQUEST
1069 } else {
1070 StatusCode::OK
1071 };
1072 return (status, Json(McpResponse::error(None, err.into()))).into_response();
1073 }
1074
1075 let request_id = request.id.clone().unwrap_or(serde_json::Value::Null);
1076
1077 if protocol.is_modern() {
1081 let raw = RawRequest::parse(&body).unwrap_or_default();
1082 if let Err(err) = check_modern_request(&mcp_headers, &raw) {
1083 record_request(McpCallStatus::Error(err.error_type()));
1084 let response = McpResponse::error(request_id, err.into());
1085 return (StatusCode::BAD_REQUEST, Json(response)).into_response();
1086 }
1087 }
1088
1089 let metrics_inner = metrics.clone();
1094 let result = tokio::time::timeout(
1095 request_timeout,
1096 mz_ore::task::spawn(|| "mcp_request", async move {
1097 handle_mcp_request_inner(
1098 &mut client,
1099 request,
1100 protocol,
1101 endpoint_type,
1102 query_tool_enabled,
1103 read_data_product_tool_enabled,
1104 max_response_size,
1105 metrics_inner,
1106 )
1107 .await
1108 })
1109 .abort_on_drop(),
1110 )
1111 .await;
1112
1113 let (response, status_label): (McpResponse, McpCallStatus) = match result {
1114 Ok(inner) => inner,
1115 Err(_elapsed) => {
1116 warn!(
1117 endpoint = %endpoint_type,
1118 timeout = ?request_timeout,
1119 "MCP request timed out",
1120 );
1121 let response = McpResponse::error(
1122 request_id,
1123 McpRequestError::QueryExecutionFailed(format!(
1124 "Request timed out after {} seconds.",
1125 request_timeout.as_secs(),
1126 ))
1127 .into(),
1128 );
1129 (response, McpCallStatus::Timeout)
1130 }
1131 };
1132
1133 record_request(status_label);
1134 let status = match &response.error {
1136 Some(error) if protocol.is_modern() && error.code == error_codes::METHOD_NOT_FOUND => {
1137 StatusCode::NOT_FOUND
1138 }
1139 _ => StatusCode::OK,
1140 };
1141 (status, Json(response)).into_response()
1144}
1145
1146fn answer_parse_failure(
1150 rejection: JsonRejection,
1151 body: &[u8],
1152 headers: &McpHeaders,
1153 record_request: &dyn Fn(McpCallStatus),
1154) -> axum::response::Response {
1155 let raw = RawRequest::parse(body).unwrap_or_default();
1156 let (status, err) = match rejection {
1157 JsonRejection::JsonSyntaxError(rejection) => (
1158 StatusCode::BAD_REQUEST,
1159 McpRequestError::ParseError(rejection.body_text()),
1160 ),
1161 JsonRejection::JsonDataError(_)
1162 if raw.id.as_ref().is_some_and(|id| !is_valid_request_id(id)) =>
1163 {
1164 (StatusCode::BAD_REQUEST, invalid_id())
1165 }
1166 JsonRejection::JsonDataError(rejection) => {
1167 let message = rejection.body_text();
1168 match (raw.id.is_some(), check_modern_request(headers, &raw)) {
1169 (_, Err(err @ McpRequestError::InvalidRequest(_))) => {
1170 (StatusCode::BAD_REQUEST, err)
1171 }
1172 (false, _) => (
1175 StatusCode::BAD_REQUEST,
1176 McpRequestError::InvalidParams(message),
1177 ),
1178 (true, Err(err)) => (StatusCode::BAD_REQUEST, err),
1179 (true, Ok(())) if raw.method() == Some("initialize") => (
1180 StatusCode::NOT_FOUND,
1181 McpRequestError::MethodNotFound("unknown method".to_string()),
1182 ),
1183 (true, Ok(())) => (StatusCode::OK, McpRequestError::InvalidParams(message)),
1184 }
1185 }
1186 rejection => return rejection.into_response(),
1187 };
1188 record_request(McpCallStatus::Error(err.error_type()));
1189 let id = raw.id.filter(is_valid_request_id);
1190 (status, Json(McpResponse::error(id, err.into()))).into_response()
1191}
1192
1193async fn handle_mcp_request_inner(
1194 client: &mut AuthedClient,
1195 request: McpRequest,
1196 protocol: ProtocolVersion,
1197 endpoint_type: McpEndpointType,
1198 query_tool_enabled: bool,
1199 read_data_product_tool_enabled: bool,
1200 max_response_size: usize,
1201 metrics: McpMetrics,
1202) -> (McpResponse, McpCallStatus) {
1203 let request_id = request.id.clone().unwrap_or(serde_json::Value::Null);
1205
1206 let result = handle_mcp_method(
1207 client,
1208 &request,
1209 protocol,
1210 endpoint_type,
1211 query_tool_enabled,
1212 read_data_product_tool_enabled,
1213 max_response_size,
1214 &metrics,
1215 )
1216 .await;
1217
1218 let status_label = call_status(&result);
1219
1220 let response = match result {
1221 Ok(result_value) => {
1222 McpResponse::success(request_id, result_value.into_body(protocol, endpoint_type))
1223 }
1224 Err(e) => {
1225 if !matches!(
1227 e,
1228 McpRequestError::MethodNotFound(_) | McpRequestError::InvalidJsonRpcVersion
1229 ) {
1230 warn!(error = %e, method = %request.method, "MCP method execution failed");
1231 }
1232 McpResponse::error(request_id, e.into())
1233 }
1234 };
1235
1236 (response, status_label)
1237}
1238
1239async fn handle_mcp_method(
1240 client: &mut AuthedClient,
1241 request: &McpRequest,
1242 protocol: ProtocolVersion,
1243 endpoint_type: McpEndpointType,
1244 query_tool_enabled: bool,
1245 read_data_product_tool_enabled: bool,
1246 max_response_size: usize,
1247 metrics: &McpMetrics,
1248) -> Result<McpResult, McpRequestError> {
1249 if request.jsonrpc != JSONRPC_VERSION {
1251 return Err(McpRequestError::InvalidJsonRpcVersion);
1252 }
1253
1254 match &request.method {
1256 McpMethod::Initialize(_) if !protocol.is_modern() => {
1258 debug!(endpoint = %endpoint_type, "Processing initialize");
1259 handle_initialize(
1260 endpoint_type,
1261 query_tool_enabled,
1262 read_data_product_tool_enabled,
1263 )
1264 }
1265 McpMethod::ToolsList(_) => {
1266 debug!(endpoint = %endpoint_type, "Processing tools/list");
1267 handle_tools_list(
1268 endpoint_type,
1269 query_tool_enabled,
1270 read_data_product_tool_enabled,
1271 max_response_size,
1272 )
1273 }
1274 McpMethod::ToolsCall(params) => {
1275 debug!(tool = %params, endpoint = %endpoint_type, "Processing tools/call");
1276 handle_tools_call(
1277 client,
1278 params,
1279 endpoint_type,
1280 query_tool_enabled,
1281 read_data_product_tool_enabled,
1282 max_response_size,
1283 metrics,
1284 )
1285 .await
1286 }
1287 McpMethod::ServerDiscover(_) if protocol.is_modern() => {
1290 debug!(endpoint = %endpoint_type, "Processing server/discover");
1291 handle_discover(
1292 endpoint_type,
1293 query_tool_enabled,
1294 read_data_product_tool_enabled,
1295 )
1296 }
1297 McpMethod::Initialize(_)
1298 | McpMethod::ServerDiscover(_)
1299 | McpMethod::Ping(_)
1300 | McpMethod::NotificationsInitialized(_)
1301 | McpMethod::Unknown => Err(McpRequestError::MethodNotFound(
1302 "unknown method".to_string(),
1303 )),
1304 }
1305}
1306
1307fn endpoint_instructions(
1311 endpoint_type: McpEndpointType,
1312 query_tool_enabled: bool,
1313 read_data_product_tool_enabled: bool,
1314) -> Option<String> {
1315 match endpoint_type {
1316 McpEndpointType::Agent => {
1317 let read_paragraph = match (read_data_product_tool_enabled, query_tool_enabled) {
1322 (true, _) => {
1323 "`read_data_product` automatically routes the \
1324 read to the cluster recorded in the data product catalog so indexes are used; \
1325 you only need to set the `cluster` parameter if you intentionally want the \
1326 read to run on a different cluster (e.g. one with larger or more replicas). \
1327 A null `cluster` in discovery means your role lacks USAGE on the object's \
1328 index/compute cluster; `read_data_product` without an override then reads on \
1329 your session's default cluster (safe: only materialized views appear this way, \
1330 and they serve from persist). "
1331 }
1332 (false, true) => {
1333 "Use the `query` tool to read data products, passing the cluster from \
1334 `get_data_product_details` so indexed reads hit the arrangement. If \
1335 `get_data_product_details` returns a null `cluster`, your role lacks USAGE on \
1336 the object's index/compute cluster; run the `query` against any cluster your \
1337 role can use (the read still works, just without the index arrangement). "
1338 }
1339 (false, false) => {
1340 "This server is configured for discovery only: no read tool is exposed. \
1341 Use `get_data_products` and `get_data_product_details` to inspect what \
1342 is available. "
1343 }
1344 };
1345 Some(format!(
1346 "You have access to Materialize data products via MCP. \
1347 Prefer indexed objects (served from memory) over unindexed materialized views \
1348 (read from persistent storage). {read_paragraph}\
1349 `get_data_product_details` returns a `hydration` object with `hydrated`, \
1350 `replica_count`, and `hydrated_replica_count` fields. Reads never return \
1351 partial data: a read against a not-yet-hydrated product blocks until the \
1352 dataflow catches up, and may hit the request timeout. Check `hydrated` \
1353 before reading: if it is false and `replica_count` is greater than 0, the \
1354 dataflow is still warming up, so wait and retry; if `replica_count` is 0 the \
1355 cluster has no replicas and the read cannot make progress until one is added.",
1356 ))
1357 }
1358 McpEndpointType::Developer => {
1359 let query_tool_line = if query_tool_enabled {
1363 "- 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"
1364 } else {
1365 ""
1366 };
1367 let introspection_rule = if query_tool_enabled {
1371 "- 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"
1372 } else {
1373 "- 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"
1374 };
1375 Some(format!(
1376 "You are connected to the Materialize developer MCP server for troubleshooting and observability.\n\n\
1377 Tools:\n\
1378 - 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\
1379 {query_tool_line}\n\
1380 IMPORTANT: Before writing queries, discover table schemas using the mz_ontology tables:\n\
1381 - mz_internal.mz_ontology_entity_types: what catalog entities exist and which tables they map to\n\
1382 - mz_internal.mz_ontology_link_types: relationships between entities (foreign keys, metrics, etc.)\n\
1383 - mz_internal.mz_ontology_properties: column names, types, and descriptions for each entity\n\
1384 - mz_internal.mz_ontology_semantic_types: typed ID domains (CatalogItemId, ReplicaId, etc.)\n\n\
1385 Use these to find the correct tables, join paths, and column names instead of guessing.\n\n\
1386 Key rules:\n\
1387 - mz_source_statuses and mz_sink_statuses use `last_status_change_at` (NOT `updated_at`)\n\
1388 - mz_cluster_replica_utilization only has `replica_id` — JOIN with mz_cluster_replicas and mz_clusters to get names\n\
1389 {introspection_rule}\n\
1390 - 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\
1391 - Use SHOW COLUMNS FROM <table> to verify column names if unsure",
1392 ))
1393 }
1394 }
1395}
1396
1397fn server_info(endpoint_type: McpEndpointType) -> ServerInfo {
1398 ServerInfo {
1399 name: format!("materialize-mcp-{}", endpoint_type),
1400 version: env!("CARGO_PKG_VERSION").to_string(),
1401 }
1402}
1403
1404fn handle_initialize(
1405 endpoint_type: McpEndpointType,
1406 query_tool_enabled: bool,
1407 read_data_product_tool_enabled: bool,
1408) -> Result<McpResult, McpRequestError> {
1409 Ok(McpResult::Initialize(InitializeResult {
1410 protocol_version: MCP_PROTOCOL_VERSION.to_string(),
1411 capabilities: Capabilities { tools: json!({}) },
1412 server_info: server_info(endpoint_type),
1413 instructions: endpoint_instructions(
1414 endpoint_type,
1415 query_tool_enabled,
1416 read_data_product_tool_enabled,
1417 ),
1418 }))
1419}
1420
1421fn handle_discover(
1422 endpoint_type: McpEndpointType,
1423 query_tool_enabled: bool,
1424 read_data_product_tool_enabled: bool,
1425) -> Result<McpResult, McpRequestError> {
1426 Ok(McpResult::Discover(DiscoverResult {
1427 supported_versions: [
1428 ProtocolVersion::V2026_07_28.as_str(),
1429 ProtocolVersion::V2025_11_25.as_str(),
1430 ],
1431 capabilities: Capabilities { tools: json!({}) },
1432 instructions: endpoint_instructions(
1433 endpoint_type,
1434 query_tool_enabled,
1435 read_data_product_tool_enabled,
1436 ),
1437 }))
1438}
1439
1440fn handle_tools_list(
1441 endpoint_type: McpEndpointType,
1442 query_tool_enabled: bool,
1443 read_data_product_tool_enabled: bool,
1444 max_response_size: usize,
1445) -> Result<McpResult, McpRequestError> {
1446 let size_hint = format!(
1447 "Response limit: {:.1} MB.",
1448 f64::cast_lossy(max_response_size) / 1_000_000.0
1449 );
1450
1451 let tools = match endpoint_type {
1452 McpEndpointType::Agent => {
1453 let mut tools = vec![
1454 ToolDefinition {
1455 name: "get_data_products".to_string(),
1456 title: Some("List Data Products".to_string()),
1457 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(),
1458 input_schema: json!({
1459 "type": "object",
1460 "properties": {},
1461 "required": []
1462 }),
1463 annotations: Some(READ_ONLY_ANNOTATIONS),
1464 },
1465 ToolDefinition {
1466 name: "get_data_product_details".to_string(),
1467 title: Some("Get Data Product Details".to_string()),
1468 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(),
1469 input_schema: json!({
1470 "type": "object",
1471 "properties": {
1472 "name": {
1473 "type": "string",
1474 "description": "Exact name of the data product from get_data_products() list"
1475 }
1476 },
1477 "required": ["name"]
1478 }),
1479 annotations: Some(READ_ONLY_ANNOTATIONS),
1480 },
1481 ];
1482 if read_data_product_tool_enabled {
1483 tools.push(ToolDefinition {
1484 name: "read_data_product".to_string(),
1485 title: Some("Read Data Product".to_string()),
1486 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}"),
1487 input_schema: json!({
1488 "type": "object",
1489 "properties": {
1490 "name": {
1491 "type": "string",
1492 "description": "Exact fully-qualified name of the data product (e.g. '\"materialize\".\"schema\".\"view_name\"')"
1493 },
1494 "limit": {
1495 "type": "integer",
1496 "description": format!("Maximum number of rows to return (default {DEFAULT_READ_LIMIT})"),
1497 "default": DEFAULT_READ_LIMIT
1498 },
1499 "cluster": {
1500 "type": "string",
1501 "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."
1502 }
1503 },
1504 "required": ["name"]
1505 }),
1506 annotations: Some(READ_ONLY_ANNOTATIONS),
1507 });
1508 }
1509 if query_tool_enabled {
1510 tools.push(ToolDefinition {
1511 name: "query".to_string(),
1512 title: Some("Query Data Products".to_string()),
1513 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}"),
1514 input_schema: json!({
1515 "type": "object",
1516 "properties": {
1517 "cluster": {
1518 "type": "string",
1519 "description": "Exact cluster name from the data product details - required for query execution"
1520 },
1521 "sql_query": {
1522 "type": "string",
1523 "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."
1524 }
1525 },
1526 "required": ["cluster", "sql_query"]
1527 }),
1528 annotations: Some(READ_ONLY_ANNOTATIONS),
1529 });
1530 }
1531 tools
1532 }
1533 McpEndpointType::Developer => {
1534 let mut tools = vec![ToolDefinition {
1535 name: "query_system_catalog".to_string(),
1536 title: Some("Query System Catalog".to_string()),
1537 description: concat!(
1538 "Query Materialize system catalog tables for troubleshooting and observability. ",
1539 "Only mz_*, pg_catalog, and information_schema tables are accessible. ",
1540 "Use the mz_internal.mz_ontology_* tables to discover tables, columns, and join paths before writing queries.",
1541 ).to_owned() + &format!(" {size_hint}"),
1542 input_schema: json!({
1543 "type": "object",
1544 "properties": {
1545 "sql_query": {
1546 "type": "string",
1547 "description": "PostgreSQL-compatible SELECT, SHOW, or EXPLAIN query referencing mz_*, pg_catalog, or information_schema tables"
1548 }
1549 },
1550 "required": ["sql_query"]
1551 }),
1552 annotations: Some(READ_ONLY_ANNOTATIONS),
1553 }];
1554 if query_tool_enabled {
1555 tools.push(ToolDefinition {
1556 name: "query".to_string(),
1557 title: Some("Query".to_string()),
1558 description: format!(
1559 "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}",
1560 ),
1561 input_schema: json!({
1562 "type": "object",
1563 "properties": {
1564 "cluster": {
1565 "type": "string",
1566 "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."
1567 },
1568 "cluster_replica": {
1569 "type": "string",
1570 "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."
1571 },
1572 "sql_query": {
1573 "type": "string",
1574 "description": "PostgreSQL-compatible SELECT, SHOW, or EXPLAIN statement. Multi-statement queries are rejected."
1575 }
1576 },
1577 "required": ["cluster", "sql_query"]
1578 }),
1579 annotations: Some(READ_ONLY_ANNOTATIONS),
1580 });
1581 }
1582 tools
1583 }
1584 };
1585
1586 Ok(McpResult::ToolsList(ToolsListResult { tools }))
1587}
1588
1589async fn handle_tools_call(
1590 client: &mut AuthedClient,
1591 params: &ToolsCallParams,
1592 endpoint_type: McpEndpointType,
1593 query_tool_enabled: bool,
1594 read_data_product_tool_enabled: bool,
1595 max_response_size: usize,
1596 metrics: &McpMetrics,
1597) -> Result<McpResult, McpRequestError> {
1598 let mut guard = ToolCallGuard::new(metrics, endpoint_type.as_label(), params.to_string());
1600
1601 let result = match (endpoint_type, params) {
1602 (McpEndpointType::Agent, ToolsCallParams::GetDataProducts(_)) => {
1603 get_data_products(client, max_response_size).await
1604 }
1605 (McpEndpointType::Agent, ToolsCallParams::GetDataProductDetails(p)) => {
1606 get_data_product_details(client, &p.name, max_response_size).await
1607 }
1608 (McpEndpointType::Agent, ToolsCallParams::ReadDataProduct(_))
1609 if !read_data_product_tool_enabled =>
1610 {
1611 Err(McpRequestError::ToolNotFound(
1612 "read_data_product tool is not available. Use the query tool to read data products."
1613 .to_string(),
1614 ))
1615 }
1616 (McpEndpointType::Agent, ToolsCallParams::ReadDataProduct(p)) => {
1617 read_data_product(
1618 client,
1619 &p.name,
1620 p.limit,
1621 p.cluster.as_deref(),
1622 max_response_size,
1623 )
1624 .await
1625 }
1626 (McpEndpointType::Agent, ToolsCallParams::Query(_)) if !query_tool_enabled => {
1627 Err(McpRequestError::ToolNotFound(
1628 "query tool is not available. Use get_data_products, get_data_product_details, and read_data_product instead.".to_string(),
1629 ))
1630 }
1631 (McpEndpointType::Agent, ToolsCallParams::Query(p)) => {
1632 execute_query(client, &p.cluster, None, &p.sql_query, max_response_size).await
1635 }
1636 (McpEndpointType::Developer, ToolsCallParams::QuerySystemCatalog(p)) => {
1637 query_system_catalog(client, &p.sql_query, max_response_size).await
1638 }
1639 (McpEndpointType::Developer, ToolsCallParams::Query(_)) if !query_tool_enabled => {
1640 Err(McpRequestError::ToolNotFound(
1641 "query tool is not available. Use query_system_catalog instead.".to_string(),
1642 ))
1643 }
1644 (McpEndpointType::Developer, ToolsCallParams::Query(p)) => {
1645 execute_query(
1646 client,
1647 &p.cluster,
1648 p.cluster_replica.as_deref(),
1649 &p.sql_query,
1650 max_response_size,
1651 )
1652 .await
1653 }
1654 (endpoint, tool) => Err(McpRequestError::ToolNotFound(format!(
1656 "{} is not available on {} endpoint",
1657 tool, endpoint
1658 ))),
1659 };
1660
1661 guard.set_status(call_status(&result));
1662
1663 result
1664}
1665
1666fn call_status<T>(result: &Result<T, McpRequestError>) -> McpCallStatus {
1669 match result {
1670 Ok(_) => McpCallStatus::Ok,
1671 Err(e) => McpCallStatus::Error(e.error_type()),
1672 }
1673}
1674
1675async fn execute_sql(
1677 client: &mut AuthedClient,
1678 query: &str,
1679) -> Result<Vec<Box<serde_json::value::RawValue>>, McpRequestError> {
1680 let mut response = SqlResponse::new();
1681
1682 execute_request(
1683 client,
1684 SqlRequest::Simple {
1685 query: mz_ore::sql::Sql::trusted_external_request(query.to_string()),
1686 },
1687 &mut response,
1688 )
1689 .await
1690 .map_err(|e| McpRequestError::QueryExecutionFailed(e.to_string()))?;
1691
1692 select_single_rows(response.results)
1693}
1694
1695fn select_single_rows(
1701 results: Vec<SqlResult>,
1702) -> Result<Vec<Box<serde_json::value::RawValue>>, McpRequestError> {
1703 let mut rows = None;
1704 for result in results {
1705 match result {
1706 SqlResult::Rows { rows: r, .. } => {
1707 if rows.is_some() {
1708 return Err(McpRequestError::Internal(anyhow!(
1709 "MCP query returned multiple row-producing statements"
1710 )));
1711 }
1712 rows = Some(r);
1713 }
1714 SqlResult::Err { error, .. } => {
1715 return Err(McpRequestError::QueryExecutionFailed(error.message));
1716 }
1717 SqlResult::Ok { .. } => continue,
1718 }
1719 }
1720
1721 rows.ok_or_else(|| {
1722 McpRequestError::QueryExecutionFailed("Query did not return any results".to_string())
1723 })
1724}
1725
1726fn format_rows_response(
1733 rows: Vec<Box<serde_json::value::RawValue>>,
1734 max_size: usize,
1735) -> Result<McpResult, McpRequestError> {
1736 let text =
1739 serde_json::to_string_pretty(&rows).map_err(|e| McpRequestError::Internal(anyhow!(e)))?;
1740
1741 if text.len() > max_size {
1742 return Err(McpRequestError::QueryExecutionFailed(format!(
1743 "Response size ({} bytes) exceeds the {} byte limit. \
1744 Use LIMIT or WHERE to narrow your query.",
1745 text.len(),
1746 max_size,
1747 )));
1748 }
1749
1750 Ok(McpResult::ToolContent(ToolContentResult {
1751 content: vec![ContentBlock {
1752 content_type: "text".to_string(),
1753 text,
1754 }],
1755 is_error: false,
1756 }))
1757}
1758
1759async fn get_data_products(
1760 client: &mut AuthedClient,
1761 max_response_size: usize,
1762) -> Result<McpResult, McpRequestError> {
1763 debug!("Executing get_data_products");
1764 let rows = execute_sql(client, DISCOVERY_QUERY).await?;
1765 debug!("get_data_products returned {} rows", rows.len());
1766
1767 format_rows_response(rows, max_response_size)
1768}
1769
1770async fn get_data_product_details(
1771 client: &mut AuthedClient,
1772 name: &str,
1773 max_response_size: usize,
1774) -> Result<McpResult, McpRequestError> {
1775 debug!(name = %name, "Executing get_data_product_details");
1776
1777 let query = format!("{}{}", DETAILS_QUERY_PREFIX, escaped_string_literal(name));
1778
1779 let rows = execute_sql(client, &query).await?;
1780
1781 if rows.is_empty() {
1782 return Err(McpRequestError::DataProductNotFound(name.to_string()));
1783 }
1784
1785 format_rows_response(rows, max_response_size)
1786}
1787
1788fn safe_data_product_name(name: &str) -> Result<String, McpRequestError> {
1795 let name = name.trim();
1796 if name.is_empty() {
1797 return Err(McpRequestError::QueryValidationFailed(
1798 "Data product name cannot be empty".to_string(),
1799 ));
1800 }
1801
1802 let parsed = parse_item_name_with_limit(name)
1805 .map_err(McpRequestError::QueryValidationFailed)?
1806 .map_err(|_| {
1807 McpRequestError::QueryValidationFailed(format!(
1808 "Invalid data product name: {}. Expected a valid object name, \
1809 e.g. '\"database\".\"schema\".\"name\"' or 'my_view'",
1810 name
1811 ))
1812 })?;
1813
1814 Ok(parsed.to_ast_string_stable())
1817}
1818
1819async fn read_data_product(
1838 client: &mut AuthedClient,
1839 name: &str,
1840 limit: u32,
1841 cluster_override: Option<&str>,
1842 max_response_size: usize,
1843) -> Result<McpResult, McpRequestError> {
1844 debug!(name = %name, limit = limit, cluster_override = ?cluster_override, "Executing read_data_product");
1845
1846 let safe_name = safe_data_product_name(name)?;
1848
1849 let lookup_query = format!(
1855 "SELECT dp.cluster FROM mz_internal.mz_mcp_data_products dp \
1856 WHERE dp.object_name = {} \
1857 ORDER BY dp.cluster NULLS LAST \
1858 LIMIT 1",
1859 escaped_string_literal(name)
1860 );
1861 let lookup_rows = execute_sql(client, &lookup_query).await?;
1862 if lookup_rows.is_empty() {
1863 return Err(McpRequestError::DataProductNotFound(name.to_string()));
1864 }
1865 let catalog_cluster: Option<String> = lookup_rows
1868 .first()
1869 .and_then(|row| serde_json::from_str::<Vec<serde_json::Value>>(row.get()).ok())
1870 .and_then(|row| row.into_iter().next())
1871 .and_then(|v| v.as_str().map(|s| s.to_string()));
1872
1873 let target_cluster: Option<&str> = cluster_override.or(catalog_cluster.as_deref());
1879
1880 let read_query = build_read_query(&safe_name, limit, target_cluster);
1885
1886 let rows = execute_sql(client, &read_query).await?;
1887
1888 format_rows_response(rows, max_response_size)
1889}
1890
1891fn build_read_query(safe_name: &str, limit: u32, target_cluster: Option<&str>) -> String {
1900 let body = format!("SELECT * FROM {safe_name} LIMIT {limit}");
1901 match target_cluster {
1902 Some(cluster) => read_only_txn(
1903 &format!("SET CLUSTER = {}", escaped_string_literal(cluster)),
1904 &body,
1905 ),
1906 None => format!("BEGIN READ ONLY; {body}\n; COMMIT;"),
1907 }
1908}
1909
1910fn read_only_txn(set_clause: &str, body: &str) -> String {
1916 format!("BEGIN READ ONLY; {set_clause}; {body}\n; COMMIT;")
1917}
1918
1919fn validate_readonly_query(sql: &str) -> Result<(), McpRequestError> {
1921 let sql = sql.trim();
1922 if sql.is_empty() {
1923 return Err(McpRequestError::QueryValidationFailed(
1924 "Empty query".to_string(),
1925 ));
1926 }
1927
1928 let stmts = parse_with_limit(sql)
1932 .map_err(McpRequestError::QueryValidationFailed)?
1933 .map_err(|e| {
1934 McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
1935 })?;
1936
1937 if stmts.len() != 1 {
1939 return Err(McpRequestError::QueryValidationFailed(format!(
1940 "Only one query allowed at a time. Found {} statements.",
1941 stmts.len()
1942 )));
1943 }
1944
1945 let stmt = &stmts[0];
1951 use mz_sql_parser::ast::Statement;
1952
1953 match &stmt.ast {
1954 Statement::Select(_)
1955 | Statement::Show(_)
1956 | Statement::ExplainPlan(_)
1957 | Statement::ExplainPushdown(_)
1958 | Statement::ExplainTimestamp(_)
1959 | Statement::ExplainSinkSchema(_)
1960 | Statement::ExplainAnalyzeObject(_)
1961 | Statement::ExplainAnalyzeCluster(_) => Ok(()),
1962 _ => Err(McpRequestError::QueryValidationFailed(
1963 "Only SELECT, SHOW, and EXPLAIN statements are allowed".to_string(),
1964 )),
1965 }
1966}
1967
1968async fn execute_query(
1969 client: &mut AuthedClient,
1970 cluster: &str,
1971 cluster_replica: Option<&str>,
1972 sql_query: &str,
1973 max_response_size: usize,
1974) -> Result<McpResult, McpRequestError> {
1975 debug!(cluster = %cluster, cluster_replica = ?cluster_replica, "Executing user query");
1976
1977 validate_readonly_query(sql_query)?;
1978 validate_cluster_replica(cluster_replica)?;
1979
1980 let combined_query = read_only_txn(&query_set_clause(cluster, cluster_replica), sql_query);
1983
1984 let rows = execute_sql(client, &combined_query).await?;
1985
1986 format_rows_response(rows, max_response_size)
1987}
1988
1989fn query_set_clause(cluster: &str, cluster_replica: Option<&str>) -> String {
1995 let mut set_clause = format!("SET CLUSTER = {}", escaped_string_literal(cluster));
1996 if let Some(replica) = cluster_replica {
1997 set_clause.push_str(&format!(
1998 "; SET CLUSTER_REPLICA = {}",
1999 escaped_string_literal(replica)
2000 ));
2001 }
2002 set_clause
2003}
2004
2005fn validate_cluster_replica(cluster_replica: Option<&str>) -> Result<(), McpRequestError> {
2010 if let Some(replica) = cluster_replica {
2011 if replica.trim().is_empty() {
2012 return Err(McpRequestError::QueryValidationFailed(
2013 "cluster_replica must not be empty or whitespace-only".to_string(),
2014 ));
2015 }
2016 }
2017 Ok(())
2018}
2019
2020async fn query_system_catalog(
2021 client: &mut AuthedClient,
2022 sql_query: &str,
2023 max_response_size: usize,
2024) -> Result<McpResult, McpRequestError> {
2025 debug!("Executing query_system_catalog");
2026
2027 validate_readonly_query(sql_query)?;
2029
2030 validate_system_catalog_query(sql_query)?;
2032
2033 let combined_query = read_only_txn(
2039 "SET search_path = mz_catalog, mz_internal, pg_catalog, information_schema",
2040 sql_query,
2041 );
2042
2043 let rows = execute_sql(client, &combined_query).await?;
2044
2045 format_rows_response(rows, max_response_size)
2046}
2047
2048struct TableReferenceCollector {
2050 tables: Vec<(Option<String>, String)>,
2052 cte_names: std::collections::BTreeSet<String>,
2054 id_references: Vec<String>,
2057}
2058
2059impl TableReferenceCollector {
2060 fn new() -> Self {
2061 Self {
2062 tables: Vec::new(),
2063 cte_names: std::collections::BTreeSet::new(),
2064 id_references: Vec::new(),
2065 }
2066 }
2067
2068 fn record_table(&mut self, name: &RawItemName) {
2069 let parts = &name.name().0;
2070 let Some(last) = parts.last() else {
2071 return;
2072 };
2073 let table_name = last.as_str().to_lowercase();
2074
2075 if parts.len() == 1 && self.cte_names.contains(&table_name) {
2078 return;
2079 }
2080
2081 let schema = (parts.len() >= 2).then(|| parts[parts.len() - 2].as_str().to_lowercase());
2083 self.tables.push((schema, table_name));
2084 }
2085}
2086
2087impl<'ast> Visit<'ast, Raw> for TableReferenceCollector {
2088 fn visit_cte_block(&mut self, block: &'ast mz_sql_parser::ast::CteBlock<Raw>) {
2089 self.cte_names.extend(
2092 block
2093 .bound_identifiers()
2094 .map(|ident| ident.as_str().to_lowercase()),
2095 );
2096 visit::visit_cte_block(self, block);
2097 }
2098
2099 fn visit_table_factor(&mut self, table_factor: &'ast mz_sql_parser::ast::TableFactor<Raw>) {
2100 if let mz_sql_parser::ast::TableFactor::Table { name, .. } = table_factor {
2102 self.record_table(name);
2103 }
2104 visit::visit_table_factor(self, table_factor);
2105 }
2106
2107 fn visit_set_expr(&mut self, set_expr: &'ast mz_sql_parser::ast::SetExpr<Raw>) {
2108 if let mz_sql_parser::ast::SetExpr::Table(name) = set_expr {
2110 self.record_table(name);
2111 }
2112 visit::visit_set_expr(self, set_expr);
2113 }
2114
2115 fn visit_item_name(&mut self, name: &'ast RawItemName) {
2116 if let RawItemName::Id(..) = name {
2117 self.id_references.push(name.to_ast_string_simple());
2118 }
2119 }
2120}
2121
2122fn validate_system_catalog_query(sql: &str) -> Result<(), McpRequestError> {
2130 let stmts = parse_with_limit(sql)
2133 .map_err(McpRequestError::QueryValidationFailed)?
2134 .map_err(|e| {
2135 McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
2136 })?;
2137
2138 if stmts.is_empty() {
2139 return Err(McpRequestError::QueryValidationFailed(
2140 "Empty query".to_string(),
2141 ));
2142 }
2143
2144 let mut collector = TableReferenceCollector::new();
2146 for stmt in &stmts {
2147 collector.visit_statement(&stmt.ast);
2148 }
2149
2150 if !collector.id_references.is_empty() {
2151 return Err(McpRequestError::QueryValidationFailed(format!(
2152 "Query references objects by ID: {}. Reference system catalog tables by name.",
2153 collector.id_references.join(", ")
2154 )));
2155 }
2156
2157 let is_allowed_schema =
2160 |s: &str| SYSTEM_SCHEMAS.contains(&s) && s != namespaces::MZ_UNSAFE_SCHEMA;
2161
2162 let is_system_table = |(schema, table_name): &(Option<String>, String)| match schema {
2167 Some(s) => is_allowed_schema(s.as_str()),
2168 None => table_name.starts_with("mz_") || table_name.starts_with("pg_"),
2169 };
2170
2171 let non_system_tables: Vec<String> = collector
2173 .tables
2174 .iter()
2175 .filter(|t| !is_system_table(t))
2176 .map(|(schema, table)| match schema {
2177 Some(s) => format!("{}.{}", s, table),
2178 None => table.clone(),
2179 })
2180 .collect();
2181
2182 if !non_system_tables.is_empty() {
2183 return Err(McpRequestError::QueryValidationFailed(format!(
2184 "Query references non-system tables: {}. Only system catalog tables (mz_*, pg_catalog, information_schema) are allowed.",
2185 non_system_tables.join(", ")
2186 )));
2187 }
2188
2189 use mz_sql_parser::ast::Statement;
2192 let is_select = stmts.iter().any(|s| matches!(&s.ast, Statement::Select(_)));
2193
2194 if is_select && (collector.tables.is_empty() || !collector.tables.iter().any(is_system_table)) {
2195 return Err(McpRequestError::QueryValidationFailed(
2196 "Query must reference at least one system catalog table".to_string(),
2197 ));
2198 }
2199
2200 Ok(())
2201}
2202
2203#[cfg(test)]
2204mod tests {
2205 use super::*;
2206 use crate::http::sql::{Description, SqlError};
2207
2208 fn raw_rows(rows: Vec<Vec<serde_json::Value>>) -> Vec<Box<serde_json::value::RawValue>> {
2211 rows.iter()
2212 .map(|r| serde_json::value::to_raw_value(r).unwrap())
2213 .collect()
2214 }
2215
2216 fn rows_result(rows: Vec<Vec<serde_json::Value>>) -> SqlResult {
2217 SqlResult::Rows {
2218 tag: String::new(),
2219 rows: raw_rows(rows),
2220 desc: Description { columns: vec![] },
2221 notices: vec![],
2222 }
2223 }
2224
2225 fn ok_result() -> SqlResult {
2226 SqlResult::Ok {
2227 ok: String::new(),
2228 notices: vec![],
2229 parameters: vec![],
2230 execution_time: None,
2231 }
2232 }
2233
2234 fn err_result(message: &str) -> SqlResult {
2235 SqlResult::Err {
2236 error: SqlError {
2237 message: message.to_string(),
2238 code: String::new(),
2239 detail: None,
2240 hint: None,
2241 position: None,
2242 },
2243 notices: vec![],
2244 }
2245 }
2246
2247 #[mz_ore::test]
2250 fn test_select_single_rows_extracts_rows() {
2251 let rows = vec![vec![serde_json::json!(1)]];
2252 let results = vec![
2253 ok_result(),
2254 ok_result(),
2255 rows_result(rows.clone()),
2256 ok_result(),
2257 ];
2258 let got = select_single_rows(results).unwrap();
2259 let got: Vec<&str> = got.iter().map(|r| r.get()).collect();
2260 assert_eq!(got, vec!["[1]"]);
2261 }
2262
2263 #[mz_ore::test]
2265 fn test_select_single_rows_requires_rows() {
2266 let err = select_single_rows(vec![ok_result(), ok_result()]).unwrap_err();
2267 assert!(
2268 matches!(err, McpRequestError::QueryExecutionFailed(_)),
2269 "{err:?}"
2270 );
2271 }
2272
2273 #[mz_ore::test]
2276 fn test_select_single_rows_rejects_multiple() {
2277 let results = vec![rows_result(vec![]), rows_result(vec![])];
2278 let err = select_single_rows(results).unwrap_err();
2279 assert!(matches!(err, McpRequestError::Internal(_)), "{err:?}");
2280 }
2281
2282 #[mz_ore::test]
2284 fn test_select_single_rows_surfaces_error() {
2285 let err = select_single_rows(vec![ok_result(), err_result("boom")]).unwrap_err();
2286 match err {
2287 McpRequestError::QueryExecutionFailed(msg) => assert_eq!(msg, "boom"),
2288 other => panic!("unexpected error: {other:?}"),
2289 }
2290 }
2291
2292 #[mz_ore::test]
2295 fn test_validate_origin() {
2296 let allowed = [HeaderValue::from_static("https://good.example")];
2297
2298 assert!(validate_origin(&HeaderMap::new(), &allowed).is_none());
2299
2300 let mut ok = HeaderMap::new();
2301 ok.insert(http::header::ORIGIN, allowed[0].clone());
2302 assert!(validate_origin(&ok, &allowed).is_none());
2303
2304 let mut bad = HeaderMap::new();
2305 bad.insert(
2306 http::header::ORIGIN,
2307 HeaderValue::from_static("https://evil.example"),
2308 );
2309 let rejected = validate_origin(&bad, &allowed);
2310 assert_eq!(
2311 rejected
2312 .expect("disallowed origin must be rejected")
2313 .status(),
2314 StatusCode::FORBIDDEN,
2315 );
2316 }
2317
2318 #[mz_ore::test]
2321 fn test_mcp_response_constructors() {
2322 let id = serde_json::json!(1);
2323
2324 let ok = McpResponse::success(
2325 id.clone(),
2326 McpResult::ToolContent(ToolContentResult {
2327 content: vec![],
2328 is_error: false,
2329 })
2330 .into_body(ProtocolVersion::V2025_11_25, McpEndpointType::Developer),
2331 );
2332 assert_eq!(ok.jsonrpc, JSONRPC_VERSION);
2333 assert!(ok.result.is_some());
2334 assert!(ok.error.is_none());
2335
2336 let err = McpResponse::error(id, McpRequestError::ToolNotFound("t".to_string()).into());
2337 assert_eq!(err.jsonrpc, JSONRPC_VERSION);
2338 assert!(err.result.is_none());
2339 assert!(err.error.is_some());
2340 }
2341
2342 #[mz_ore::test]
2343 fn test_validate_readonly_query_select() {
2344 assert!(validate_readonly_query("SELECT * FROM mz_tables").is_ok());
2345 assert!(validate_readonly_query("SELECT 1 + 2").is_ok());
2346 assert!(validate_readonly_query(" SELECT 1 ").is_ok());
2347 }
2348
2349 #[mz_ore::test]
2350 fn test_validate_readonly_query_subqueries() {
2351 assert!(
2353 validate_readonly_query(
2354 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
2355 )
2356 .is_ok()
2357 );
2358
2359 assert!(
2361 validate_readonly_query(
2362 "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
2363 )
2364 .is_ok()
2365 );
2366
2367 assert!(validate_readonly_query(
2369 "SELECT * FROM mz_tables t WHERE EXISTS (SELECT 1 FROM mz_columns c WHERE c.id = t.id)"
2370 )
2371 .is_ok());
2372
2373 assert!(validate_readonly_query(
2375 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns WHERE type IN (SELECT name FROM mz_types))"
2376 )
2377 .is_ok());
2378
2379 assert!(
2381 validate_readonly_query(
2382 "SELECT * FROM mz_tables WHERE id = (SELECT MAX(id) FROM mz_columns)"
2383 )
2384 .is_ok()
2385 );
2386 }
2387
2388 #[mz_ore::test]
2389 fn test_validate_readonly_query_show() {
2390 assert!(validate_readonly_query("SHOW CLUSTERS").is_ok());
2391 assert!(validate_readonly_query("SHOW TABLES").is_ok());
2392 }
2393
2394 #[mz_ore::test]
2395 fn test_validate_readonly_query_explain() {
2396 assert!(validate_readonly_query("EXPLAIN SELECT 1").is_ok());
2401 assert!(
2402 validate_readonly_query("EXPLAIN FILTER PUSHDOWN FOR SELECT * FROM mz_tables").is_ok()
2403 );
2404 assert!(validate_readonly_query("EXPLAIN TIMESTAMP FOR SELECT 1").is_ok());
2405 assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR INDEX foo").is_ok());
2406 assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR MATERIALIZED VIEW mv").is_ok());
2407 assert!(validate_readonly_query("EXPLAIN ANALYZE CLUSTER MEMORY").is_ok());
2408 }
2409
2410 #[mz_ore::test]
2411 fn test_validate_readonly_query_rejects_writes() {
2412 assert!(validate_readonly_query("INSERT INTO t VALUES (1)").is_err());
2413 assert!(validate_readonly_query("UPDATE t SET a = 1").is_err());
2414 assert!(validate_readonly_query("DELETE FROM t").is_err());
2415 assert!(validate_readonly_query("CREATE TABLE t (a INT)").is_err());
2416 assert!(validate_readonly_query("DROP TABLE t").is_err());
2417 }
2418
2419 #[mz_ore::test]
2420 fn test_validate_readonly_query_rejects_multiple() {
2421 assert!(validate_readonly_query("SELECT 1; SELECT 2").is_err());
2422 }
2423
2424 #[mz_ore::test]
2425 fn test_validate_readonly_query_rejects_empty() {
2426 assert!(validate_readonly_query("").is_err());
2427 assert!(validate_readonly_query(" ").is_err());
2428 }
2429
2430 #[mz_ore::test]
2437 fn test_validate_readonly_query_enforces_size_limit() {
2438 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
2439 let oversized: String =
2441 "SELECT 1;".repeat((MAX_STATEMENT_BATCH_SIZE / "SELECT 1;".len()) + 1);
2442 let err = validate_readonly_query(&oversized).expect_err("should be rejected");
2443 let msg = err.to_string();
2444 assert!(
2445 msg.contains("statement batch size cannot exceed"),
2446 "expected size-guard error, got: {msg}"
2447 );
2448 }
2449
2450 #[mz_ore::test]
2451 fn test_validate_system_catalog_query_enforces_size_limit() {
2452 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
2453 let stmt = "SELECT * FROM mz_tables;";
2454 let oversized: String = stmt.repeat((MAX_STATEMENT_BATCH_SIZE / stmt.len()) + 1);
2455 let err = validate_system_catalog_query(&oversized).expect_err("should be rejected");
2456 let msg = err.to_string();
2457 assert!(
2458 msg.contains("statement batch size cannot exceed"),
2459 "expected size-guard error, got: {msg}"
2460 );
2461 }
2462
2463 #[mz_ore::test]
2464 fn test_validate_system_catalog_query_accepts_mz_tables() {
2465 assert!(validate_system_catalog_query("SELECT * FROM mz_tables").is_ok());
2466 assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_comments").is_ok());
2467 assert!(
2468 validate_system_catalog_query(
2469 "SELECT * FROM mz_tables t JOIN mz_columns c ON t.id = c.id"
2470 )
2471 .is_ok()
2472 );
2473 }
2474
2475 #[mz_ore::test]
2476 fn test_validate_system_catalog_query_subqueries() {
2477 assert!(
2479 validate_system_catalog_query(
2480 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
2481 )
2482 .is_ok()
2483 );
2484
2485 assert!(validate_system_catalog_query(
2487 "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM mz_columns WHERE type IN (SELECT id FROM mz_types))"
2488 )
2489 .is_ok());
2490
2491 assert!(
2493 validate_system_catalog_query(
2494 "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
2495 )
2496 .is_ok()
2497 );
2498
2499 assert!(
2501 validate_system_catalog_query(
2502 "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM user_data)"
2503 )
2504 .is_err()
2505 );
2506
2507 assert!(validate_system_catalog_query(
2509 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM (SELECT id FROM user_table) AS t)"
2510 )
2511 .is_err());
2512 }
2513
2514 #[mz_ore::test]
2515 fn test_validate_system_catalog_query_rejects_user_tables() {
2516 assert!(validate_system_catalog_query("SELECT * FROM user_data").is_err());
2517 assert!(validate_system_catalog_query("SELECT * FROM my_table").is_err());
2518 assert!(
2520 validate_system_catalog_query("SELECT * FROM user_data WHERE 'mz_' IS NOT NULL")
2521 .is_err()
2522 );
2523 }
2524
2525 #[mz_ore::test]
2526 fn test_validate_system_catalog_query_allows_functions() {
2527 assert!(
2529 validate_system_catalog_query(
2530 "SELECT date_part('year', now())::int4 AS y FROM mz_tables LIMIT 1"
2531 )
2532 .is_ok()
2533 );
2534 assert!(validate_system_catalog_query("SELECT length(name) FROM mz_tables").is_ok());
2535 assert!(
2536 validate_system_catalog_query(
2537 "SELECT count(*) FROM mz_sources WHERE now() > created_at"
2538 )
2539 .is_ok()
2540 );
2541 }
2542
2543 #[mz_ore::test]
2544 fn test_validate_system_catalog_query_unqualified_pg_catalog() {
2545 assert!(validate_system_catalog_query("SELECT * FROM pg_class").is_ok());
2548 assert!(validate_system_catalog_query("SELECT nspname FROM pg_namespace").is_ok());
2549 }
2550
2551 #[mz_ore::test]
2552 fn test_validate_system_catalog_query_schema_qualified() {
2553 assert!(validate_system_catalog_query("SELECT * FROM mz_catalog.mz_tables").is_ok());
2555 assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_sessions").is_ok());
2556 assert!(validate_system_catalog_query("SELECT * FROM pg_catalog.pg_type").is_ok());
2557 assert!(validate_system_catalog_query("SELECT * FROM information_schema.tables").is_ok());
2558
2559 assert!(validate_system_catalog_query("SELECT * FROM public.user_table").is_err());
2561 assert!(validate_system_catalog_query("SELECT * FROM myschema.mytable").is_err());
2562
2563 assert!(
2565 validate_system_catalog_query("SELECT * FROM mz_unsafe.mz_some_table").is_err(),
2566 "mz_unsafe schema should be blocked even though it is a system schema"
2567 );
2568
2569 assert!(
2571 validate_system_catalog_query(
2572 "SELECT * FROM mz_catalog.mz_tables JOIN public.user_data ON true"
2573 )
2574 .is_err()
2575 );
2576 }
2577
2578 #[mz_ore::test]
2579 fn test_validate_system_catalog_query_rejects_hidden_user_references() {
2580 for sql in [
2581 "SELECT * FROM mz_catalog.mz_tables UNION ALL TABLE public.user_data",
2582 "WITH user_data AS (SELECT 1) \
2583 SELECT * FROM public.user_data, mz_catalog.mz_tables",
2584 "SELECT * FROM [u1 AS mz_catalog.mz_tables]",
2585 ] {
2586 assert!(
2587 validate_system_catalog_query(sql).is_err(),
2588 "should reject {sql}"
2589 );
2590 }
2591 }
2592
2593 #[mz_ore::test]
2594 fn test_validate_system_catalog_query_accepts_table_and_mutual_recursion() {
2595 for sql in [
2596 "TABLE mz_catalog.mz_tables",
2597 "WITH MUTUALLY RECURSIVE \
2598 t (id text) AS (TABLE u), \
2599 u (id text) AS (SELECT id FROM mz_catalog.mz_tables) \
2600 SELECT * FROM t",
2601 ] {
2602 assert!(
2603 validate_system_catalog_query(sql).is_ok(),
2604 "should accept {sql}"
2605 );
2606 }
2607 }
2608
2609 #[mz_ore::test]
2610 fn test_validate_system_catalog_query_adversarial_cases() {
2611 assert!(
2613 validate_system_catalog_query(
2614 "WITH user_cte AS (SELECT * FROM user_data) \
2615 SELECT * FROM mz_tables, user_cte"
2616 )
2617 .is_err(),
2618 "Should reject CTE referencing user table"
2619 );
2620
2621 assert!(
2623 validate_system_catalog_query(
2624 "WITH \
2625 cte1 AS (SELECT * FROM mz_tables), \
2626 cte2 AS (SELECT * FROM cte1), \
2627 cte3 AS (SELECT * FROM user_data) \
2628 SELECT * FROM cte2"
2629 )
2630 .is_err(),
2631 "Should reject CTE chain with user table"
2632 );
2633
2634 assert!(
2636 validate_system_catalog_query(
2637 "SELECT * FROM mz_tables t1 \
2638 JOIN user_data u ON t1.id = u.id \
2639 JOIN mz_sources s ON t1.id = s.id"
2640 )
2641 .is_err(),
2642 "Should reject multi-join with user table"
2643 );
2644
2645 assert!(
2647 validate_system_catalog_query(
2648 "SELECT * FROM mz_tables t \
2649 LEFT JOIN user_data u ON t.id = u.table_id \
2650 WHERE u.id IS NULL"
2651 )
2652 .is_err(),
2653 "Should reject LEFT JOIN with user table"
2654 );
2655
2656 assert!(
2658 validate_system_catalog_query(
2659 "SELECT * FROM mz_tables WHERE id IN \
2660 (SELECT table_id FROM (SELECT * FROM user_data) AS u)"
2661 )
2662 .is_err(),
2663 "Should reject nested subquery with user table"
2664 );
2665
2666 assert!(
2668 validate_system_catalog_query(
2669 "SELECT name FROM mz_tables \
2670 UNION \
2671 SELECT name FROM user_data"
2672 )
2673 .is_err(),
2674 "Should reject UNION with user table"
2675 );
2676
2677 assert!(
2679 validate_system_catalog_query(
2680 "SELECT id FROM mz_sources \
2681 UNION ALL \
2682 SELECT id FROM products"
2683 )
2684 .is_err(),
2685 "Should reject UNION ALL with user table"
2686 );
2687
2688 assert!(
2690 validate_system_catalog_query("SELECT * FROM mz_tables CROSS JOIN user_data").is_err(),
2691 "Should reject CROSS JOIN with user table"
2692 );
2693
2694 assert!(
2696 validate_system_catalog_query(
2697 "SELECT t.*, (SELECT COUNT(*) FROM user_data) AS cnt FROM mz_tables t"
2698 )
2699 .is_err(),
2700 "Should reject subquery in SELECT with user table"
2701 );
2702
2703 assert!(
2705 validate_system_catalog_query("SELECT * FROM mz_catalogg.fake_table").is_err(),
2706 "Should reject typo-squatting schema name"
2707 );
2708 assert!(
2709 validate_system_catalog_query("SELECT * FROM mz_catalog_hack.fake_table").is_err(),
2710 "Should reject fake schema with mz_catalog prefix"
2711 );
2712
2713 assert!(
2715 validate_system_catalog_query(
2716 "SELECT * FROM mz_tables t, LATERAL (SELECT * FROM user_data WHERE id = t.id) u"
2717 )
2718 .is_err(),
2719 "Should reject LATERAL join with user table"
2720 );
2721
2722 assert!(
2724 validate_system_catalog_query(
2725 "WITH \
2726 tables AS (SELECT * FROM mz_tables), \
2727 sources AS (SELECT * FROM mz_sources) \
2728 SELECT t.name, s.name \
2729 FROM tables t \
2730 JOIN sources s ON t.id = s.id \
2731 WHERE t.id IN (SELECT id FROM mz_columns)"
2732 )
2733 .is_ok(),
2734 "Should allow complex query with only system tables"
2735 );
2736
2737 assert!(
2739 validate_system_catalog_query(
2740 "SELECT name FROM mz_tables \
2741 UNION \
2742 SELECT name FROM mz_sources"
2743 )
2744 .is_ok(),
2745 "Should allow UNION of system tables"
2746 );
2747 }
2748
2749 #[mz_ore::test]
2750 fn test_validate_system_catalog_query_rejects_constant_queries() {
2751 assert!(
2754 validate_system_catalog_query("SELECT 1").is_err(),
2755 "Should reject constant SELECT with no table references"
2756 );
2757 assert!(
2758 validate_system_catalog_query("SELECT 1 + 2, 'hello'").is_err(),
2759 "Should reject constant expression SELECT"
2760 );
2761 assert!(
2762 validate_system_catalog_query("SELECT now()").is_err(),
2763 "Should reject function-only SELECT with no table references"
2764 );
2765 }
2766
2767 #[mz_ore::test]
2768 fn test_validate_system_catalog_query_rejects_mixed_tables() {
2769 assert!(
2770 validate_system_catalog_query(
2771 "SELECT * FROM mz_tables t JOIN user_data u ON t.id = u.table_id"
2772 )
2773 .is_err()
2774 );
2775 }
2776
2777 #[mz_ore::test]
2778 fn test_validate_system_catalog_query_allows_show() {
2779 assert!(
2781 validate_system_catalog_query("SHOW TABLES FROM mz_internal").is_ok(),
2782 "SHOW TABLES FROM mz_internal should be allowed"
2783 );
2784 assert!(
2785 validate_system_catalog_query("SHOW TABLES FROM mz_catalog").is_ok(),
2786 "SHOW TABLES FROM mz_catalog should be allowed"
2787 );
2788 assert!(
2789 validate_system_catalog_query("SHOW CLUSTERS").is_ok(),
2790 "SHOW CLUSTERS should be allowed"
2791 );
2792 assert!(
2793 validate_system_catalog_query("SHOW SOURCES").is_ok(),
2794 "SHOW SOURCES should be allowed"
2795 );
2796 assert!(
2797 validate_system_catalog_query("SHOW TABLES").is_ok(),
2798 "SHOW TABLES should be allowed"
2799 );
2800 }
2801
2802 #[mz_ore::test]
2803 fn test_validate_system_catalog_query_allows_explain() {
2804 assert!(
2805 validate_system_catalog_query("EXPLAIN SELECT * FROM mz_tables").is_ok(),
2806 "EXPLAIN of system table query should be allowed"
2807 );
2808 assert!(
2809 validate_system_catalog_query("EXPLAIN SELECT 1").is_ok(),
2810 "EXPLAIN SELECT 1 should be allowed"
2811 );
2812 }
2813
2814 #[mz_ore::test(tokio::test)]
2817 async fn test_tools_list_agent_query_tool_disabled() {
2818 let result = handle_tools_list(McpEndpointType::Agent, false, true, 1_000_000).unwrap();
2819 let McpResult::ToolsList(list) = result else {
2820 panic!("Expected ToolsList result");
2821 };
2822 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2823 assert!(
2824 tool_names.contains(&"get_data_products"),
2825 "get_data_products should always be present"
2826 );
2827 assert!(
2828 tool_names.contains(&"get_data_product_details"),
2829 "get_data_product_details should always be present"
2830 );
2831 assert!(
2832 tool_names.contains(&"read_data_product"),
2833 "read_data_product should be present when its flag is on"
2834 );
2835 assert!(
2836 !tool_names.contains(&"query"),
2837 "query tool should be hidden when disabled"
2838 );
2839 }
2840
2841 #[mz_ore::test(tokio::test)]
2842 async fn test_tools_list_agent_query_tool_enabled() {
2843 let result = handle_tools_list(McpEndpointType::Agent, true, true, 1_000_000).unwrap();
2844 let McpResult::ToolsList(list) = result else {
2845 panic!("Expected ToolsList result");
2846 };
2847 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2848 assert!(
2849 tool_names.contains(&"get_data_products"),
2850 "get_data_products should always be present"
2851 );
2852 assert!(
2853 tool_names.contains(&"get_data_product_details"),
2854 "get_data_product_details should always be present"
2855 );
2856 assert!(
2857 tool_names.contains(&"read_data_product"),
2858 "read_data_product should be present when its flag is on"
2859 );
2860 assert!(
2861 tool_names.contains(&"query"),
2862 "query tool should be present when enabled"
2863 );
2864 }
2865
2866 #[mz_ore::test(tokio::test)]
2867 async fn test_tools_list_agent_read_data_product_tool_disabled() {
2868 let result = handle_tools_list(McpEndpointType::Agent, true, false, 1_000_000).unwrap();
2869 let McpResult::ToolsList(list) = result else {
2870 panic!("Expected ToolsList result");
2871 };
2872 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2873 assert!(
2874 tool_names.contains(&"get_data_products"),
2875 "get_data_products should always be present"
2876 );
2877 assert!(
2878 tool_names.contains(&"get_data_product_details"),
2879 "get_data_product_details should always be present"
2880 );
2881 assert!(
2882 !tool_names.contains(&"read_data_product"),
2883 "read_data_product should be hidden when disabled"
2884 );
2885 assert!(
2886 tool_names.contains(&"query"),
2887 "query tool should remain present when enabled"
2888 );
2889 }
2890
2891 #[mz_ore::test(tokio::test)]
2896 async fn test_tools_list_agent_both_read_tools_disabled() {
2897 let result = handle_tools_list(McpEndpointType::Agent, false, false, 1_000_000).unwrap();
2898 let McpResult::ToolsList(list) = result else {
2899 panic!("Expected ToolsList result");
2900 };
2901 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2902 assert_eq!(
2903 tool_names
2904 .iter()
2905 .copied()
2906 .collect::<std::collections::BTreeSet<_>>(),
2907 ["get_data_product_details", "get_data_products"]
2908 .into_iter()
2909 .collect(),
2910 "only discovery tools should be advertised when both read flags are off",
2911 );
2912
2913 let instructions = endpoint_instructions(McpEndpointType::Agent, false, false)
2914 .expect("agent instructions must be present");
2915 assert!(
2916 !instructions.contains("Use the `query` tool"),
2917 "instructions must not point at query when it is hidden: {instructions}",
2918 );
2919 assert!(
2920 !instructions.contains("`read_data_product` automatically"),
2921 "instructions must not point at read_data_product when it is hidden: {instructions}",
2922 );
2923 assert!(
2924 instructions.contains("discovery only"),
2925 "instructions must tell the agent it is discovery-only: {instructions}",
2926 );
2927 }
2928
2929 #[mz_ore::test(tokio::test)]
2930 async fn test_tools_list_developer_query_tool_disabled() {
2931 let result = handle_tools_list(McpEndpointType::Developer, false, true, 1_000_000).unwrap();
2934 let McpResult::ToolsList(list) = result else {
2935 panic!("Expected ToolsList result");
2936 };
2937 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2938 assert!(
2939 tool_names.contains(&"query_system_catalog"),
2940 "query_system_catalog should always be present on developer"
2941 );
2942 assert!(
2943 !tool_names.contains(&"query"),
2944 "query tool should be hidden when disabled"
2945 );
2946 }
2947
2948 #[mz_ore::test(tokio::test)]
2949 async fn test_tools_list_developer_query_tool_enabled() {
2950 let result = handle_tools_list(McpEndpointType::Developer, true, true, 1_000_000).unwrap();
2951 let McpResult::ToolsList(list) = result else {
2952 panic!("Expected ToolsList result");
2953 };
2954 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2955 assert!(
2956 tool_names.contains(&"query_system_catalog"),
2957 "query_system_catalog should always be present on developer"
2958 );
2959 assert!(
2960 tool_names.contains(&"query"),
2961 "query tool should be present on developer when enabled"
2962 );
2963
2964 let instructions = endpoint_instructions(McpEndpointType::Developer, true, true)
2965 .expect("developer instructions must be present");
2966 assert!(
2967 instructions.contains("through the query tool with its cluster argument"),
2968 "instructions must route mz_introspection through query: {instructions}",
2969 );
2970 }
2971
2972 #[mz_ore::test]
2973 fn test_developer_instructions_query_tool_disabled() {
2974 let instructions = endpoint_instructions(McpEndpointType::Developer, false, true)
2975 .expect("developer instructions must be present");
2976 assert!(
2977 instructions.contains("cluster-targeted introspection is unavailable")
2978 && !instructions.contains("through the query tool"),
2979 "instructions must not route to the hidden query tool: {instructions}",
2980 );
2981 }
2982
2983 #[mz_ore::test]
2986 fn test_tools_list_accepts_optional_params() {
2987 for body in [
2988 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
2989 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":null}"#,
2990 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#,
2991 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"progressToken":0}}}"#,
2992 ] {
2993 let req: McpRequest = serde_json::from_str(body)
2994 .unwrap_or_else(|e| panic!("tools/list must deserialize: {body}: {e}"));
2995 assert!(
2996 matches!(req.method, McpMethod::ToolsList(_)),
2997 "expected ToolsList for {body}"
2998 );
2999 }
3000 }
3001
3002 #[mz_ore::test]
3003 fn test_protocol_version_select() {
3004 use ProtocolVersion::*;
3005 for (header, enabled, want) in [
3006 (&[][..], true, V2025_11_25),
3007 (&["2025-11-25"], true, V2025_11_25),
3008 (&["2025-06-18"], true, V2025_11_25),
3009 (&["2026-07-28"], true, V2026_07_28),
3010 (&["2026-07-28"], false, V2025_11_25),
3011 (&["not-a-version"], true, V2025_11_25),
3012 (&["2026-07-28X"], true, V2025_11_25),
3013 (&["2025-11-25", "2026-07-28"], true, V2026_07_28),
3014 (&["2025-11-25, 2026-07-28"], true, V2026_07_28),
3015 (&["2025-11-25", "2026-07-28"], false, V2025_11_25),
3016 ] {
3017 let values: Vec<_> = header.iter().map(|v| HeaderValue::from_static(v)).collect();
3018 assert_eq!(
3019 ProtocolVersion::select(&values, enabled),
3020 want,
3021 "for {header:?} with the flag {enabled}"
3022 );
3023 }
3024 let non_ascii = HeaderValue::from_bytes(b"2026-07-28\xff").expect("valid header bytes");
3025 assert_eq!(ProtocolVersion::select(&[non_ascii], true), V2025_11_25);
3026 let non_ascii_list =
3027 HeaderValue::from_bytes(b"\xff, 2026-07-28").expect("valid header bytes");
3028 assert_eq!(
3029 ProtocolVersion::select(&[non_ascii_list], true),
3030 V2026_07_28
3031 );
3032 }
3033
3034 fn json_headers() -> HeaderMap {
3035 HeaderMap::from_iter([(
3036 header::CONTENT_TYPE,
3037 HeaderValue::from_static("application/json"),
3038 )])
3039 }
3040
3041 #[mz_ore::test]
3042 fn test_parse_mcp_request_unknown_method_with_params() {
3043 let req = parse_mcp_request(
3044 &json_headers(),
3045 br#"{"jsonrpc":"2.0","id":7,"method":"server/other","params":{"_meta":{}}}"#,
3046 )
3047 .expect("unknown method with params must parse");
3048 assert!(matches!(req.method, McpMethod::Unknown));
3049 assert_eq!(req.id, Some(json!(7)));
3050
3051 let req = parse_mcp_request(
3052 &json_headers(),
3053 br#"{"jsonrpc":"2.0","method":"notifications/other","params":{}}"#,
3054 )
3055 .expect("unknown notification with params must parse");
3056 assert!(matches!(req.method, McpMethod::Unknown));
3057 assert_eq!(req.id, None);
3058 }
3059
3060 #[mz_ore::test]
3061 fn test_parse_mcp_request_keeps_other_rejections() {
3062 for (headers, body, want) in [
3063 (
3064 json_headers(),
3065 &br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nonexistent_tool","arguments":{}}}"#[..],
3066 StatusCode::UNPROCESSABLE_ENTITY,
3067 ),
3068 (
3069 json_headers(),
3070 br#"{"id":1,"method":"server/other","params":{}}"#,
3071 StatusCode::UNPROCESSABLE_ENTITY,
3072 ),
3073 (json_headers(), b"{not json", StatusCode::BAD_REQUEST),
3074 (
3075 HeaderMap::new(),
3076 br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
3077 StatusCode::UNSUPPORTED_MEDIA_TYPE,
3078 ),
3079 ] {
3080 let rejection = parse_mcp_request(&headers, body).expect_err("must be rejected");
3081 let body = String::from_utf8_lossy(body);
3082 assert_eq!(rejection.status(), want, "for {body}");
3083 }
3084 }
3085
3086 fn headers(method: &'static str, name: Option<&'static str>) -> McpHeaders {
3087 McpHeaders {
3088 protocol_version: vec![HeaderValue::from_static(
3089 ProtocolVersion::V2026_07_28.as_str(),
3090 )],
3091 method: vec![HeaderValue::from_static(method)],
3092 name: name.into_iter().map(HeaderValue::from_static).collect(),
3093 }
3094 }
3095
3096 #[mz_ore::test]
3097 fn test_header_check_tool_names() {
3098 let call = headers("tools/call", Some("query"));
3099 assert!(call.check("tools/call", ToolName::Name("query")).is_ok());
3100 assert!(call.check("tools/call", ToolName::Missing).is_err());
3101 assert!(call.check("tools/list", ToolName::NotToolsCall).is_err());
3102 assert!(
3103 headers("server/other", None)
3104 .check("server/other", ToolName::NotToolsCall)
3105 .is_ok()
3106 );
3107 }
3108
3109 #[mz_ore::test(tokio::test)]
3112 async fn test_answer_parse_failure() {
3113 let no_method = r#"{"jsonrpc":"2.0","id":4,"params":{}}"#;
3114 for (body, want_code, want_id) in [
3115 ("[]", error_codes::INVALID_REQUEST, None),
3116 ("null", error_codes::INVALID_REQUEST, None),
3117 ("{not json", error_codes::PARSE_ERROR, None),
3118 (no_method, error_codes::INVALID_REQUEST, Some(json!(4))),
3119 ] {
3120 let failure = parse_mcp_request(&json_headers(), body.as_bytes())
3121 .expect_err("not a request we parse");
3122 let headers = headers("tools/list", None);
3123 let resp = answer_parse_failure(failure, body.as_bytes(), &headers, &|_| {});
3124 assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "status for {body}");
3125 let resp: serde_json::Value = serde_json::from_slice(
3126 &axum::body::to_bytes(resp.into_body(), usize::MAX)
3127 .await
3128 .expect("body"),
3129 )
3130 .expect("JSON body");
3131 assert_eq!(resp["error"]["code"], json!(want_code), "code for {body}");
3132 assert_eq!(resp.get("id").cloned(), want_id, "id for {body}");
3133 }
3134 }
3135
3136 #[mz_ore::test]
3140 fn test_named_methods_accept_optional_params() {
3141 for (body, want_ping) in [
3142 (r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#, true),
3143 (
3144 r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}"#,
3145 true,
3146 ),
3147 (
3148 r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{"_meta":{"progressToken":0}}}"#,
3149 true,
3150 ),
3151 (
3152 r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
3153 false,
3154 ),
3155 (
3156 r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{"_meta":{"x":1}}}"#,
3157 false,
3158 ),
3159 ] {
3160 let req: McpRequest = serde_json::from_str(body)
3161 .unwrap_or_else(|e| panic!("must deserialize: {body}: {e}"));
3162 if want_ping {
3163 assert!(matches!(req.method, McpMethod::Ping(_)), "for {body}");
3164 } else {
3165 assert!(
3166 matches!(req.method, McpMethod::NotificationsInitialized(_)),
3167 "for {body}"
3168 );
3169 }
3170 }
3171 }
3172
3173 #[mz_ore::test]
3174 fn test_tools_call_arguments_optional() {
3175 for body in [
3176 r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_data_products"}}"#,
3177 r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_data_products","arguments":{}}}"#,
3178 r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_data_products","arguments":null}}"#,
3179 ] {
3180 let req: McpRequest = serde_json::from_str(body)
3181 .unwrap_or_else(|e| panic!("must deserialize: {body}: {e}"));
3182 assert!(
3183 matches!(
3184 req.method,
3185 McpMethod::ToolsCall(ToolsCallParams::GetDataProducts(NoArguments {}))
3186 ),
3187 "for {body}"
3188 );
3189 }
3190
3191 let err = serde_json::from_str::<McpRequest>(
3193 r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"query_system_catalog"}}"#,
3194 )
3195 .expect_err("query_system_catalog requires sql_query");
3196 assert!(err.to_string().contains("sql_query"), "{err}");
3197 }
3198
3199 #[mz_ore::test]
3202 fn test_format_rows_response_within_limit() {
3203 let rows = vec![vec![json!("a"), json!(1)], vec![json!("b"), json!(2)]];
3204 let result = format_rows_response(raw_rows(rows), 1_000_000).unwrap();
3205 let McpResult::ToolContent(content) = result else {
3206 panic!("Expected ToolContent");
3207 };
3208 assert_eq!(content.content.len(), 1);
3209 assert!(content.content[0].text.contains("\"a\""));
3210 assert!(content.content[0].text.contains("\"b\""));
3211 }
3212
3213 #[mz_ore::test]
3214 fn test_format_rows_response_errors_when_over_limit() {
3215 let rows: Vec<Vec<serde_json::Value>> = (0..100)
3216 .map(|i| vec![json!(format!("row_{}", i)), json!(i)])
3217 .collect();
3218 let err = format_rows_response(raw_rows(rows), 500).unwrap_err();
3219 let msg = err.to_string();
3220 assert!(
3221 msg.contains("exceeds the 500 byte limit"),
3222 "Error should mention the size limit, got: {msg}"
3223 );
3224 assert!(
3225 msg.contains("Use LIMIT or WHERE"),
3226 "Error should suggest narrowing the query, got: {msg}"
3227 );
3228 }
3229
3230 #[mz_ore::test]
3231 fn test_format_rows_response_empty_rows() {
3232 let rows: Vec<Vec<serde_json::Value>> = vec![];
3233 let result = format_rows_response(raw_rows(rows), 1000).unwrap();
3234 let McpResult::ToolContent(content) = result else {
3235 panic!("Expected ToolContent");
3236 };
3237 assert_eq!(content.content.len(), 1);
3238 assert_eq!(content.content[0].text, "[]");
3239 }
3240
3241 #[mz_ore::test]
3244 fn test_safe_data_product_name_valid() {
3245 assert_eq!(
3247 safe_data_product_name(r#""materialize"."public"."my_view""#).unwrap(),
3248 r#""materialize"."public"."my_view""#
3249 );
3250 assert_eq!(
3252 safe_data_product_name(r#""public"."my_view""#).unwrap(),
3253 r#""public"."my_view""#
3254 );
3255 assert_eq!(safe_data_product_name("my_view").unwrap(), r#""my_view""#);
3257 }
3258
3259 #[mz_ore::test]
3260 fn test_safe_data_product_name_rejects_empty() {
3261 assert!(safe_data_product_name("").is_err());
3262 assert!(safe_data_product_name(" ").is_err());
3263 }
3264
3265 #[mz_ore::test]
3269 fn test_safe_data_product_name_enforces_size_limit() {
3270 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
3271 let oversized: String = "(".repeat(MAX_STATEMENT_BATCH_SIZE + 1);
3272 let err = safe_data_product_name(&oversized).expect_err("should be rejected");
3273 let msg = err.to_string();
3274 assert!(
3275 msg.contains("statement batch size cannot exceed"),
3276 "expected size-guard error, got: {msg}"
3277 );
3278 }
3279
3280 #[mz_ore::test]
3281 fn test_safe_data_product_name_rejects_sql_injection() {
3282 assert!(safe_data_product_name("my_view; DROP TABLE users").is_err());
3284 assert!(safe_data_product_name("my_view UNION SELECT * FROM secrets").is_err());
3286 assert!(safe_data_product_name("my_view, secrets").is_err());
3288 assert!(safe_data_product_name("my_view WHERE 1=1 --").is_err());
3290 }
3291
3292 #[mz_ore::test]
3294 fn test_read_only_txn_comment_cannot_swallow_commit() {
3295 let sql = read_only_txn("SET CLUSTER = 'c'", "SELECT 1 --");
3296 assert!(
3297 sql.contains("\n; COMMIT;"),
3298 "COMMIT must sit on its own line: {sql}",
3299 );
3300 }
3301
3302 #[mz_ore::test]
3306 fn test_query_set_clause_without_replica() {
3307 let clause = query_set_clause("prod_cluster", None);
3308 assert_eq!(clause, "SET CLUSTER = 'prod_cluster'");
3309 }
3310
3311 #[mz_ore::test]
3315 fn test_query_set_clause_with_replica() {
3316 let clause = query_set_clause("prod_cluster", Some("r1"));
3317 assert_eq!(
3318 clause,
3319 "SET CLUSTER = 'prod_cluster'; SET CLUSTER_REPLICA = 'r1'"
3320 );
3321 }
3322
3323 #[mz_ore::test]
3327 fn test_query_set_clause_escapes_replica_name() {
3328 let clause = query_set_clause("c", Some("evil'; DROP TABLE secrets; --"));
3329 assert_eq!(
3330 clause,
3331 "SET CLUSTER = 'c'; SET CLUSTER_REPLICA = 'evil''; DROP TABLE secrets; --'"
3332 );
3333 }
3334
3335 #[mz_ore::test]
3338 fn test_validate_cluster_replica_accepts_none_and_names() {
3339 assert!(validate_cluster_replica(None).is_ok());
3340 assert!(validate_cluster_replica(Some("r1")).is_ok());
3341 }
3342
3343 #[mz_ore::test]
3347 fn test_validate_cluster_replica_rejects_empty() {
3348 for name in ["", " ", "\t\n"] {
3349 assert!(
3350 matches!(
3351 validate_cluster_replica(Some(name)),
3352 Err(McpRequestError::QueryValidationFailed(_))
3353 ),
3354 "expected validation error for {name:?}",
3355 );
3356 }
3357 }
3358
3359 #[mz_ore::test]
3365 fn test_build_read_query_with_cluster() {
3366 let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, Some("prod_cluster"));
3367 assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
3368 assert!(sql.contains("SET CLUSTER = 'prod_cluster'"), "{sql}");
3369 assert!(
3370 sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
3371 "{sql}",
3372 );
3373 assert!(sql.contains("COMMIT"), "{sql}");
3374 }
3375
3376 #[mz_ore::test]
3379 fn test_build_read_query_without_cluster() {
3380 let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, None);
3381 assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
3382 assert!(!sql.contains("SET CLUSTER"), "{sql}");
3383 assert!(
3384 sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
3385 "{sql}",
3386 );
3387 assert!(sql.contains("COMMIT"), "{sql}");
3388 }
3389
3390 #[mz_ore::test]
3395 fn test_build_read_query_escapes_cluster_name() {
3396 let sql = build_read_query(
3397 "\"db\".\"sch\".\"v\"",
3398 10,
3399 Some("evil'; DROP TABLE secrets; --"),
3400 );
3401 assert!(
3403 sql.contains("SET CLUSTER = 'evil''; DROP TABLE secrets; --'"),
3404 "single quote should be doubled inside the literal: {sql}",
3405 );
3406 assert_eq!(
3409 sql.matches("SET CLUSTER").count(),
3410 1,
3411 "exactly one SET CLUSTER statement: {sql}",
3412 );
3413 assert_eq!(
3414 sql.matches("DROP TABLE").count(),
3415 1,
3416 "DROP TABLE should appear once, inside the quoted literal: {sql}",
3417 );
3418 }
3419
3420 #[mz_ore::test]
3421 fn test_mcp_error_codes() {
3422 assert_eq!(
3423 McpRequestError::InvalidJsonRpcVersion.error_code(),
3424 error_codes::INVALID_REQUEST
3425 );
3426 assert_eq!(
3427 McpRequestError::MethodNotFound("test".to_string()).error_code(),
3428 error_codes::METHOD_NOT_FOUND
3429 );
3430 assert_eq!(
3431 McpRequestError::QueryExecutionFailed("test".to_string()).error_code(),
3432 error_codes::INTERNAL_ERROR
3433 );
3434 assert_eq!(
3435 McpRequestError::HeaderMismatch("test".to_string()).error_code(),
3436 error_codes::HEADER_MISMATCH
3437 );
3438 }
3439
3440 #[mz_ore::test]
3441 fn test_request_meta_check() {
3442 let params = |body: &str| {
3443 RawRequest::parse(body.as_bytes())
3444 .expect("an object")
3445 .params
3446 };
3447 let check =
3448 |body: &str| check_request_meta(params(body).as_ref()).map_err(|e| e.error_code());
3449 let with_meta = |meta: &str| {
3450 format!(
3451 r#"{{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{{"_meta":{meta}}}}}"#
3452 )
3453 };
3454 assert_eq!(
3455 check(&with_meta(
3456 r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}"#
3457 )),
3458 Ok(())
3459 );
3460 for (meta, want) in [
3461 (
3462 r#"{"io.modelcontextprotocol/clientCapabilities":{}}"#,
3463 error_codes::INVALID_PARAMS,
3464 ),
3465 (
3466 r#"{"io.modelcontextprotocol/protocolVersion":7,"io.modelcontextprotocol/clientCapabilities":{}}"#,
3467 error_codes::INVALID_PARAMS,
3468 ),
3469 (
3470 r#"{"io.modelcontextprotocol/protocolVersion":"2025-11-25","io.modelcontextprotocol/clientCapabilities":{}}"#,
3471 error_codes::HEADER_MISMATCH,
3472 ),
3473 (
3474 r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}"#,
3475 error_codes::INVALID_PARAMS,
3476 ),
3477 (
3478 r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":[]}"#,
3479 error_codes::INVALID_PARAMS,
3480 ),
3481 ] {
3482 assert_eq!(check(&with_meta(meta)), Err(want), "for {meta}");
3483 }
3484 let message = |body: &str| {
3485 check_request_meta(params(body).as_ref())
3486 .expect_err("must be rejected")
3487 .to_string()
3488 };
3489 assert!(
3490 message(&with_meta(
3491 r#"{"io.modelcontextprotocol/protocolVersion":7}"#
3492 ))
3493 .ends_with("must be a string")
3494 );
3495 assert!(message(&with_meta("[]")).ends_with("params._meta must be an object"));
3496 assert_eq!(
3497 check(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#),
3498 Err(error_codes::INVALID_PARAMS)
3499 );
3500 }
3501
3502 #[mz_ore::test]
3503 fn test_header_check_rejects_non_ascii() {
3504 let headers = McpHeaders {
3505 protocol_version: vec![HeaderValue::from_static(
3506 ProtocolVersion::V2026_07_28.as_str(),
3507 )],
3508 method: vec![HeaderValue::from_bytes(b"tools/list\xff").expect("valid header bytes")],
3509 name: vec![],
3510 };
3511 let err = headers
3512 .check("tools/list", ToolName::NotToolsCall)
3513 .expect_err("non-ASCII Mcp-Method");
3514 assert_eq!(err.error_code(), error_codes::HEADER_MISMATCH);
3515 }
3516}