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}
2055
2056impl TableReferenceCollector {
2057 fn new() -> Self {
2058 Self {
2059 tables: Vec::new(),
2060 cte_names: std::collections::BTreeSet::new(),
2061 }
2062 }
2063}
2064
2065impl<'ast> Visit<'ast, Raw> for TableReferenceCollector {
2066 fn visit_cte(&mut self, cte: &'ast mz_sql_parser::ast::Cte<Raw>) {
2067 self.cte_names
2069 .insert(cte.alias.name.as_str().to_lowercase());
2070 visit::visit_cte(self, cte);
2071 }
2072
2073 fn visit_table_factor(&mut self, table_factor: &'ast mz_sql_parser::ast::TableFactor<Raw>) {
2074 if let mz_sql_parser::ast::TableFactor::Table { name, .. } = table_factor {
2076 match name {
2077 RawItemName::Name(n) | RawItemName::Id(_, n, _) => {
2078 let parts = &n.0;
2079 if !parts.is_empty() {
2080 let table_name = parts.last().unwrap().as_str().to_lowercase();
2081
2082 if self.cte_names.contains(&table_name) {
2084 visit::visit_table_factor(self, table_factor);
2085 return;
2086 }
2087
2088 let schema = if parts.len() >= 2 {
2090 Some(parts[parts.len() - 2].as_str().to_lowercase())
2091 } else {
2092 None
2093 };
2094 self.tables.push((schema, table_name));
2095 }
2096 }
2097 }
2098 }
2099 visit::visit_table_factor(self, table_factor);
2100 }
2101}
2102
2103fn validate_system_catalog_query(sql: &str) -> Result<(), McpRequestError> {
2111 let stmts = parse_with_limit(sql)
2114 .map_err(McpRequestError::QueryValidationFailed)?
2115 .map_err(|e| {
2116 McpRequestError::QueryValidationFailed(format!("Failed to parse SQL: {}", e))
2117 })?;
2118
2119 if stmts.is_empty() {
2120 return Err(McpRequestError::QueryValidationFailed(
2121 "Empty query".to_string(),
2122 ));
2123 }
2124
2125 let mut collector = TableReferenceCollector::new();
2127 for stmt in &stmts {
2128 collector.visit_statement(&stmt.ast);
2129 }
2130
2131 let is_allowed_schema =
2134 |s: &str| SYSTEM_SCHEMAS.contains(&s) && s != namespaces::MZ_UNSAFE_SCHEMA;
2135
2136 let is_system_table = |(schema, table_name): &(Option<String>, String)| match schema {
2141 Some(s) => is_allowed_schema(s.as_str()),
2142 None => table_name.starts_with("mz_") || table_name.starts_with("pg_"),
2143 };
2144
2145 let non_system_tables: Vec<String> = collector
2147 .tables
2148 .iter()
2149 .filter(|t| !is_system_table(t))
2150 .map(|(schema, table)| match schema {
2151 Some(s) => format!("{}.{}", s, table),
2152 None => table.clone(),
2153 })
2154 .collect();
2155
2156 if !non_system_tables.is_empty() {
2157 return Err(McpRequestError::QueryValidationFailed(format!(
2158 "Query references non-system tables: {}. Only system catalog tables (mz_*, pg_catalog, information_schema) are allowed.",
2159 non_system_tables.join(", ")
2160 )));
2161 }
2162
2163 use mz_sql_parser::ast::Statement;
2166 let is_select = stmts.iter().any(|s| matches!(&s.ast, Statement::Select(_)));
2167
2168 if is_select && (collector.tables.is_empty() || !collector.tables.iter().any(is_system_table)) {
2169 return Err(McpRequestError::QueryValidationFailed(
2170 "Query must reference at least one system catalog table".to_string(),
2171 ));
2172 }
2173
2174 Ok(())
2175}
2176
2177#[cfg(test)]
2178mod tests {
2179 use super::*;
2180 use crate::http::sql::{Description, SqlError};
2181
2182 fn raw_rows(rows: Vec<Vec<serde_json::Value>>) -> Vec<Box<serde_json::value::RawValue>> {
2185 rows.iter()
2186 .map(|r| serde_json::value::to_raw_value(r).unwrap())
2187 .collect()
2188 }
2189
2190 fn rows_result(rows: Vec<Vec<serde_json::Value>>) -> SqlResult {
2191 SqlResult::Rows {
2192 tag: String::new(),
2193 rows: raw_rows(rows),
2194 desc: Description { columns: vec![] },
2195 notices: vec![],
2196 }
2197 }
2198
2199 fn ok_result() -> SqlResult {
2200 SqlResult::Ok {
2201 ok: String::new(),
2202 notices: vec![],
2203 parameters: vec![],
2204 }
2205 }
2206
2207 fn err_result(message: &str) -> SqlResult {
2208 SqlResult::Err {
2209 error: SqlError {
2210 message: message.to_string(),
2211 code: String::new(),
2212 detail: None,
2213 hint: None,
2214 position: None,
2215 },
2216 notices: vec![],
2217 }
2218 }
2219
2220 #[mz_ore::test]
2223 fn test_select_single_rows_extracts_rows() {
2224 let rows = vec![vec![serde_json::json!(1)]];
2225 let results = vec![
2226 ok_result(),
2227 ok_result(),
2228 rows_result(rows.clone()),
2229 ok_result(),
2230 ];
2231 let got = select_single_rows(results).unwrap();
2232 let got: Vec<&str> = got.iter().map(|r| r.get()).collect();
2233 assert_eq!(got, vec!["[1]"]);
2234 }
2235
2236 #[mz_ore::test]
2238 fn test_select_single_rows_requires_rows() {
2239 let err = select_single_rows(vec![ok_result(), ok_result()]).unwrap_err();
2240 assert!(
2241 matches!(err, McpRequestError::QueryExecutionFailed(_)),
2242 "{err:?}"
2243 );
2244 }
2245
2246 #[mz_ore::test]
2249 fn test_select_single_rows_rejects_multiple() {
2250 let results = vec![rows_result(vec![]), rows_result(vec![])];
2251 let err = select_single_rows(results).unwrap_err();
2252 assert!(matches!(err, McpRequestError::Internal(_)), "{err:?}");
2253 }
2254
2255 #[mz_ore::test]
2257 fn test_select_single_rows_surfaces_error() {
2258 let err = select_single_rows(vec![ok_result(), err_result("boom")]).unwrap_err();
2259 match err {
2260 McpRequestError::QueryExecutionFailed(msg) => assert_eq!(msg, "boom"),
2261 other => panic!("unexpected error: {other:?}"),
2262 }
2263 }
2264
2265 #[mz_ore::test]
2268 fn test_validate_origin() {
2269 let allowed = [HeaderValue::from_static("https://good.example")];
2270
2271 assert!(validate_origin(&HeaderMap::new(), &allowed).is_none());
2272
2273 let mut ok = HeaderMap::new();
2274 ok.insert(http::header::ORIGIN, allowed[0].clone());
2275 assert!(validate_origin(&ok, &allowed).is_none());
2276
2277 let mut bad = HeaderMap::new();
2278 bad.insert(
2279 http::header::ORIGIN,
2280 HeaderValue::from_static("https://evil.example"),
2281 );
2282 let rejected = validate_origin(&bad, &allowed);
2283 assert_eq!(
2284 rejected
2285 .expect("disallowed origin must be rejected")
2286 .status(),
2287 StatusCode::FORBIDDEN,
2288 );
2289 }
2290
2291 #[mz_ore::test]
2294 fn test_mcp_response_constructors() {
2295 let id = serde_json::json!(1);
2296
2297 let ok = McpResponse::success(
2298 id.clone(),
2299 McpResult::ToolContent(ToolContentResult {
2300 content: vec![],
2301 is_error: false,
2302 })
2303 .into_body(ProtocolVersion::V2025_11_25, McpEndpointType::Developer),
2304 );
2305 assert_eq!(ok.jsonrpc, JSONRPC_VERSION);
2306 assert!(ok.result.is_some());
2307 assert!(ok.error.is_none());
2308
2309 let err = McpResponse::error(id, McpRequestError::ToolNotFound("t".to_string()).into());
2310 assert_eq!(err.jsonrpc, JSONRPC_VERSION);
2311 assert!(err.result.is_none());
2312 assert!(err.error.is_some());
2313 }
2314
2315 #[mz_ore::test]
2316 fn test_validate_readonly_query_select() {
2317 assert!(validate_readonly_query("SELECT * FROM mz_tables").is_ok());
2318 assert!(validate_readonly_query("SELECT 1 + 2").is_ok());
2319 assert!(validate_readonly_query(" SELECT 1 ").is_ok());
2320 }
2321
2322 #[mz_ore::test]
2323 fn test_validate_readonly_query_subqueries() {
2324 assert!(
2326 validate_readonly_query(
2327 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
2328 )
2329 .is_ok()
2330 );
2331
2332 assert!(
2334 validate_readonly_query(
2335 "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
2336 )
2337 .is_ok()
2338 );
2339
2340 assert!(validate_readonly_query(
2342 "SELECT * FROM mz_tables t WHERE EXISTS (SELECT 1 FROM mz_columns c WHERE c.id = t.id)"
2343 )
2344 .is_ok());
2345
2346 assert!(validate_readonly_query(
2348 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns WHERE type IN (SELECT name FROM mz_types))"
2349 )
2350 .is_ok());
2351
2352 assert!(
2354 validate_readonly_query(
2355 "SELECT * FROM mz_tables WHERE id = (SELECT MAX(id) FROM mz_columns)"
2356 )
2357 .is_ok()
2358 );
2359 }
2360
2361 #[mz_ore::test]
2362 fn test_validate_readonly_query_show() {
2363 assert!(validate_readonly_query("SHOW CLUSTERS").is_ok());
2364 assert!(validate_readonly_query("SHOW TABLES").is_ok());
2365 }
2366
2367 #[mz_ore::test]
2368 fn test_validate_readonly_query_explain() {
2369 assert!(validate_readonly_query("EXPLAIN SELECT 1").is_ok());
2374 assert!(
2375 validate_readonly_query("EXPLAIN FILTER PUSHDOWN FOR SELECT * FROM mz_tables").is_ok()
2376 );
2377 assert!(validate_readonly_query("EXPLAIN TIMESTAMP FOR SELECT 1").is_ok());
2378 assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR INDEX foo").is_ok());
2379 assert!(validate_readonly_query("EXPLAIN ANALYZE MEMORY FOR MATERIALIZED VIEW mv").is_ok());
2380 assert!(validate_readonly_query("EXPLAIN ANALYZE CLUSTER MEMORY").is_ok());
2381 }
2382
2383 #[mz_ore::test]
2384 fn test_validate_readonly_query_rejects_writes() {
2385 assert!(validate_readonly_query("INSERT INTO t VALUES (1)").is_err());
2386 assert!(validate_readonly_query("UPDATE t SET a = 1").is_err());
2387 assert!(validate_readonly_query("DELETE FROM t").is_err());
2388 assert!(validate_readonly_query("CREATE TABLE t (a INT)").is_err());
2389 assert!(validate_readonly_query("DROP TABLE t").is_err());
2390 }
2391
2392 #[mz_ore::test]
2393 fn test_validate_readonly_query_rejects_multiple() {
2394 assert!(validate_readonly_query("SELECT 1; SELECT 2").is_err());
2395 }
2396
2397 #[mz_ore::test]
2398 fn test_validate_readonly_query_rejects_empty() {
2399 assert!(validate_readonly_query("").is_err());
2400 assert!(validate_readonly_query(" ").is_err());
2401 }
2402
2403 #[mz_ore::test]
2410 fn test_validate_readonly_query_enforces_size_limit() {
2411 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
2412 let oversized: String =
2414 "SELECT 1;".repeat((MAX_STATEMENT_BATCH_SIZE / "SELECT 1;".len()) + 1);
2415 let err = validate_readonly_query(&oversized).expect_err("should be rejected");
2416 let msg = err.to_string();
2417 assert!(
2418 msg.contains("statement batch size cannot exceed"),
2419 "expected size-guard error, got: {msg}"
2420 );
2421 }
2422
2423 #[mz_ore::test]
2424 fn test_validate_system_catalog_query_enforces_size_limit() {
2425 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
2426 let stmt = "SELECT * FROM mz_tables;";
2427 let oversized: String = stmt.repeat((MAX_STATEMENT_BATCH_SIZE / stmt.len()) + 1);
2428 let err = validate_system_catalog_query(&oversized).expect_err("should be rejected");
2429 let msg = err.to_string();
2430 assert!(
2431 msg.contains("statement batch size cannot exceed"),
2432 "expected size-guard error, got: {msg}"
2433 );
2434 }
2435
2436 #[mz_ore::test]
2437 fn test_validate_system_catalog_query_accepts_mz_tables() {
2438 assert!(validate_system_catalog_query("SELECT * FROM mz_tables").is_ok());
2439 assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_comments").is_ok());
2440 assert!(
2441 validate_system_catalog_query(
2442 "SELECT * FROM mz_tables t JOIN mz_columns c ON t.id = c.id"
2443 )
2444 .is_ok()
2445 );
2446 }
2447
2448 #[mz_ore::test]
2449 fn test_validate_system_catalog_query_subqueries() {
2450 assert!(
2452 validate_system_catalog_query(
2453 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM mz_columns)"
2454 )
2455 .is_ok()
2456 );
2457
2458 assert!(validate_system_catalog_query(
2460 "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM mz_columns WHERE type IN (SELECT id FROM mz_types))"
2461 )
2462 .is_ok());
2463
2464 assert!(
2466 validate_system_catalog_query(
2467 "SELECT * FROM (SELECT * FROM mz_tables WHERE name LIKE 'test%') AS t"
2468 )
2469 .is_ok()
2470 );
2471
2472 assert!(
2474 validate_system_catalog_query(
2475 "SELECT * FROM mz_tables WHERE id IN (SELECT table_id FROM user_data)"
2476 )
2477 .is_err()
2478 );
2479
2480 assert!(validate_system_catalog_query(
2482 "SELECT * FROM mz_tables WHERE id IN (SELECT id FROM (SELECT id FROM user_table) AS t)"
2483 )
2484 .is_err());
2485 }
2486
2487 #[mz_ore::test]
2488 fn test_validate_system_catalog_query_rejects_user_tables() {
2489 assert!(validate_system_catalog_query("SELECT * FROM user_data").is_err());
2490 assert!(validate_system_catalog_query("SELECT * FROM my_table").is_err());
2491 assert!(
2493 validate_system_catalog_query("SELECT * FROM user_data WHERE 'mz_' IS NOT NULL")
2494 .is_err()
2495 );
2496 }
2497
2498 #[mz_ore::test]
2499 fn test_validate_system_catalog_query_allows_functions() {
2500 assert!(
2502 validate_system_catalog_query(
2503 "SELECT date_part('year', now())::int4 AS y FROM mz_tables LIMIT 1"
2504 )
2505 .is_ok()
2506 );
2507 assert!(validate_system_catalog_query("SELECT length(name) FROM mz_tables").is_ok());
2508 assert!(
2509 validate_system_catalog_query(
2510 "SELECT count(*) FROM mz_sources WHERE now() > created_at"
2511 )
2512 .is_ok()
2513 );
2514 }
2515
2516 #[mz_ore::test]
2517 fn test_validate_system_catalog_query_unqualified_pg_catalog() {
2518 assert!(validate_system_catalog_query("SELECT * FROM pg_class").is_ok());
2521 assert!(validate_system_catalog_query("SELECT nspname FROM pg_namespace").is_ok());
2522 }
2523
2524 #[mz_ore::test]
2525 fn test_validate_system_catalog_query_schema_qualified() {
2526 assert!(validate_system_catalog_query("SELECT * FROM mz_catalog.mz_tables").is_ok());
2528 assert!(validate_system_catalog_query("SELECT * FROM mz_internal.mz_sessions").is_ok());
2529 assert!(validate_system_catalog_query("SELECT * FROM pg_catalog.pg_type").is_ok());
2530 assert!(validate_system_catalog_query("SELECT * FROM information_schema.tables").is_ok());
2531
2532 assert!(validate_system_catalog_query("SELECT * FROM public.user_table").is_err());
2534 assert!(validate_system_catalog_query("SELECT * FROM myschema.mytable").is_err());
2535
2536 assert!(
2538 validate_system_catalog_query("SELECT * FROM mz_unsafe.mz_some_table").is_err(),
2539 "mz_unsafe schema should be blocked even though it is a system schema"
2540 );
2541
2542 assert!(
2544 validate_system_catalog_query(
2545 "SELECT * FROM mz_catalog.mz_tables JOIN public.user_data ON true"
2546 )
2547 .is_err()
2548 );
2549 }
2550
2551 #[mz_ore::test]
2552 fn test_validate_system_catalog_query_adversarial_cases() {
2553 assert!(
2555 validate_system_catalog_query(
2556 "WITH user_cte AS (SELECT * FROM user_data) \
2557 SELECT * FROM mz_tables, user_cte"
2558 )
2559 .is_err(),
2560 "Should reject CTE referencing user table"
2561 );
2562
2563 assert!(
2565 validate_system_catalog_query(
2566 "WITH \
2567 cte1 AS (SELECT * FROM mz_tables), \
2568 cte2 AS (SELECT * FROM cte1), \
2569 cte3 AS (SELECT * FROM user_data) \
2570 SELECT * FROM cte2"
2571 )
2572 .is_err(),
2573 "Should reject CTE chain with user table"
2574 );
2575
2576 assert!(
2578 validate_system_catalog_query(
2579 "SELECT * FROM mz_tables t1 \
2580 JOIN user_data u ON t1.id = u.id \
2581 JOIN mz_sources s ON t1.id = s.id"
2582 )
2583 .is_err(),
2584 "Should reject multi-join with user table"
2585 );
2586
2587 assert!(
2589 validate_system_catalog_query(
2590 "SELECT * FROM mz_tables t \
2591 LEFT JOIN user_data u ON t.id = u.table_id \
2592 WHERE u.id IS NULL"
2593 )
2594 .is_err(),
2595 "Should reject LEFT JOIN with user table"
2596 );
2597
2598 assert!(
2600 validate_system_catalog_query(
2601 "SELECT * FROM mz_tables WHERE id IN \
2602 (SELECT table_id FROM (SELECT * FROM user_data) AS u)"
2603 )
2604 .is_err(),
2605 "Should reject nested subquery with user table"
2606 );
2607
2608 assert!(
2610 validate_system_catalog_query(
2611 "SELECT name FROM mz_tables \
2612 UNION \
2613 SELECT name FROM user_data"
2614 )
2615 .is_err(),
2616 "Should reject UNION with user table"
2617 );
2618
2619 assert!(
2621 validate_system_catalog_query(
2622 "SELECT id FROM mz_sources \
2623 UNION ALL \
2624 SELECT id FROM products"
2625 )
2626 .is_err(),
2627 "Should reject UNION ALL with user table"
2628 );
2629
2630 assert!(
2632 validate_system_catalog_query("SELECT * FROM mz_tables CROSS JOIN user_data").is_err(),
2633 "Should reject CROSS JOIN with user table"
2634 );
2635
2636 assert!(
2638 validate_system_catalog_query(
2639 "SELECT t.*, (SELECT COUNT(*) FROM user_data) AS cnt FROM mz_tables t"
2640 )
2641 .is_err(),
2642 "Should reject subquery in SELECT with user table"
2643 );
2644
2645 assert!(
2647 validate_system_catalog_query("SELECT * FROM mz_catalogg.fake_table").is_err(),
2648 "Should reject typo-squatting schema name"
2649 );
2650 assert!(
2651 validate_system_catalog_query("SELECT * FROM mz_catalog_hack.fake_table").is_err(),
2652 "Should reject fake schema with mz_catalog prefix"
2653 );
2654
2655 assert!(
2657 validate_system_catalog_query(
2658 "SELECT * FROM mz_tables t, LATERAL (SELECT * FROM user_data WHERE id = t.id) u"
2659 )
2660 .is_err(),
2661 "Should reject LATERAL join with user table"
2662 );
2663
2664 assert!(
2666 validate_system_catalog_query(
2667 "WITH \
2668 tables AS (SELECT * FROM mz_tables), \
2669 sources AS (SELECT * FROM mz_sources) \
2670 SELECT t.name, s.name \
2671 FROM tables t \
2672 JOIN sources s ON t.id = s.id \
2673 WHERE t.id IN (SELECT id FROM mz_columns)"
2674 )
2675 .is_ok(),
2676 "Should allow complex query with only system tables"
2677 );
2678
2679 assert!(
2681 validate_system_catalog_query(
2682 "SELECT name FROM mz_tables \
2683 UNION \
2684 SELECT name FROM mz_sources"
2685 )
2686 .is_ok(),
2687 "Should allow UNION of system tables"
2688 );
2689 }
2690
2691 #[mz_ore::test]
2692 fn test_validate_system_catalog_query_rejects_constant_queries() {
2693 assert!(
2696 validate_system_catalog_query("SELECT 1").is_err(),
2697 "Should reject constant SELECT with no table references"
2698 );
2699 assert!(
2700 validate_system_catalog_query("SELECT 1 + 2, 'hello'").is_err(),
2701 "Should reject constant expression SELECT"
2702 );
2703 assert!(
2704 validate_system_catalog_query("SELECT now()").is_err(),
2705 "Should reject function-only SELECT with no table references"
2706 );
2707 }
2708
2709 #[mz_ore::test]
2710 fn test_validate_system_catalog_query_rejects_mixed_tables() {
2711 assert!(
2712 validate_system_catalog_query(
2713 "SELECT * FROM mz_tables t JOIN user_data u ON t.id = u.table_id"
2714 )
2715 .is_err()
2716 );
2717 }
2718
2719 #[mz_ore::test]
2720 fn test_validate_system_catalog_query_allows_show() {
2721 assert!(
2723 validate_system_catalog_query("SHOW TABLES FROM mz_internal").is_ok(),
2724 "SHOW TABLES FROM mz_internal should be allowed"
2725 );
2726 assert!(
2727 validate_system_catalog_query("SHOW TABLES FROM mz_catalog").is_ok(),
2728 "SHOW TABLES FROM mz_catalog should be allowed"
2729 );
2730 assert!(
2731 validate_system_catalog_query("SHOW CLUSTERS").is_ok(),
2732 "SHOW CLUSTERS should be allowed"
2733 );
2734 assert!(
2735 validate_system_catalog_query("SHOW SOURCES").is_ok(),
2736 "SHOW SOURCES should be allowed"
2737 );
2738 assert!(
2739 validate_system_catalog_query("SHOW TABLES").is_ok(),
2740 "SHOW TABLES should be allowed"
2741 );
2742 }
2743
2744 #[mz_ore::test]
2745 fn test_validate_system_catalog_query_allows_explain() {
2746 assert!(
2747 validate_system_catalog_query("EXPLAIN SELECT * FROM mz_tables").is_ok(),
2748 "EXPLAIN of system table query should be allowed"
2749 );
2750 assert!(
2751 validate_system_catalog_query("EXPLAIN SELECT 1").is_ok(),
2752 "EXPLAIN SELECT 1 should be allowed"
2753 );
2754 }
2755
2756 #[mz_ore::test(tokio::test)]
2759 async fn test_tools_list_agent_query_tool_disabled() {
2760 let result = handle_tools_list(McpEndpointType::Agent, false, true, 1_000_000).unwrap();
2761 let McpResult::ToolsList(list) = result else {
2762 panic!("Expected ToolsList result");
2763 };
2764 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2765 assert!(
2766 tool_names.contains(&"get_data_products"),
2767 "get_data_products should always be present"
2768 );
2769 assert!(
2770 tool_names.contains(&"get_data_product_details"),
2771 "get_data_product_details should always be present"
2772 );
2773 assert!(
2774 tool_names.contains(&"read_data_product"),
2775 "read_data_product should be present when its flag is on"
2776 );
2777 assert!(
2778 !tool_names.contains(&"query"),
2779 "query tool should be hidden when disabled"
2780 );
2781 }
2782
2783 #[mz_ore::test(tokio::test)]
2784 async fn test_tools_list_agent_query_tool_enabled() {
2785 let result = handle_tools_list(McpEndpointType::Agent, true, true, 1_000_000).unwrap();
2786 let McpResult::ToolsList(list) = result else {
2787 panic!("Expected ToolsList result");
2788 };
2789 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2790 assert!(
2791 tool_names.contains(&"get_data_products"),
2792 "get_data_products should always be present"
2793 );
2794 assert!(
2795 tool_names.contains(&"get_data_product_details"),
2796 "get_data_product_details should always be present"
2797 );
2798 assert!(
2799 tool_names.contains(&"read_data_product"),
2800 "read_data_product should be present when its flag is on"
2801 );
2802 assert!(
2803 tool_names.contains(&"query"),
2804 "query tool should be present when enabled"
2805 );
2806 }
2807
2808 #[mz_ore::test(tokio::test)]
2809 async fn test_tools_list_agent_read_data_product_tool_disabled() {
2810 let result = handle_tools_list(McpEndpointType::Agent, true, false, 1_000_000).unwrap();
2811 let McpResult::ToolsList(list) = result else {
2812 panic!("Expected ToolsList result");
2813 };
2814 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2815 assert!(
2816 tool_names.contains(&"get_data_products"),
2817 "get_data_products should always be present"
2818 );
2819 assert!(
2820 tool_names.contains(&"get_data_product_details"),
2821 "get_data_product_details should always be present"
2822 );
2823 assert!(
2824 !tool_names.contains(&"read_data_product"),
2825 "read_data_product should be hidden when disabled"
2826 );
2827 assert!(
2828 tool_names.contains(&"query"),
2829 "query tool should remain present when enabled"
2830 );
2831 }
2832
2833 #[mz_ore::test(tokio::test)]
2838 async fn test_tools_list_agent_both_read_tools_disabled() {
2839 let result = handle_tools_list(McpEndpointType::Agent, false, false, 1_000_000).unwrap();
2840 let McpResult::ToolsList(list) = result else {
2841 panic!("Expected ToolsList result");
2842 };
2843 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2844 assert_eq!(
2845 tool_names
2846 .iter()
2847 .copied()
2848 .collect::<std::collections::BTreeSet<_>>(),
2849 ["get_data_product_details", "get_data_products"]
2850 .into_iter()
2851 .collect(),
2852 "only discovery tools should be advertised when both read flags are off",
2853 );
2854
2855 let instructions = endpoint_instructions(McpEndpointType::Agent, false, false)
2856 .expect("agent instructions must be present");
2857 assert!(
2858 !instructions.contains("Use the `query` tool"),
2859 "instructions must not point at query when it is hidden: {instructions}",
2860 );
2861 assert!(
2862 !instructions.contains("`read_data_product` automatically"),
2863 "instructions must not point at read_data_product when it is hidden: {instructions}",
2864 );
2865 assert!(
2866 instructions.contains("discovery only"),
2867 "instructions must tell the agent it is discovery-only: {instructions}",
2868 );
2869 }
2870
2871 #[mz_ore::test(tokio::test)]
2872 async fn test_tools_list_developer_query_tool_disabled() {
2873 let result = handle_tools_list(McpEndpointType::Developer, false, true, 1_000_000).unwrap();
2876 let McpResult::ToolsList(list) = result else {
2877 panic!("Expected ToolsList result");
2878 };
2879 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2880 assert!(
2881 tool_names.contains(&"query_system_catalog"),
2882 "query_system_catalog should always be present on developer"
2883 );
2884 assert!(
2885 !tool_names.contains(&"query"),
2886 "query tool should be hidden when disabled"
2887 );
2888 }
2889
2890 #[mz_ore::test(tokio::test)]
2891 async fn test_tools_list_developer_query_tool_enabled() {
2892 let result = handle_tools_list(McpEndpointType::Developer, true, true, 1_000_000).unwrap();
2893 let McpResult::ToolsList(list) = result else {
2894 panic!("Expected ToolsList result");
2895 };
2896 let tool_names: Vec<&str> = list.tools.iter().map(|t| t.name.as_str()).collect();
2897 assert!(
2898 tool_names.contains(&"query_system_catalog"),
2899 "query_system_catalog should always be present on developer"
2900 );
2901 assert!(
2902 tool_names.contains(&"query"),
2903 "query tool should be present on developer when enabled"
2904 );
2905
2906 let instructions = endpoint_instructions(McpEndpointType::Developer, true, true)
2907 .expect("developer instructions must be present");
2908 assert!(
2909 instructions.contains("through the query tool with its cluster argument"),
2910 "instructions must route mz_introspection through query: {instructions}",
2911 );
2912 }
2913
2914 #[mz_ore::test]
2915 fn test_developer_instructions_query_tool_disabled() {
2916 let instructions = endpoint_instructions(McpEndpointType::Developer, false, true)
2917 .expect("developer instructions must be present");
2918 assert!(
2919 instructions.contains("cluster-targeted introspection is unavailable")
2920 && !instructions.contains("through the query tool"),
2921 "instructions must not route to the hidden query tool: {instructions}",
2922 );
2923 }
2924
2925 #[mz_ore::test]
2928 fn test_tools_list_accepts_optional_params() {
2929 for body in [
2930 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
2931 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":null}"#,
2932 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#,
2933 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"progressToken":0}}}"#,
2934 ] {
2935 let req: McpRequest = serde_json::from_str(body)
2936 .unwrap_or_else(|e| panic!("tools/list must deserialize: {body}: {e}"));
2937 assert!(
2938 matches!(req.method, McpMethod::ToolsList(_)),
2939 "expected ToolsList for {body}"
2940 );
2941 }
2942 }
2943
2944 #[mz_ore::test]
2945 fn test_protocol_version_select() {
2946 use ProtocolVersion::*;
2947 for (header, enabled, want) in [
2948 (&[][..], true, V2025_11_25),
2949 (&["2025-11-25"], true, V2025_11_25),
2950 (&["2025-06-18"], true, V2025_11_25),
2951 (&["2026-07-28"], true, V2026_07_28),
2952 (&["2026-07-28"], false, V2025_11_25),
2953 (&["not-a-version"], true, V2025_11_25),
2954 (&["2026-07-28X"], true, V2025_11_25),
2955 (&["2025-11-25", "2026-07-28"], true, V2026_07_28),
2956 (&["2025-11-25, 2026-07-28"], true, V2026_07_28),
2957 (&["2025-11-25", "2026-07-28"], false, V2025_11_25),
2958 ] {
2959 let values: Vec<_> = header.iter().map(|v| HeaderValue::from_static(v)).collect();
2960 assert_eq!(
2961 ProtocolVersion::select(&values, enabled),
2962 want,
2963 "for {header:?} with the flag {enabled}"
2964 );
2965 }
2966 let non_ascii = HeaderValue::from_bytes(b"2026-07-28\xff").expect("valid header bytes");
2967 assert_eq!(ProtocolVersion::select(&[non_ascii], true), V2025_11_25);
2968 let non_ascii_list =
2969 HeaderValue::from_bytes(b"\xff, 2026-07-28").expect("valid header bytes");
2970 assert_eq!(
2971 ProtocolVersion::select(&[non_ascii_list], true),
2972 V2026_07_28
2973 );
2974 }
2975
2976 fn json_headers() -> HeaderMap {
2977 HeaderMap::from_iter([(
2978 header::CONTENT_TYPE,
2979 HeaderValue::from_static("application/json"),
2980 )])
2981 }
2982
2983 #[mz_ore::test]
2984 fn test_parse_mcp_request_unknown_method_with_params() {
2985 let req = parse_mcp_request(
2986 &json_headers(),
2987 br#"{"jsonrpc":"2.0","id":7,"method":"server/other","params":{"_meta":{}}}"#,
2988 )
2989 .expect("unknown method with params must parse");
2990 assert!(matches!(req.method, McpMethod::Unknown));
2991 assert_eq!(req.id, Some(json!(7)));
2992
2993 let req = parse_mcp_request(
2994 &json_headers(),
2995 br#"{"jsonrpc":"2.0","method":"notifications/other","params":{}}"#,
2996 )
2997 .expect("unknown notification with params must parse");
2998 assert!(matches!(req.method, McpMethod::Unknown));
2999 assert_eq!(req.id, None);
3000 }
3001
3002 #[mz_ore::test]
3003 fn test_parse_mcp_request_keeps_other_rejections() {
3004 for (headers, body, want) in [
3005 (
3006 json_headers(),
3007 &br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nonexistent_tool","arguments":{}}}"#[..],
3008 StatusCode::UNPROCESSABLE_ENTITY,
3009 ),
3010 (
3011 json_headers(),
3012 br#"{"id":1,"method":"server/other","params":{}}"#,
3013 StatusCode::UNPROCESSABLE_ENTITY,
3014 ),
3015 (json_headers(), b"{not json", StatusCode::BAD_REQUEST),
3016 (
3017 HeaderMap::new(),
3018 br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
3019 StatusCode::UNSUPPORTED_MEDIA_TYPE,
3020 ),
3021 ] {
3022 let rejection = parse_mcp_request(&headers, body).expect_err("must be rejected");
3023 let body = String::from_utf8_lossy(body);
3024 assert_eq!(rejection.status(), want, "for {body}");
3025 }
3026 }
3027
3028 fn headers(method: &'static str, name: Option<&'static str>) -> McpHeaders {
3029 McpHeaders {
3030 protocol_version: vec![HeaderValue::from_static(
3031 ProtocolVersion::V2026_07_28.as_str(),
3032 )],
3033 method: vec![HeaderValue::from_static(method)],
3034 name: name.into_iter().map(HeaderValue::from_static).collect(),
3035 }
3036 }
3037
3038 #[mz_ore::test]
3039 fn test_header_check_tool_names() {
3040 let call = headers("tools/call", Some("query"));
3041 assert!(call.check("tools/call", ToolName::Name("query")).is_ok());
3042 assert!(call.check("tools/call", ToolName::Missing).is_err());
3043 assert!(call.check("tools/list", ToolName::NotToolsCall).is_err());
3044 assert!(
3045 headers("server/other", None)
3046 .check("server/other", ToolName::NotToolsCall)
3047 .is_ok()
3048 );
3049 }
3050
3051 #[mz_ore::test(tokio::test)]
3054 async fn test_answer_parse_failure() {
3055 let no_method = r#"{"jsonrpc":"2.0","id":4,"params":{}}"#;
3056 for (body, want_code, want_id) in [
3057 ("[]", error_codes::INVALID_REQUEST, None),
3058 ("null", error_codes::INVALID_REQUEST, None),
3059 ("{not json", error_codes::PARSE_ERROR, None),
3060 (no_method, error_codes::INVALID_REQUEST, Some(json!(4))),
3061 ] {
3062 let failure = parse_mcp_request(&json_headers(), body.as_bytes())
3063 .expect_err("not a request we parse");
3064 let headers = headers("tools/list", None);
3065 let resp = answer_parse_failure(failure, body.as_bytes(), &headers, &|_| {});
3066 assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "status for {body}");
3067 let resp: serde_json::Value = serde_json::from_slice(
3068 &axum::body::to_bytes(resp.into_body(), usize::MAX)
3069 .await
3070 .expect("body"),
3071 )
3072 .expect("JSON body");
3073 assert_eq!(resp["error"]["code"], json!(want_code), "code for {body}");
3074 assert_eq!(resp.get("id").cloned(), want_id, "id for {body}");
3075 }
3076 }
3077
3078 #[mz_ore::test]
3082 fn test_named_methods_accept_optional_params() {
3083 for (body, want_ping) in [
3084 (r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#, true),
3085 (
3086 r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}"#,
3087 true,
3088 ),
3089 (
3090 r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{"_meta":{"progressToken":0}}}"#,
3091 true,
3092 ),
3093 (
3094 r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
3095 false,
3096 ),
3097 (
3098 r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{"_meta":{"x":1}}}"#,
3099 false,
3100 ),
3101 ] {
3102 let req: McpRequest = serde_json::from_str(body)
3103 .unwrap_or_else(|e| panic!("must deserialize: {body}: {e}"));
3104 if want_ping {
3105 assert!(matches!(req.method, McpMethod::Ping(_)), "for {body}");
3106 } else {
3107 assert!(
3108 matches!(req.method, McpMethod::NotificationsInitialized(_)),
3109 "for {body}"
3110 );
3111 }
3112 }
3113 }
3114
3115 #[mz_ore::test]
3116 fn test_tools_call_arguments_optional() {
3117 for body in [
3118 r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_data_products"}}"#,
3119 r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_data_products","arguments":{}}}"#,
3120 r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_data_products","arguments":null}}"#,
3121 ] {
3122 let req: McpRequest = serde_json::from_str(body)
3123 .unwrap_or_else(|e| panic!("must deserialize: {body}: {e}"));
3124 assert!(
3125 matches!(
3126 req.method,
3127 McpMethod::ToolsCall(ToolsCallParams::GetDataProducts(NoArguments {}))
3128 ),
3129 "for {body}"
3130 );
3131 }
3132
3133 let err = serde_json::from_str::<McpRequest>(
3135 r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"query_system_catalog"}}"#,
3136 )
3137 .expect_err("query_system_catalog requires sql_query");
3138 assert!(err.to_string().contains("sql_query"), "{err}");
3139 }
3140
3141 #[mz_ore::test]
3144 fn test_format_rows_response_within_limit() {
3145 let rows = vec![vec![json!("a"), json!(1)], vec![json!("b"), json!(2)]];
3146 let result = format_rows_response(raw_rows(rows), 1_000_000).unwrap();
3147 let McpResult::ToolContent(content) = result else {
3148 panic!("Expected ToolContent");
3149 };
3150 assert_eq!(content.content.len(), 1);
3151 assert!(content.content[0].text.contains("\"a\""));
3152 assert!(content.content[0].text.contains("\"b\""));
3153 }
3154
3155 #[mz_ore::test]
3156 fn test_format_rows_response_errors_when_over_limit() {
3157 let rows: Vec<Vec<serde_json::Value>> = (0..100)
3158 .map(|i| vec![json!(format!("row_{}", i)), json!(i)])
3159 .collect();
3160 let err = format_rows_response(raw_rows(rows), 500).unwrap_err();
3161 let msg = err.to_string();
3162 assert!(
3163 msg.contains("exceeds the 500 byte limit"),
3164 "Error should mention the size limit, got: {msg}"
3165 );
3166 assert!(
3167 msg.contains("Use LIMIT or WHERE"),
3168 "Error should suggest narrowing the query, got: {msg}"
3169 );
3170 }
3171
3172 #[mz_ore::test]
3173 fn test_format_rows_response_empty_rows() {
3174 let rows: Vec<Vec<serde_json::Value>> = vec![];
3175 let result = format_rows_response(raw_rows(rows), 1000).unwrap();
3176 let McpResult::ToolContent(content) = result else {
3177 panic!("Expected ToolContent");
3178 };
3179 assert_eq!(content.content.len(), 1);
3180 assert_eq!(content.content[0].text, "[]");
3181 }
3182
3183 #[mz_ore::test]
3186 fn test_safe_data_product_name_valid() {
3187 assert_eq!(
3189 safe_data_product_name(r#""materialize"."public"."my_view""#).unwrap(),
3190 r#""materialize"."public"."my_view""#
3191 );
3192 assert_eq!(
3194 safe_data_product_name(r#""public"."my_view""#).unwrap(),
3195 r#""public"."my_view""#
3196 );
3197 assert_eq!(safe_data_product_name("my_view").unwrap(), r#""my_view""#);
3199 }
3200
3201 #[mz_ore::test]
3202 fn test_safe_data_product_name_rejects_empty() {
3203 assert!(safe_data_product_name("").is_err());
3204 assert!(safe_data_product_name(" ").is_err());
3205 }
3206
3207 #[mz_ore::test]
3211 fn test_safe_data_product_name_enforces_size_limit() {
3212 use mz_sql_parser::parser::MAX_STATEMENT_BATCH_SIZE;
3213 let oversized: String = "(".repeat(MAX_STATEMENT_BATCH_SIZE + 1);
3214 let err = safe_data_product_name(&oversized).expect_err("should be rejected");
3215 let msg = err.to_string();
3216 assert!(
3217 msg.contains("statement batch size cannot exceed"),
3218 "expected size-guard error, got: {msg}"
3219 );
3220 }
3221
3222 #[mz_ore::test]
3223 fn test_safe_data_product_name_rejects_sql_injection() {
3224 assert!(safe_data_product_name("my_view; DROP TABLE users").is_err());
3226 assert!(safe_data_product_name("my_view UNION SELECT * FROM secrets").is_err());
3228 assert!(safe_data_product_name("my_view, secrets").is_err());
3230 assert!(safe_data_product_name("my_view WHERE 1=1 --").is_err());
3232 }
3233
3234 #[mz_ore::test]
3236 fn test_read_only_txn_comment_cannot_swallow_commit() {
3237 let sql = read_only_txn("SET CLUSTER = 'c'", "SELECT 1 --");
3238 assert!(
3239 sql.contains("\n; COMMIT;"),
3240 "COMMIT must sit on its own line: {sql}",
3241 );
3242 }
3243
3244 #[mz_ore::test]
3248 fn test_query_set_clause_without_replica() {
3249 let clause = query_set_clause("prod_cluster", None);
3250 assert_eq!(clause, "SET CLUSTER = 'prod_cluster'");
3251 }
3252
3253 #[mz_ore::test]
3257 fn test_query_set_clause_with_replica() {
3258 let clause = query_set_clause("prod_cluster", Some("r1"));
3259 assert_eq!(
3260 clause,
3261 "SET CLUSTER = 'prod_cluster'; SET CLUSTER_REPLICA = 'r1'"
3262 );
3263 }
3264
3265 #[mz_ore::test]
3269 fn test_query_set_clause_escapes_replica_name() {
3270 let clause = query_set_clause("c", Some("evil'; DROP TABLE secrets; --"));
3271 assert_eq!(
3272 clause,
3273 "SET CLUSTER = 'c'; SET CLUSTER_REPLICA = 'evil''; DROP TABLE secrets; --'"
3274 );
3275 }
3276
3277 #[mz_ore::test]
3280 fn test_validate_cluster_replica_accepts_none_and_names() {
3281 assert!(validate_cluster_replica(None).is_ok());
3282 assert!(validate_cluster_replica(Some("r1")).is_ok());
3283 }
3284
3285 #[mz_ore::test]
3289 fn test_validate_cluster_replica_rejects_empty() {
3290 for name in ["", " ", "\t\n"] {
3291 assert!(
3292 matches!(
3293 validate_cluster_replica(Some(name)),
3294 Err(McpRequestError::QueryValidationFailed(_))
3295 ),
3296 "expected validation error for {name:?}",
3297 );
3298 }
3299 }
3300
3301 #[mz_ore::test]
3307 fn test_build_read_query_with_cluster() {
3308 let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, Some("prod_cluster"));
3309 assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
3310 assert!(sql.contains("SET CLUSTER = 'prod_cluster'"), "{sql}");
3311 assert!(
3312 sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
3313 "{sql}",
3314 );
3315 assert!(sql.contains("COMMIT"), "{sql}");
3316 }
3317
3318 #[mz_ore::test]
3321 fn test_build_read_query_without_cluster() {
3322 let sql = build_read_query("\"db\".\"sch\".\"v\"", 50, None);
3323 assert!(sql.contains("BEGIN READ ONLY"), "{sql}");
3324 assert!(!sql.contains("SET CLUSTER"), "{sql}");
3325 assert!(
3326 sql.contains("SELECT * FROM \"db\".\"sch\".\"v\" LIMIT 50"),
3327 "{sql}",
3328 );
3329 assert!(sql.contains("COMMIT"), "{sql}");
3330 }
3331
3332 #[mz_ore::test]
3337 fn test_build_read_query_escapes_cluster_name() {
3338 let sql = build_read_query(
3339 "\"db\".\"sch\".\"v\"",
3340 10,
3341 Some("evil'; DROP TABLE secrets; --"),
3342 );
3343 assert!(
3345 sql.contains("SET CLUSTER = 'evil''; DROP TABLE secrets; --'"),
3346 "single quote should be doubled inside the literal: {sql}",
3347 );
3348 assert_eq!(
3351 sql.matches("SET CLUSTER").count(),
3352 1,
3353 "exactly one SET CLUSTER statement: {sql}",
3354 );
3355 assert_eq!(
3356 sql.matches("DROP TABLE").count(),
3357 1,
3358 "DROP TABLE should appear once, inside the quoted literal: {sql}",
3359 );
3360 }
3361
3362 #[mz_ore::test]
3363 fn test_mcp_error_codes() {
3364 assert_eq!(
3365 McpRequestError::InvalidJsonRpcVersion.error_code(),
3366 error_codes::INVALID_REQUEST
3367 );
3368 assert_eq!(
3369 McpRequestError::MethodNotFound("test".to_string()).error_code(),
3370 error_codes::METHOD_NOT_FOUND
3371 );
3372 assert_eq!(
3373 McpRequestError::QueryExecutionFailed("test".to_string()).error_code(),
3374 error_codes::INTERNAL_ERROR
3375 );
3376 assert_eq!(
3377 McpRequestError::HeaderMismatch("test".to_string()).error_code(),
3378 error_codes::HEADER_MISMATCH
3379 );
3380 }
3381
3382 #[mz_ore::test]
3383 fn test_request_meta_check() {
3384 let params = |body: &str| {
3385 RawRequest::parse(body.as_bytes())
3386 .expect("an object")
3387 .params
3388 };
3389 let check =
3390 |body: &str| check_request_meta(params(body).as_ref()).map_err(|e| e.error_code());
3391 let with_meta = |meta: &str| {
3392 format!(
3393 r#"{{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{{"_meta":{meta}}}}}"#
3394 )
3395 };
3396 assert_eq!(
3397 check(&with_meta(
3398 r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}"#
3399 )),
3400 Ok(())
3401 );
3402 for (meta, want) in [
3403 (
3404 r#"{"io.modelcontextprotocol/clientCapabilities":{}}"#,
3405 error_codes::INVALID_PARAMS,
3406 ),
3407 (
3408 r#"{"io.modelcontextprotocol/protocolVersion":7,"io.modelcontextprotocol/clientCapabilities":{}}"#,
3409 error_codes::INVALID_PARAMS,
3410 ),
3411 (
3412 r#"{"io.modelcontextprotocol/protocolVersion":"2025-11-25","io.modelcontextprotocol/clientCapabilities":{}}"#,
3413 error_codes::HEADER_MISMATCH,
3414 ),
3415 (
3416 r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}"#,
3417 error_codes::INVALID_PARAMS,
3418 ),
3419 (
3420 r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":[]}"#,
3421 error_codes::INVALID_PARAMS,
3422 ),
3423 ] {
3424 assert_eq!(check(&with_meta(meta)), Err(want), "for {meta}");
3425 }
3426 let message = |body: &str| {
3427 check_request_meta(params(body).as_ref())
3428 .expect_err("must be rejected")
3429 .to_string()
3430 };
3431 assert!(
3432 message(&with_meta(
3433 r#"{"io.modelcontextprotocol/protocolVersion":7}"#
3434 ))
3435 .ends_with("must be a string")
3436 );
3437 assert!(message(&with_meta("[]")).ends_with("params._meta must be an object"));
3438 assert_eq!(
3439 check(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#),
3440 Err(error_codes::INVALID_PARAMS)
3441 );
3442 }
3443
3444 #[mz_ore::test]
3445 fn test_header_check_rejects_non_ascii() {
3446 let headers = McpHeaders {
3447 protocol_version: vec![HeaderValue::from_static(
3448 ProtocolVersion::V2026_07_28.as_str(),
3449 )],
3450 method: vec![HeaderValue::from_bytes(b"tools/list\xff").expect("valid header bytes")],
3451 name: vec![],
3452 };
3453 let err = headers
3454 .check("tools/list", ToolName::NotToolsCall)
3455 .expect_err("non-ASCII Mcp-Method");
3456 assert_eq!(err.error_code(), error_codes::HEADER_MISMATCH);
3457 }
3458}