diff --git a/src/environmentd/src/http/mcp.rs b/src/environmentd/src/http/mcp.rs index 16fb1f62706ba..9685f48ae95e4 100644 --- a/src/environmentd/src/http/mcp.rs +++ b/src/environmentd/src/http/mcp.rs @@ -86,27 +86,111 @@ impl ProtocolVersion { /// with a 2026-07-28 error tells dual-era clients the server speaks that /// revision, so they stop falling back to `initialize`. /// - /// Any copy naming 2026-07-28, also inside a comma-joined list, selects it. - /// Serving 2025-11-25 there would skip the header check while a proxy that - /// reads that copy trusts `Mcp-Name`. `McpHeaders::check_against` then - /// refuses the repetition. + /// Any copy naming 2026-07-28 selects it. Serving 2025-11-25 there would + /// skip the header check while a proxy that reads that copy trusts + /// `Mcp-Name`. `McpHeaders::check_against` then refuses the repetition. fn select(values: &[HeaderValue], modern_enabled: bool) -> Self { - // Bytes, since a copy with a non-ASCII byte elsewhere still names it. - let names_2026_07_28 = values - .iter() - .flat_map(|value| value.as_bytes().split(|byte| *byte == b',')) - .any(|version| version.trim_ascii() == b"2026-07-28"); - if names_2026_07_28 && modern_enabled { + if modern_enabled && asks_for_2026_07_28(values) { Self::V2026_07_28 } else { Self::V2025_11_25 } } + /// Whether a request is answered with the 2026-07-28 HTTP status codes: + /// 404 for an unknown method, and JSON-RPC errors for bodies that do not + /// parse as a request. + fn uses_2026_07_28_statuses(self) -> bool { + matches!(self, Self::V2026_07_28) + } + /// Whether clients mirror body fields into `Mcp-Method` and `Mcp-Name`. fn mirrors_body_in_headers(self) -> bool { matches!(self, Self::V2026_07_28) } + + /// Whether each request carries its protocol version and client + /// capabilities in `params._meta`, in place of an `initialize` handshake. + fn uses_per_request_meta(self) -> bool { + matches!(self, Self::V2026_07_28) + } + + /// Whether every result carries `resultType` and the server's identity in + /// `_meta`. + fn adds_result_fields(self) -> bool { + matches!(self, Self::V2026_07_28) + } +} + +/// Checks the `params._meta` fields every 2026-07-28 request must carry: the +/// protocol version, matching its header, and the client capabilities. +/// +/// The typed parse does not keep `_meta`, so this reads the body a second +/// time. Only 2026-07-28 requests pay for it. +fn check_request_meta(body: &[u8], headers: &McpHeaders) -> Result<(), McpRequestError> { + #[derive(Deserialize)] + struct Body { + params: Option, + } + let invalid = |message: String| McpRequestError::InvalidParams(message); + let params = serde_json::from_slice::(body) + .map_err(|e| invalid(format!("could not read params: {e}")))? + .params; + let meta = match params.as_ref().and_then(|params| params.get("_meta")) { + None => return Err(invalid("missing params._meta".into())), + Some(serde_json::Value::Object(meta)) => meta, + Some(_) => return Err(invalid("params._meta must be an object".into())), + }; + + let version_key = "io.modelcontextprotocol/protocolVersion"; + let body_version = match meta.get(version_key) { + None => return Err(invalid(format!("missing {version_key} in params._meta"))), + Some(serde_json::Value::String(version)) => version, + Some(_) => { + return Err(invalid(format!( + "{version_key} in params._meta must be a string" + ))); + } + }; + let header_version = header_text(&headers.protocol_version, "MCP-Protocol-Version")?; + if body_version != header_version { + return Err(McpRequestError::HeaderMismatch(format!( + "MCP-Protocol-Version header value '{header_version}' does not match body value '{body_version}'" + ))); + } + + let capabilities_key = "io.modelcontextprotocol/clientCapabilities"; + match meta.get(capabilities_key) { + None => Err(invalid(format!( + "missing {capabilities_key} in params._meta" + ))), + Some(serde_json::Value::Object(_)) => Ok(()), + Some(_) => Err(invalid(format!( + "{capabilities_key} in params._meta must be an object" + ))), + } +} + +/// The body's `method`, if it is a string. +fn raw_method_name(body: &[u8]) -> Option { + #[derive(Deserialize)] + struct Envelope { + method: String, + } + serde_json::from_slice::(body) + .ok() + .map(|envelope| envelope.method) +} + +/// Whether any `MCP-Protocol-Version` copy names 2026-07-28, also inside a +/// comma-joined list. Whether it is served on it also depends on the flag +/// (see [`ProtocolVersion::select`]). +fn asks_for_2026_07_28<'a>(values: impl IntoIterator) -> bool { + // Bytes, since a copy with a non-ASCII byte elsewhere still names it. + values + .into_iter() + .flat_map(|value| value.as_bytes().split(|byte| *byte == b',')) + .any(|version| version.trim_ascii() == b"2026-07-28") } /// The request headers the Streamable HTTP transport defines, with every @@ -129,7 +213,30 @@ impl McpHeaders { /// Rejects a request whose mirrored headers are missing or disagree with /// the body, so a proxy routing on the headers sees what we execute. - fn check_against(&self, method: &McpMethod) -> Result<(), McpRequestError> { + fn check_against(&self, method: &McpMethod, body: &[u8]) -> Result<(), McpRequestError> { + // The typed parse does not keep an unknown method's name, so it is read + // from the body again. + let method_name = match method { + McpMethod::Unknown => raw_method_name(body), + method => Some(method.to_string()), + }; + let tool_name = match method { + McpMethod::ToolsCall(params) => Some(Some(params.to_string())), + _ => None, + }; + self.check( + method_name.as_deref(), + tool_name.as_ref().map(Option::as_deref), + ) + } + + /// `method` is the body's method, `None` when it is not known. `tool` is + /// set for `tools/call`, to the body's tool name if it has one. + fn check( + &self, + method: Option<&str>, + tool: Option>, + ) -> Result<(), McpRequestError> { let version = header_text(&self.protocol_version, "MCP-Protocol-Version")?; if version != "2026-07-28" { return Err(McpRequestError::HeaderMismatch(format!( @@ -137,18 +244,19 @@ impl McpHeaders { ))); } let header_method = header_text(&self.method, "Mcp-Method")?; - // An unknown method's name is not kept after parsing, and it executes - // nothing, so only the header's presence is checked. - if !matches!(method, McpMethod::Unknown) && header_method != method.to_string() { - return Err(McpRequestError::HeaderMismatch(format!( - "Mcp-Method header value '{header_method}' does not match body value '{method}'" - ))); + if let Some(method) = method { + if header_method != method { + return Err(McpRequestError::HeaderMismatch(format!( + "Mcp-Method header value '{header_method}' does not match body value '{method}'" + ))); + } } - if let McpMethod::ToolsCall(params) = method { + if let Some(tool) = tool { let header_name = decode_mcp_name(header_text(&self.name, "Mcp-Name")?)?; - if header_name != params.to_string() { + if Some(header_name.as_ref()) != tool { return Err(McpRequestError::HeaderMismatch(format!( - "Mcp-Name header value '{header_name}' does not match body value '{params}'" + "Mcp-Name header value '{header_name}' does not match body value '{}'", + tool.unwrap_or_default() ))); } } @@ -215,6 +323,10 @@ enum McpRequestError { Internal(#[from] anyhow::Error), #[error("Header mismatch: {0}")] HeaderMismatch(String), + #[error("Invalid params: {0}")] + InvalidParams(String), + #[error("Invalid request: {0}")] + InvalidRequest(String), } impl McpRequestError { @@ -227,6 +339,8 @@ impl McpRequestError { Self::QueryValidationFailed(_) => error_codes::INVALID_PARAMS, Self::QueryExecutionFailed(_) | Self::Internal(_) => error_codes::INTERNAL_ERROR, Self::HeaderMismatch(_) => error_codes::HEADER_MISMATCH, + Self::InvalidParams(_) => error_codes::INVALID_PARAMS, + Self::InvalidRequest(_) => error_codes::INVALID_REQUEST, } } @@ -240,6 +354,8 @@ impl McpRequestError { Self::QueryExecutionFailed(_) => "ExecutionError", Self::Internal(_) => "InternalError", Self::HeaderMismatch(_) => "HeaderMismatch", + Self::InvalidParams(_) => "InvalidParams", + Self::InvalidRequest(_) => "InvalidRequest", } } } @@ -405,14 +521,14 @@ struct McpResponse { jsonrpc: String, id: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] - result: Option, + result: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option, } impl McpResponse { /// A successful JSON-RPC response carrying `result`. - fn success(id: serde_json::Value, result: McpResult) -> Self { + fn success(id: serde_json::Value, result: McpResultBody) -> Self { Self { jsonrpc: JSONRPC_VERSION.to_string(), id, @@ -441,6 +557,45 @@ enum McpResult { ToolContent(ToolContentResult), } +impl McpResult { + fn into_body(self, protocol: ProtocolVersion, endpoint_type: McpEndpointType) -> McpResultBody { + let common = protocol.adds_result_fields().then(|| CommonResultFields { + result_type: "complete", + meta: ResultMeta { + server_info: server_info(endpoint_type), + }, + }); + McpResultBody { + result: self, + common, + } + } +} + +/// A result as sent. `common` is `None` on 2025-11-25, which keeps that +/// output unchanged. +#[derive(Debug, Serialize)] +struct McpResultBody { + #[serde(flatten)] + result: McpResult, + #[serde(flatten)] + common: Option, +} + +#[derive(Debug, Serialize)] +struct CommonResultFields { + #[serde(rename = "resultType")] + result_type: &'static str, + #[serde(rename = "_meta")] + meta: ResultMeta, +} + +#[derive(Debug, Serialize)] +struct ResultMeta { + #[serde(rename = "io.modelcontextprotocol/serverInfo")] + server_info: ServerInfo, +} + #[derive(Debug, Serialize)] struct InitializeResult { #[serde(rename = "protocolVersion")] @@ -586,14 +741,23 @@ pub async fn handle_mcp_agent( if let Some(resp) = validate_origin(&headers, &allowed_origins) { return resp; } + // Whether a parse failure gets a JSON-RPC answer depends on the flag, which + // only the handler reads. Every other failure keeps its old response. let request = match parse_mcp_request(&headers, &body) { - Ok(request) => request, - Err(response) => return response, + Ok(request) => Ok(request), + Err(failure) + if failure.is_data_error + && asks_for_2026_07_28(headers.get_all("mcp-protocol-version")) => + { + Err(failure) + } + Err(failure) => return failure.rejection, }; handle_mcp_request( client, request, McpHeaders::from_map(&headers), + body, McpEndpointType::Agent, metrics, ) @@ -612,14 +776,23 @@ pub async fn handle_mcp_developer( if let Some(resp) = validate_origin(&headers, &allowed_origins) { return resp; } + // Whether a parse failure gets a JSON-RPC answer depends on the flag, which + // only the handler reads. Every other failure keeps its old response. let request = match parse_mcp_request(&headers, &body) { - Ok(request) => request, - Err(response) => return response, + Ok(request) => Ok(request), + Err(failure) + if failure.is_data_error + && asks_for_2026_07_28(headers.get_all("mcp-protocol-version")) => + { + Err(failure) + } + Err(failure) => return failure.rejection, }; handle_mcp_request( client, request, McpHeaders::from_map(&headers), + body, McpEndpointType::Developer, metrics, ) @@ -632,19 +805,44 @@ pub async fn handle_mcp_developer( /// that carries `params` becomes `McpMethod::Unknown` instead of a 422 (see /// [`as_unknown_method`]). The body arrives through `Bytes`, so the route's /// `DefaultBodyLimit` still applies. -fn parse_mcp_request( - headers: &HeaderMap, - body: &[u8], -) -> Result { +fn parse_mcp_request(headers: &HeaderMap, body: &[u8]) -> Result { if !is_json_content_type(headers) { - return Err(MissingJsonContentType::default().into_response()); + return Err(ParseFailure::other( + MissingJsonContentType::default().into_response(), + )); } match Json::::from_bytes(body) { Ok(Json(request)) => Ok(request), - Err(JsonRejection::JsonDataError(rejection)) => { - as_unknown_method(body).ok_or_else(|| rejection.into_response()) + Err(JsonRejection::JsonDataError(rejection)) => match as_unknown_method(body) { + Some(request) => Ok(request), + None => Err(ParseFailure { + message: rejection.body_text(), + rejection: rejection.into_response(), + is_data_error: true, + }), + }, + Err(rejection) => Err(ParseFailure::other(rejection.into_response())), + } +} + +/// A body axum's `Json` extractor would reject. +#[derive(Debug)] +struct ParseFailure { + /// The response every client got for it before 2026-07-28 support. + rejection: axum::response::Response, + message: String, + /// Valid JSON that does not match a request. Only this kind of failure + /// is answered in JSON-RPC on the 2026-07-28 path. + is_data_error: bool, +} + +impl ParseFailure { + fn other(rejection: axum::response::Response) -> Self { + Self { + rejection, + message: String::new(), + is_data_error: false, } - Err(rejection) => Err(rejection.into_response()), } } @@ -709,13 +907,17 @@ fn validate_origin( async fn handle_mcp_request( mut client: AuthedClient, - request: McpRequest, + request: Result, mcp_headers: McpHeaders, + body: Bytes, endpoint_type: McpEndpointType, metrics: McpMetrics, ) -> impl IntoResponse { let endpoint_label = endpoint_type.as_label(); - let method_label = request.method.to_string(); + let method_label = match &request { + Ok(request) => request.method.to_string(), + Err(_) => "invalid".to_string(), + }; let record_request = |status: McpCallStatus| metrics.record_request(endpoint_label, &method_label, status); @@ -731,12 +933,27 @@ async fn handle_mcp_request( { Ok(catalog) => catalog, Err(_elapsed) => { + // Without the flag we cannot pick the 2026-07-28 path, so a body + // that did not parse keeps the response it always had. + if let Err(failure) = request { + return failure.rejection; + } warn!(endpoint = %endpoint_type, "MCP catalog snapshot timed out"); record_request(McpCallStatus::Timeout); return StatusCode::SERVICE_UNAVAILABLE.into_response(); } }; let dyncfgs = catalog.system_config().dyncfgs(); + let protocol = ProtocolVersion::select( + &mcp_headers.protocol_version, + ENABLE_MCP_PROTOCOL_2026_07_28.get(dyncfgs), + ); + // Outside 2026-07-28, a body that does not parse keeps the response it had + // before, ahead of the endpoint check, as when axum's extractor rejected it. + let request = match request { + Err(failure) if !protocol.uses_2026_07_28_statuses() => return failure.rejection, + request => request, + }; let enabled = match endpoint_type { McpEndpointType::Agent => ENABLE_MCP_AGENT.get(dyncfgs), McpEndpointType::Developer => ENABLE_MCP_DEVELOPER.get(dyncfgs), @@ -760,12 +977,13 @@ async fn handle_mcp_request( // matches `query_tool_enabled` above. let read_data_product_tool_enabled = ENABLE_MCP_AGENT_READ_DATA_PRODUCT_TOOL.get(dyncfgs); let max_response_size = MCP_MAX_RESPONSE_SIZE.get(dyncfgs); - let protocol = ProtocolVersion::select( - &mcp_headers.protocol_version, - ENABLE_MCP_PROTOCOL_2026_07_28.get(dyncfgs), - ); let request_timeout = MCP_REQUEST_TIMEOUT.get(dyncfgs); + let request = match request { + Ok(request) => request, + Err(failure) => return answer_parse_failure(&body, &mcp_headers, failure, &record_request), + }; + // Tag MCP-originated sessions so they're distinguishable in // mz_session_history / mz_statement_execution_history. set_default lets a // caller still override via `?options={"application_name":"..."}` @@ -803,7 +1021,7 @@ async fn handle_mcp_request( // The transport leaves header rules for notification POSTs undefined, so // only requests are checked. if protocol.mirrors_body_in_headers() { - if let Err(err) = mcp_headers.check_against(&request.method) { + if let Err(err) = mcp_headers.check_against(&request.method, &body) { record_request(McpCallStatus::Error(err.error_type())); let id = request.id.clone().unwrap_or(serde_json::Value::Null); let response = McpResponse::error(id, err.into()); @@ -813,6 +1031,24 @@ async fn handle_mcp_request( let request_id = request.id.clone().unwrap_or(serde_json::Value::Null); + if protocol.uses_per_request_meta() { + if let Err(err) = check_request_meta(&body, &mcp_headers) { + record_request(McpCallStatus::Error(err.error_type())); + let response = McpResponse::error(request_id, err.into()); + return (StatusCode::BAD_REQUEST, Json(response)).into_response(); + } + } + + // 2026-07-28 removed `ping` along with the handshake. + if protocol.uses_2026_07_28_statuses() + && matches!(request.method, McpMethod::Unknown | McpMethod::Ping(_)) + { + let err = McpRequestError::MethodNotFound("unknown method".to_string()); + record_request(McpCallStatus::Error(err.error_type())); + let response = McpResponse::error(request_id, err.into()); + return (StatusCode::NOT_FOUND, Json(response)).into_response(); + } + // Spawn task for fault isolation, with a timeout safety net. // `abort_on_drop` propagates the timeout to the task itself; without // it the task orphans and the SQL query keeps running in the @@ -824,6 +1060,7 @@ async fn handle_mcp_request( handle_mcp_request_inner( &mut client, request, + protocol, endpoint_type, query_tool_enabled, read_data_product_tool_enabled, @@ -857,12 +1094,65 @@ async fn handle_mcp_request( }; record_request(status_label); + // Always a single JSON object. The transport lets the server answer with + // JSON or an SSE stream, and we never stream, so `Accept` is not checked. (StatusCode::OK, Json(response)).into_response() } +/// Answers a 2026-07-28 request whose body did not parse, in JSON-RPC: the +/// header and `_meta` checks first, as for any request, then `-32602`. +fn answer_parse_failure( + body: &[u8], + headers: &McpHeaders, + failure: ParseFailure, + record_request: &dyn Fn(McpCallStatus), +) -> axum::response::Response { + #[derive(Deserialize)] + struct Envelope { + id: Option, + method: Option, + params: Option, + } + let Ok(envelope) = serde_json::from_slice::(body) else { + let err = McpRequestError::InvalidRequest("the body is not a JSON object".into()); + record_request(McpCallStatus::Error(err.error_type())); + let response = McpResponse::error(serde_json::Value::Null, err.into()); + return (StatusCode::BAD_REQUEST, Json(response)).into_response(); + }; + let method = envelope.method.as_ref().and_then(|method| method.as_str()); + let tool = (method == Some("tools/call")).then(|| { + envelope + .params + .as_ref() + .and_then(|params| params.get("name")) + .and_then(|name| name.as_str()) + }); + // A notification has no reply, so one that cannot be accepted gets a 400. + let (status, err) = match envelope.id { + None => ( + StatusCode::BAD_REQUEST, + McpRequestError::InvalidParams(failure.message), + ), + Some(_) => match headers + .check(method, tool) + .and_then(|()| check_request_meta(body, headers)) + { + Err(err) => (StatusCode::BAD_REQUEST, err), + Ok(()) => ( + StatusCode::OK, + McpRequestError::InvalidParams(failure.message), + ), + }, + }; + record_request(McpCallStatus::Error(err.error_type())); + let id = envelope.id.unwrap_or(serde_json::Value::Null); + (status, Json(McpResponse::error(id, err.into()))).into_response() +} + async fn handle_mcp_request_inner( client: &mut AuthedClient, request: McpRequest, + protocol: ProtocolVersion, endpoint_type: McpEndpointType, query_tool_enabled: bool, read_data_product_tool_enabled: bool, @@ -886,7 +1176,9 @@ async fn handle_mcp_request_inner( let status_label = call_status(&result); let response = match result { - Ok(result_value) => McpResponse::success(request_id, result_value), + Ok(result_value) => { + McpResponse::success(request_id, result_value.into_body(protocol, endpoint_type)) + } Err(e) => { // Log non-trivial errors if !matches!( @@ -1043,6 +1335,13 @@ fn endpoint_instructions( } } +fn server_info(endpoint_type: McpEndpointType) -> ServerInfo { + ServerInfo { + name: format!("materialize-mcp-{}", endpoint_type), + version: env!("CARGO_PKG_VERSION").to_string(), + } +} + fn handle_initialize( endpoint_type: McpEndpointType, query_tool_enabled: bool, @@ -1051,10 +1350,7 @@ fn handle_initialize( Ok(McpResult::Initialize(InitializeResult { protocol_version: MCP_PROTOCOL_VERSION.to_string(), capabilities: Capabilities { tools: json!({}) }, - server_info: ServerInfo { - name: format!("materialize-mcp-{}", endpoint_type), - version: env!("CARGO_PKG_VERSION").to_string(), - }, + server_info: server_info(endpoint_type), instructions: endpoint_instructions( endpoint_type, query_tool_enabled, @@ -1925,7 +2221,8 @@ mod tests { McpResult::ToolContent(ToolContentResult { content: vec![], is_error: false, - }), + }) + .into_body(ProtocolVersion::V2025_11_25, McpEndpointType::Developer), ); assert_eq!(ok.jsonrpc, JSONRPC_VERSION); assert!(ok.result.is_some()); @@ -2645,7 +2942,20 @@ mod tests { ), ] { let resp = parse_mcp_request(&headers, body).expect_err("must be rejected"); - assert_eq!(resp.status(), want, "for {}", String::from_utf8_lossy(body)); + // Only valid JSON that is not a request is answered in JSON-RPC on + // the 2026-07-28 path. + assert_eq!( + resp.is_data_error, + want == StatusCode::UNPROCESSABLE_ENTITY, + "for {}", + String::from_utf8_lossy(body) + ); + assert_eq!( + resp.rejection.status(), + want, + "for {}", + String::from_utf8_lossy(body) + ); } } @@ -2953,6 +3263,101 @@ mod tests { ); } + #[mz_ore::test] + fn test_request_meta_check() { + let headers = McpHeaders { + protocol_version: vec![HeaderValue::from_static("2026-07-28")], + method: vec![HeaderValue::from_static("tools/list")], + name: vec![], + }; + let check = + |body: &str| check_request_meta(body.as_bytes(), &headers).map_err(|e| e.error_code()); + let with_meta = |meta: &str| { + format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{{"_meta":{meta}}}}}"# + ) + }; + assert_eq!( + check(&with_meta( + r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}"# + )), + Ok(()) + ); + for (meta, want) in [ + ( + r#"{"io.modelcontextprotocol/clientCapabilities":{}}"#, + error_codes::INVALID_PARAMS, + ), + ( + r#"{"io.modelcontextprotocol/protocolVersion":7,"io.modelcontextprotocol/clientCapabilities":{}}"#, + error_codes::INVALID_PARAMS, + ), + ( + r#"{"io.modelcontextprotocol/protocolVersion":"2025-11-25","io.modelcontextprotocol/clientCapabilities":{}}"#, + error_codes::HEADER_MISMATCH, + ), + ( + r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}"#, + error_codes::INVALID_PARAMS, + ), + ( + r#"{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":[]}"#, + error_codes::INVALID_PARAMS, + ), + ] { + assert_eq!(check(&with_meta(meta)), Err(want), "for {meta}"); + } + let message = |body: &str| { + check_request_meta(body.as_bytes(), &headers) + .expect_err("must be rejected") + .to_string() + }; + assert!( + message(&with_meta( + r#"{"io.modelcontextprotocol/protocolVersion":7}"# + )) + .ends_with("must be a string") + ); + assert!(message(&with_meta("[]")).ends_with("params._meta must be an object")); + assert_eq!( + check(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#), + Err(error_codes::INVALID_PARAMS) + ); + } + + fn headers(method: Option<&'static str>, name: Option<&'static str>) -> McpHeaders { + McpHeaders { + protocol_version: vec![HeaderValue::from_static("2026-07-28")], + method: method.into_iter().map(HeaderValue::from_static).collect(), + name: name.into_iter().map(HeaderValue::from_static).collect(), + } + } + + #[mz_ore::test] + fn test_answer_parse_failure_on_non_objects() { + let headers = headers(Some("tools/list"), None); + for body in [&b"[]"[..], b"null", b"42", b"\"text\""] { + let failure = parse_mcp_request(&json_headers(), body).expect_err("not a request"); + let resp = answer_parse_failure(body, &headers, failure, &|_| {}); + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "for {}", + String::from_utf8_lossy(body) + ); + } + } + + #[mz_ore::test] + fn test_header_check_on_raw_names() { + let call = headers(Some("tools/call"), Some("query")); + assert!(call.check(Some("tools/call"), Some(Some("query"))).is_ok()); + // A tools/call whose body has no tool name never matches a header. + assert!(call.check(Some("tools/call"), Some(None)).is_err()); + assert!(call.check(Some("tools/list"), None).is_err()); + assert!(call.check(None, None).is_ok()); + } + #[mz_ore::test] fn test_header_check_rejects_non_ascii() { let headers = McpHeaders { @@ -2965,7 +3370,7 @@ mod tests { .expect("valid request") .method; let err = headers - .check_against(&method) + .check_against(&method, request.as_bytes()) .expect_err("non-ASCII Mcp-Method"); assert_eq!(err.error_code(), error_codes::HEADER_MISMATCH); } diff --git a/src/environmentd/tests/testdata/mcp/developer b/src/environmentd/tests/testdata/mcp/developer index 2d67de2303233..cd2a0693d164b 100644 --- a/src/environmentd/tests/testdata/mcp/developer +++ b/src/environmentd/tests/testdata/mcp/developer @@ -199,6 +199,14 @@ MCP-Protocol-Version: 2026-07-28 200 OK {"jsonrpc":"2.0","id":43,"result":{"content":[{"type":"text","text":"[\n [\"materialize\"]\n]"}],"isError":false}} +# With the flag off, a 2026-07-28 body that does not parse keeps the old 422. +mcp-developer +MCP-Protocol-Version: 2026-07-28 +{"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"nonexistent_tool","arguments":{}}} +---- +422 Unprocessable Entity +Failed to deserialize the JSON body into the target type: .: unknown variant `nonexistent_tool`, expected one of `get_data_products`, `get_data_product_details`, `read_data_product`, `query`, `query_system_catalog` at line 1 column 99 + # An unknown notification with params is still a notification. mcp-developer {"jsonrpc":"2.0","method":"notifications/other","params":{}} diff --git a/src/environmentd/tests/testdata/mcp/developer_2026_07_28 b/src/environmentd/tests/testdata/mcp/developer_2026_07_28 index e5c4920328156..dea9410641d0a 100644 --- a/src/environmentd/tests/testdata/mcp/developer_2026_07_28 +++ b/src/environmentd/tests/testdata/mcp/developer_2026_07_28 @@ -18,10 +18,10 @@ mcp-developer MCP-Protocol-Version: 2026-07-28 Mcp-Method: tools/call Mcp-Name: query_system_catalog -{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"query_system_catalog","arguments":{"sql_query":"SELECT name FROM mz_databases WHERE name = 'materialize'"}}} +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"query_system_catalog","arguments":{"sql_query":"SELECT name FROM mz_databases WHERE name = 'materialize'"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}} ---- 200 OK -{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"[\n [\"materialize\"]\n]"}],"isError":false}} +{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"[\n [\"materialize\"]\n]"}],"isError":false,"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"materialize-mcp-developer","version":""}}}} # Names that are not plain ASCII arrive Base64 encoded, and any client may use # the encoded form. @@ -29,19 +29,19 @@ mcp-developer MCP-Protocol-Version: 2026-07-28 Mcp-Method: tools/call Mcp-Name: =?base64?cXVlcnlfc3lzdGVtX2NhdGFsb2c=?= -{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"query_system_catalog","arguments":{"sql_query":"SELECT name FROM mz_databases WHERE name = 'materialize'"}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"query_system_catalog","arguments":{"sql_query":"SELECT name FROM mz_databases WHERE name = 'materialize'"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}} ---- 200 OK -{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"[\n [\"materialize\"]\n]"}],"isError":false}} +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"[\n [\"materialize\"]\n]"}],"isError":false,"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"materialize-mcp-developer","version":""}}}} # Mcp-Name is only required on tools/call. mcp-developer MCP-Protocol-Version: 2026-07-28 Mcp-Method: tools/list -{"jsonrpc":"2.0","id":27,"method":"tools/list"} +{"jsonrpc":"2.0","id":27,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}} ---- 200 OK -{"jsonrpc":"2.0","id":27,"result":{"tools":[{"name":"query_system_catalog","title":"Query System Catalog","description":"Query Materialize system catalog tables for troubleshooting and observability. Only mz_*, pg_catalog, and information_schema tables are accessible. Use the mz_internal.mz_ontology_* tables to discover tables, columns, and join paths before writing queries. Response limit: 1.0 MB.","inputSchema":{"type":"object","properties":{"sql_query":{"type":"string","description":"PostgreSQL-compatible SELECT, SHOW, or EXPLAIN query referencing mz_*, pg_catalog, or information_schema tables"}},"required":["sql_query"]},"annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}},{"name":"query","title":"Query","description":"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`. Response limit: 1.0 MB.","inputSchema":{"type":"object","properties":{"cluster":{"type":"string","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."},"cluster_replica":{"type":"string","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."},"sql_query":{"type":"string","description":"PostgreSQL-compatible SELECT, SHOW, or EXPLAIN statement. Multi-statement queries are rejected."}},"required":["cluster","sql_query"]},"annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}]}} +{"jsonrpc":"2.0","id":27,"result":{"tools":[{"name":"query_system_catalog","title":"Query System Catalog","description":"Query Materialize system catalog tables for troubleshooting and observability. Only mz_*, pg_catalog, and information_schema tables are accessible. Use the mz_internal.mz_ontology_* tables to discover tables, columns, and join paths before writing queries. Response limit: 1.0 MB.","inputSchema":{"type":"object","properties":{"sql_query":{"type":"string","description":"PostgreSQL-compatible SELECT, SHOW, or EXPLAIN query referencing mz_*, pg_catalog, or information_schema tables"}},"required":["sql_query"]},"annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}},{"name":"query","title":"Query","description":"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`. Response limit: 1.0 MB.","inputSchema":{"type":"object","properties":{"cluster":{"type":"string","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."},"cluster_replica":{"type":"string","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."},"sql_query":{"type":"string","description":"PostgreSQL-compatible SELECT, SHOW, or EXPLAIN statement. Multi-statement queries are rejected."}},"required":["cluster","sql_query"]},"annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}],"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"materialize-mcp-developer","version":""}}}} mcp-developer MCP-Protocol-Version: 2026-07-28 @@ -146,15 +146,33 @@ Mcp-Method: tools/list 400 Bad Request {"jsonrpc":"2.0","id":26,"error":{"code":-32020,"message":"Header mismatch: MCP-Protocol-Version header value '2025-11-25, 2026-07-28' is not '2026-07-28'","data":{"error_type":"HeaderMismatch"}}} -# An unknown method only needs Mcp-Method to be present. +# An unknown method with a matching Mcp-Method gets a 404. mcp-developer MCP-Protocol-Version: 2026-07-28 Mcp-Method: server/other -{"jsonrpc":"2.0","id":9,"method":"server/other","params":{}} +{"jsonrpc":"2.0","id":9,"method":"server/other","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}} ---- -200 OK +404 Not Found {"jsonrpc":"2.0","id":9,"error":{"code":-32601,"message":"Method not found: unknown method","data":{"error_type":"MethodNotFound"}}} +# An unknown method's Mcp-Method must still match the body. +mcp-developer +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: tools/list +{"jsonrpc":"2.0","id":21,"method":"server/other","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}} +---- +400 Bad Request +{"jsonrpc":"2.0","id":21,"error":{"code":-32020,"message":"Header mismatch: Mcp-Method header value 'tools/list' does not match body value 'server/other'","data":{"error_type":"HeaderMismatch"}}} + +# 2026-07-28 removed ping. +mcp-developer +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: ping +{"jsonrpc":"2.0","id":15,"method":"ping","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}} +---- +404 Not Found +{"jsonrpc":"2.0","id":15,"error":{"code":-32601,"message":"Method not found: unknown method","data":{"error_type":"MethodNotFound"}}} + mcp-developer MCP-Protocol-Version: 2026-07-28 {"jsonrpc":"2.0","id":10,"method":"server/other","params":{}} @@ -162,6 +180,101 @@ MCP-Protocol-Version: 2026-07-28 400 Bad Request {"jsonrpc":"2.0","id":10,"error":{"code":-32020,"message":"Header mismatch: missing Mcp-Method header","data":{"error_type":"HeaderMismatch"}}} +# ============================================================================= +# params._meta must carry the protocol version and client capabilities +# ============================================================================= + +mcp-developer +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: tools/list +{"jsonrpc":"2.0","id":11,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/clientCapabilities":{}}}} +---- +400 Bad Request +{"jsonrpc":"2.0","id":11,"error":{"code":-32602,"message":"Invalid params: missing io.modelcontextprotocol/protocolVersion in params._meta","data":{"error_type":"InvalidParams"}}} + +mcp-developer +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: tools/list +{"jsonrpc":"2.0","id":12,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2025-11-25","io.modelcontextprotocol/clientCapabilities":{}}}} +---- +400 Bad Request +{"jsonrpc":"2.0","id":12,"error":{"code":-32020,"message":"Header mismatch: MCP-Protocol-Version header value '2026-07-28' does not match body value '2025-11-25'","data":{"error_type":"HeaderMismatch"}}} + +mcp-developer +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: tools/list +{"jsonrpc":"2.0","id":13,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}} +---- +400 Bad Request +{"jsonrpc":"2.0","id":13,"error":{"code":-32602,"message":"Invalid params: missing io.modelcontextprotocol/clientCapabilities in params._meta","data":{"error_type":"InvalidParams"}}} + +# A request with no params at all has no _meta. +mcp-developer +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: tools/list +{"jsonrpc":"2.0","id":14,"method":"tools/list"} +---- +400 Bad Request +{"jsonrpc":"2.0","id":14,"error":{"code":-32602,"message":"Invalid params: missing params._meta","data":{"error_type":"InvalidParams"}}} + +# Notifications are not requests, so they carry no required _meta. +mcp-developer +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: notifications/initialized +{"jsonrpc":"2.0","method":"notifications/initialized"} +---- +202 Accepted + +# ============================================================================= +# Bodies that do not parse as a request get a JSON-RPC error +# ============================================================================= + +# An unknown tool or bad arguments get -32602 in a normal response. +mcp-developer +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: tools/call +Mcp-Name: nonexistent_tool +{"jsonrpc":"2.0","id":16,"method":"tools/call","params":{"name":"nonexistent_tool","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}} +---- +200 OK +{"jsonrpc":"2.0","id":16,"error":{"code":-32602,"message":"Invalid params: Failed to deserialize the JSON body into the target type: .: unknown variant `nonexistent_tool`, expected one of `get_data_products`, `get_data_product_details`, `read_data_product`, `query`, `query_system_catalog` at line 1 column 212","data":{"error_type":"InvalidParams"}}} + +mcp-developer +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: tools/call +Mcp-Name: query_system_catalog +{"jsonrpc":"2.0","id":18,"method":"tools/call","params":{"name":"query_system_catalog","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}} +---- +200 OK +{"jsonrpc":"2.0","id":18,"error":{"code":-32602,"message":"Invalid params: Failed to deserialize the JSON body into the target type: .: missing field `sql_query` at line 1 column 216","data":{"error_type":"InvalidParams"}}} + +# The header and _meta checks still come first. +mcp-developer +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: tools/call +Mcp-Name: query +{"jsonrpc":"2.0","id":17,"method":"tools/call","params":{"name":"nonexistent_tool","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}} +---- +400 Bad Request +{"jsonrpc":"2.0","id":17,"error":{"code":-32020,"message":"Header mismatch: Mcp-Name header value 'query' does not match body value 'nonexistent_tool'","data":{"error_type":"HeaderMismatch"}}} + +mcp-developer +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: tools/call +Mcp-Name: nonexistent_tool +{"jsonrpc":"2.0","id":19,"method":"tools/call","params":{"name":"nonexistent_tool","arguments":{}}} +---- +400 Bad Request +{"jsonrpc":"2.0","id":19,"error":{"code":-32602,"message":"Invalid params: missing params._meta","data":{"error_type":"InvalidParams"}}} + +# A notification that cannot be accepted gets a 400, since it has no reply. +mcp-developer +MCP-Protocol-Version: 2026-07-28 +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"nonexistent_tool"}} +---- +400 Bad Request +{"jsonrpc":"2.0","id":null,"error":{"code":-32602,"message":"Invalid params: Failed to deserialize the JSON body into the target type: .: unknown variant `nonexistent_tool`, expected one of `get_data_products`, `get_data_product_details`, `read_data_product`, `query`, `query_system_catalog` at line 1 column 76","data":{"error_type":"InvalidParams"}}} + # ============================================================================= # Earlier revisions are never checked # =============================================================================