From 001de6ff52b5cddaa7e855c0909da656aef026d5 Mon Sep 17 00:00:00 2001 From: Shiv Rossi Date: Mon, 28 Sep 2026 17:36:01 -0600 Subject: [PATCH] feat: add stateless MCP Streamable HTTP transport --- Cargo.lock | 39 ++ Cargo.toml | 3 + README.md | 53 ++ crates/hydra-mcp-http/Cargo.toml | 20 + crates/hydra-mcp-http/src/lib.rs | 646 ++++++++++++++++++ crates/hydra-mcp-http/tests/transport.rs | 327 +++++++++ examples/notes/Cargo.toml | 1 + examples/notes/src/bin/notes-mcp-http.rs | 6 + examples/notes/src/lib.rs | 138 ++-- examples/notes/tests/mcp_http.rs | 126 ++++ .../changes/add-mcp-streamable-http/tasks.md | 30 +- 11 files changed, 1334 insertions(+), 55 deletions(-) create mode 100644 crates/hydra-mcp-http/Cargo.toml create mode 100644 crates/hydra-mcp-http/src/lib.rs create mode 100644 crates/hydra-mcp-http/tests/transport.rs create mode 100644 examples/notes/src/bin/notes-mcp-http.rs create mode 100644 examples/notes/tests/mcp_http.rs diff --git a/Cargo.lock b/Cargo.lock index a2b9f66..ed0ae18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.13.1" @@ -346,6 +352,18 @@ dependencies = [ "serde_yaml", ] +[[package]] +name = "hydra-mcp-http" +version = "0.3.0" +dependencies = [ + "axum", + "base64", + "serde_json", + "thiserror", + "tokio", + "tower", +] + [[package]] name = "hydra-mcp-stdio" version = "0.3.0" @@ -475,6 +493,7 @@ dependencies = [ "anyhow", "axum", "clap", + "hydra-mcp-http", "hydra-mcp-stdio", "serde", "serde_json", @@ -758,6 +777,26 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio" version = "1.53.1" diff --git a/Cargo.toml b/Cargo.toml index 8f71c9e..c8e98a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/hydra-core", + "crates/hydra-mcp-http", "crates/hydra-mcp-stdio", "crates/hydra-codegen", "examples/notes", @@ -31,12 +32,14 @@ missing_panics_doc = "allow" [workspace.dependencies] hydra-core = { path = "crates/hydra-core" } +hydra-mcp-http = { path = "crates/hydra-mcp-http" } hydra-mcp-stdio = { path = "crates/hydra-mcp-stdio" } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" anyhow = "1" +base64 = "0.22" thiserror = "2" tokio = { version = "1", features = ["full"] } clap = { version = "4", features = ["derive"] } diff --git a/README.md b/README.md index b46e24a..690a8fc 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,10 @@ inference — everything is declared**. - `hydra-mcp-stdio` — a minimal reusable MCP stdio server (JSON-RPC 2.0, newline-delimited) you hand your generated tool schemas and one dispatch closure. +- `hydra-mcp-http` — a reusable, stateless MCP Streamable HTTP server for the + `2026-07-28` profile. It validates explicit per-request metadata, mirrored + method/name headers, and an explicit Origin policy, then calls one + consumer-owned async dispatch closure. - `examples/notes` — a complete four-surface project. Copy it as a template. - `examples/ts-client` — a TypeScript-only Iris consumer fixture with `tsc --noEmit` coverage. @@ -81,6 +85,55 @@ cargo run -p hydra-codegen -- check # CI guard: fails if artifacts are stale 4. `include!` the generated files, implement one dispatch function, and wire your binaries. See `examples/notes/src/lib.rs`. +### Stateless MCP Streamable HTTP + +`hydra-mcp-http` is an embedded-host runtime, not a standalone service. The +host owns the listener and nests the returned router at an explicit path. The +reference Notes consumer mounts it at `/mcp`: + +```bash +cargo run -p notes-example --bin notes-mcp-http +``` + +The Notes binary binds only to `127.0.0.1:8942` and prints the local endpoint. +A current-protocol client sends one JSON-RPC request per POST with: + +- `Content-Type: application/json`; +- an `Accept` value explicitly containing both `application/json` and + `text/event-stream`; +- `MCP-Protocol-Version: 2026-07-28`; +- `Mcp-Method` matching the JSON-RPC method; and +- `params._meta` containing + `io.modelcontextprotocol/protocolVersion` and + `io.modelcontextprotocol/clientCapabilities`. + +`tools/call` additionally mirrors `params.name` through `Mcp-Name`. Names that +cannot be represented as a safe plain header use the MCP Base64 sentinel form +`=?base64?{value}?=`. The runtime validates the generated tool manifest at +construction, preserves its exact order and objects for `tools/list`, and +rejects unsupported `x-mcp-header` annotations rather than advertising a +contract it cannot validate. + +The default `OriginPolicy::RejectPresented` permits non-browser clients that +omit `Origin` and rejects every presented Origin. Hosts that deliberately need +browser-origin validation must provide a finite `OriginPolicy::AllowExact` +list and own any separately reviewed CORS or Private Network Access layer. +This runtime emits no CORS/PNA headers, handles no `OPTIONS` preflights, does +not create sessions or `Mcp-Session-Id` headers, has no GET stream, and does +not downgrade to the legacy `initialize` protocol. It also never binds a +listener, chooses a mount path, or implements consumer operation semantics. + +The reproducible reference checks are: + +```bash +cargo test --locked -p hydra-mcp-http +cargo test --locked -p notes-example --test mcp_http +``` + +The second test starts a real ephemeral loopback listener and uses an +independent HTTP client fixture for `server/discover` → `tools/list` → +`tools/call`; it is not a `Router::oneshot`-only test. + ### JSON batch operations Hydra v0.2.0 can project a declared `json` body parameter to HTTP, MCP, and diff --git a/crates/hydra-mcp-http/Cargo.toml b/crates/hydra-mcp-http/Cargo.toml new file mode 100644 index 0000000..dfe57f5 --- /dev/null +++ b/crates/hydra-mcp-http/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "hydra-mcp-http" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Stateless MCP Streamable HTTP runtime for Hydra-generated tools" + +[lints] +workspace = true + +[dependencies] +axum = { workspace = true } +base64 = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +tower = { version = "0.5", features = ["util"] } diff --git a/crates/hydra-mcp-http/src/lib.rs b/crates/hydra-mcp-http/src/lib.rs new file mode 100644 index 0000000..72159fa --- /dev/null +++ b/crates/hydra-mcp-http/src/lib.rs @@ -0,0 +1,646 @@ +//! Stateless MCP Streamable HTTP transport for Hydra-generated tools. +//! +//! The runtime owns only the current MCP HTTP/JSON-RPC protocol boundary. It +//! accepts a validated generated tool manifest and one consumer-owned async +//! dispatch closure; it never infers an endpoint path, operation semantics, +//! parameter locations, authentication policy, or host lifecycle. + +use std::{collections::BTreeSet, future::Future, pin::Pin, sync::Arc}; + +use axum::{ + Json, Router, + body::{Body, to_bytes}, + extract::State, + http::{ + HeaderMap, Request, StatusCode, + header::{ACCEPT, CONTENT_TYPE, ORIGIN}, + }, + response::{IntoResponse, Response}, + routing::post, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde_json::{Map, Value, json}; +use thiserror::Error; + +/// MCP protocol revision implemented by this transport. +pub const PROTOCOL_VERSION: &str = "2026-07-28"; + +const MAX_REQUEST_BYTES: usize = 8 * 1024 * 1024; +const HEADER_MISMATCH_CODE: i64 = -32020; +const UNSUPPORTED_PROTOCOL_VERSION_CODE: i64 = -32022; +const BASE64_SENTINEL_PREFIX: &str = "=?base64?"; +const BASE64_SENTINEL_SUFFIX: &str = "?="; + +/// Origin handling is deliberately explicit and secure by default. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub enum OriginPolicy { + /// Allow ordinary non-browser clients without an Origin header, but reject + /// every request that presents one. + #[default] + RejectPresented, + /// Allow only the exact literal origins in this finite list. This is + /// inbound validation, not a CORS or Private Network Access policy. + AllowExact(Vec), +} + +/// Configuration owned by the embedding host. +#[derive(Clone, Debug)] +pub struct ServerConfig { + /// Human-readable server name returned by `server/discover`. + pub server_name: String, + /// Server version returned by `server/discover`. + pub server_version: String, + /// Bare generated tool array or the generated `{"tools": [...]}` value. + pub tools: Value, + /// Explicit inbound Origin policy. + pub origin_policy: OriginPolicy, +} + +impl ServerConfig { + /// Construct an HTTP runtime configuration. + #[must_use] + pub fn new( + server_name: impl Into, + server_version: impl Into, + tools: Value, + origin_policy: OriginPolicy, + ) -> Self { + Self { + server_name: server_name.into(), + server_version: server_version.into(), + tools, + origin_policy, + } + } +} + +/// Errors found before an HTTP endpoint is exposed. +#[derive(Debug, Error, Eq, PartialEq)] +pub enum ConfigurationError { + /// The generated manifest is not a supported tool manifest. + #[error("invalid MCP tools manifest: {0}")] + InvalidManifest(String), + /// The explicit Origin policy contains an unsafe or duplicate entry. + #[error("invalid MCP Origin policy: {0}")] + InvalidOriginPolicy(String), +} + +type DispatchFuture = Pin> + Send + 'static>>; +type Dispatch = dyn Fn(String, Value) -> DispatchFuture + Send + Sync + 'static; + +struct ServerState { + server_name: String, + server_version: String, + tools: Vec, + tool_names: BTreeSet, + origin_policy: OriginPolicy, + dispatch: Arc, +} + +/// Build a reusable Axum router for the stateless MCP HTTP server profile. +/// +/// The returned router serves one POST endpoint at `/`. The embedding host +/// chooses the listener and may nest it at an explicit path such as `/mcp`. +/// +/// # Errors +/// +/// Returns [`ConfigurationError`] when the generated manifest or Origin policy +/// cannot be safely represented by this transport. +pub fn router(config: ServerConfig, dispatch: F) -> Result +where + F: Fn(String, Value) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, +{ + validate_origin_policy(&config.origin_policy)?; + let (tools, tool_names) = normalize_tools(config.tools)?; + let dispatch: Arc = + Arc::new(move |name, arguments| Box::pin(dispatch(name, arguments))); + let state = Arc::new(ServerState { + server_name: config.server_name, + server_version: config.server_version, + tools, + tool_names, + origin_policy: config.origin_policy, + dispatch, + }); + + Ok(Router::new() + .route("/", post(handle_request)) + .with_state(state)) +} + +async fn handle_request(State(state): State>, request: Request) -> Response { + let headers = request.headers().clone(); + if !origin_allowed(&headers, &state.origin_policy) { + return rpc_error( + Value::Null, + StatusCode::FORBIDDEN, + -32600, + "Origin rejected", + None, + ); + } + if !has_json_content_type(&headers) { + return rpc_error( + Value::Null, + StatusCode::UNSUPPORTED_MEDIA_TYPE, + -32600, + "Content-Type must be application/json", + None, + ); + } + if !accepts_required_media_types(&headers) { + return rpc_error( + Value::Null, + StatusCode::NOT_ACCEPTABLE, + -32600, + "Accept must include application/json and text/event-stream", + None, + ); + } + + let Ok(body) = to_bytes(request.into_body(), MAX_REQUEST_BYTES).await else { + return rpc_error( + Value::Null, + StatusCode::PAYLOAD_TOO_LARGE, + -32600, + "Request body is too large", + None, + ); + }; + let Ok(value) = serde_json::from_slice::(&body) else { + return rpc_error( + Value::Null, + StatusCode::BAD_REQUEST, + -32600, + "Invalid JSON-RPC request", + None, + ); + }; + process_request(&state, &headers, value).await +} + +async fn process_request(state: &ServerState, headers: &HeaderMap, request: Value) -> Response { + let parsed = match parse_request(&request) { + Ok(parsed) => parsed, + Err(error) => { + return rpc_error( + error.id, + StatusCode::BAD_REQUEST, + -32600, + error.message, + None, + ); + } + }; + let ParsedRequest { + id, + method, + params, + body_version, + } = parsed; + let Some(header_version) = plain_header(headers, "MCP-Protocol-Version") else { + return header_mismatch(id); + }; + if header_version != body_version { + return header_mismatch(id); + } + let Some(header_method) = plain_header(headers, "Mcp-Method") else { + return header_mismatch(id); + }; + if header_method != method { + return header_mismatch(id); + } + if body_version != PROTOCOL_VERSION { + return rpc_error( + id, + StatusCode::BAD_REQUEST, + UNSUPPORTED_PROTOCOL_VERSION_CODE, + "UnsupportedProtocolVersionError", + Some(json!({ "supportedVersions": [PROTOCOL_VERSION] })), + ); + } + + match method { + "server/discover" => { + let result = json!({ + "resultType": "complete", + "supportedVersions": [PROTOCOL_VERSION], + "capabilities": { "tools": {} }, + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": state.server_name, + "version": state.server_version, + } + } + }); + success_response(id, result) + } + "tools/list" => success_response( + id, + json!({ "resultType": "complete", "tools": state.tools }), + ), + "tools/call" => handle_tool_call(state, headers, id, params).await, + _ => rpc_error( + id, + StatusCode::NOT_FOUND, + -32601, + "Method not found for protocol 2026-07-28", + None, + ), + } +} + +struct ParsedRequest<'a> { + id: Value, + method: &'a str, + params: &'a Map, + body_version: &'a str, +} + +struct RequestError { + id: Value, + message: &'static str, +} + +fn parse_request(request: &Value) -> Result, RequestError> { + let Some(object) = request.as_object() else { + return Err(RequestError { + id: Value::Null, + message: "Invalid JSON-RPC request", + }); + }; + let raw_id = object.get("id"); + let id = raw_id + .filter(|value| is_valid_request_id(value)) + .cloned() + .unwrap_or(Value::Null); + if raw_id.is_none_or(|value| !is_valid_request_id(value)) + || object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") + { + return Err(RequestError { + id, + message: "Invalid JSON-RPC request", + }); + } + let Some(method) = object.get("method").and_then(Value::as_str) else { + return Err(RequestError { + id, + message: "Invalid JSON-RPC request", + }); + }; + if method.chars().any(char::is_control) { + return Err(RequestError { + id, + message: "Invalid JSON-RPC request", + }); + } + let Some(params) = object.get("params").and_then(Value::as_object) else { + return Err(RequestError { + id, + message: "Request metadata is required", + }); + }; + let Some(metadata) = params.get("_meta").and_then(Value::as_object) else { + return Err(RequestError { + id, + message: "Request metadata is required", + }); + }; + let Some(body_version) = metadata + .get("io.modelcontextprotocol/protocolVersion") + .and_then(Value::as_str) + else { + return Err(RequestError { + id, + message: "Request protocol metadata is required", + }); + }; + if !metadata + .get("io.modelcontextprotocol/clientCapabilities") + .is_some_and(Value::is_object) + { + return Err(RequestError { + id, + message: "Request client capabilities are required", + }); + } + Ok(ParsedRequest { + id, + method, + params, + body_version, + }) +} + +async fn handle_tool_call( + state: &ServerState, + headers: &HeaderMap, + id: Value, + params: &serde_json::Map, +) -> Response { + let Some(name) = params.get("name").and_then(Value::as_str) else { + return rpc_error( + id, + StatusCode::BAD_REQUEST, + -32602, + "tools/call requires a tool name", + None, + ); + }; + if name.is_empty() || name.chars().any(char::is_control) { + return rpc_error( + id, + StatusCode::BAD_REQUEST, + -32602, + "tools/call has an invalid tool name", + None, + ); + } + let Some(header_name) = encoded_or_plain_header(headers, "Mcp-Name") else { + return header_mismatch(id); + }; + if header_name != name { + return header_mismatch(id); + } + if !state.tool_names.contains(name) { + return rpc_error( + id, + StatusCode::BAD_REQUEST, + -32602, + "tools/call names an undeclared tool", + None, + ); + } + let arguments = match params.get("arguments") { + None => json!({}), + Some(value) if value.is_object() => value.clone(), + Some(_) => { + return rpc_error( + id, + StatusCode::BAD_REQUEST, + -32602, + "tools/call arguments must be an object", + None, + ); + } + }; + + match (state.dispatch)(name.to_owned(), arguments).await { + Ok(value) => success_response( + id, + json!({ + "resultType": "complete", + "structuredContent": value, + "content": [{ "type": "text", "text": value.to_string() }] + }), + ), + Err(message) => success_response( + id, + json!({ + "resultType": "complete", + "isError": true, + "content": [{ "type": "text", "text": message }] + }), + ), + } +} + +fn normalize_tools(tools: Value) -> Result<(Vec, BTreeSet), ConfigurationError> { + let tools = match tools { + Value::Array(tools) => tools, + Value::Object(object) => object + .get("tools") + .and_then(Value::as_array) + .cloned() + .ok_or_else(|| { + ConfigurationError::InvalidManifest( + "manifest wrapper must contain a tools array".to_owned(), + ) + })?, + _ => { + return Err(ConfigurationError::InvalidManifest( + "manifest must be a tools array or object wrapper".to_owned(), + )); + } + }; + + let mut names = BTreeSet::new(); + for (index, tool) in tools.iter().enumerate() { + let Some(object) = tool.as_object() else { + return Err(ConfigurationError::InvalidManifest(format!( + "tool {index} must be an object" + ))); + }; + let Some(name) = object.get("name").and_then(Value::as_str) else { + return Err(ConfigurationError::InvalidManifest(format!( + "tool {index} must have a string name" + ))); + }; + if name.is_empty() || name.chars().any(char::is_control) { + return Err(ConfigurationError::InvalidManifest(format!( + "tool {index} has an invalid name" + ))); + } + if !names.insert(name.to_owned()) { + return Err(ConfigurationError::InvalidManifest(format!( + "tool name is duplicated: {name}" + ))); + } + let Some(schema) = object.get("inputSchema") else { + return Err(ConfigurationError::InvalidManifest(format!( + "tool {name} is missing inputSchema" + ))); + }; + if !schema.is_object() { + return Err(ConfigurationError::InvalidManifest(format!( + "tool {name} inputSchema must be an object" + ))); + } + if contains_key(schema, "x-mcp-header") { + return Err(ConfigurationError::InvalidManifest(format!( + "tool {name} uses unsupported x-mcp-header" + ))); + } + } + Ok((tools, names)) +} + +fn contains_key(value: &Value, wanted: &str) -> bool { + match value { + Value::Object(object) => object + .iter() + .any(|(key, value)| key == wanted || contains_key(value, wanted)), + Value::Array(values) => values.iter().any(|value| contains_key(value, wanted)), + _ => false, + } +} + +fn validate_origin_policy(policy: &OriginPolicy) -> Result<(), ConfigurationError> { + let OriginPolicy::AllowExact(origins) = policy else { + return Ok(()); + }; + let mut seen = BTreeSet::new(); + for origin in origins { + if origin.is_empty() + || origin.chars().any(char::is_control) + || origin.trim() != origin + || !seen.insert(origin) + { + return Err(ConfigurationError::InvalidOriginPolicy( + "origins must be non-empty, unique, and free of control or surrounding whitespace" + .to_owned(), + )); + } + } + Ok(()) +} + +fn origin_allowed(headers: &HeaderMap, policy: &OriginPolicy) -> bool { + let Some(origin_header) = headers.get(ORIGIN) else { + return true; + }; + let Ok(origin) = origin_header.to_str() else { + return false; + }; + match policy { + OriginPolicy::RejectPresented => false, + OriginPolicy::AllowExact(origins) => origins.iter().any(|allowed| allowed == origin), + } +} + +fn has_json_content_type(headers: &HeaderMap) -> bool { + headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .is_some_and(|value| value.trim().eq_ignore_ascii_case("application/json")) +} + +fn accepts_required_media_types(headers: &HeaderMap) -> bool { + let accepted: BTreeSet = headers + .get_all(ACCEPT) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .filter_map(|value| value.trim().split(';').next()) + .map(|value| value.trim().to_ascii_lowercase()) + .collect(); + accepted.contains("application/json") && accepted.contains("text/event-stream") +} + +fn plain_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name).and_then(|value| value.to_str().ok()) +} + +fn encoded_or_plain_header(headers: &HeaderMap, name: &str) -> Option { + let value = plain_header(headers, name)?; + if value.starts_with(BASE64_SENTINEL_PREFIX) || value.ends_with(BASE64_SENTINEL_SUFFIX) { + if !(value.starts_with(BASE64_SENTINEL_PREFIX) && value.ends_with(BASE64_SENTINEL_SUFFIX)) { + return None; + } + let encoded = value + .strip_prefix(BASE64_SENTINEL_PREFIX)? + .strip_suffix(BASE64_SENTINEL_SUFFIX)?; + let decoded = STANDARD.decode(encoded).ok()?; + let decoded = String::from_utf8(decoded).ok()?; + if decoded.is_empty() || decoded.chars().any(char::is_control) { + return None; + } + Some(decoded) + } else if value.is_empty() || value.trim() != value || value.chars().any(char::is_control) { + None + } else { + Some(value.to_owned()) + } +} + +fn is_valid_request_id(id: &Value) -> bool { + id.is_string() || id.is_number() +} + +fn header_mismatch(id: Value) -> Response { + rpc_error( + id, + StatusCode::BAD_REQUEST, + HEADER_MISMATCH_CODE, + "HeaderMismatch", + None, + ) +} + +fn success_response(id: Value, result: Value) -> Response { + let mut body = Map::new(); + body.insert("jsonrpc".to_owned(), json!("2.0")); + body.insert("id".to_owned(), id); + body.insert("result".to_owned(), result); + response(StatusCode::OK, Value::Object(body)) +} + +fn rpc_error( + id: Value, + status: StatusCode, + code: i64, + message: &str, + data: Option, +) -> Response { + let mut error = Map::new(); + error.insert("code".to_owned(), json!(code)); + error.insert("message".to_owned(), json!(message)); + if let Some(data) = data { + error.insert("data".to_owned(), data); + } + let mut body = Map::new(); + body.insert("jsonrpc".to_owned(), json!("2.0")); + body.insert("id".to_owned(), id); + body.insert("error".to_owned(), Value::Object(error)); + response(status, Value::Object(body)) +} + +fn response(status: StatusCode, body: Value) -> Response { + (status, Json(body)).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_validation_preserves_tool_order() { + let manifest = json!({ + "tools": [ + {"name": "first", "inputSchema": {"type": "object"}}, + {"name": "second", "inputSchema": {"type": "object"}} + ] + }); + let (tools, names) = normalize_tools(manifest).unwrap(); + assert_eq!(tools[0]["name"], "first"); + assert!(names.contains("second")); + } + + #[test] + fn nested_header_annotation_is_rejected() { + let manifest = json!({ + "tools": [{ + "name": "secret", + "inputSchema": {"properties": {"nested": {"x-mcp-header": "X-Key"}}} + }] + }); + let error = normalize_tools(manifest).unwrap_err().to_string(); + assert!(error.contains("secret")); + assert!(error.contains("x-mcp-header")); + } + + #[test] + fn sentinel_name_decodes_utf8() { + let encoded = STANDARD.encode("世界"); + let headers = HeaderMap::from_iter([( + "Mcp-Name".parse().unwrap(), + format!("=?base64?{encoded}?=").parse().unwrap(), + )]); + assert_eq!( + encoded_or_plain_header(&headers, "Mcp-Name").as_deref(), + Some("世界") + ); + } +} diff --git a/crates/hydra-mcp-http/tests/transport.rs b/crates/hydra-mcp-http/tests/transport.rs new file mode 100644 index 0000000..ce61e77 --- /dev/null +++ b/crates/hydra-mcp-http/tests/transport.rs @@ -0,0 +1,327 @@ +use std::sync::{Arc, Mutex}; + +use axum::{ + body::Body, + http::{Request, StatusCode}, + response::Response, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use hydra_mcp_http::{OriginPolicy, PROTOCOL_VERSION, ServerConfig, router}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +fn manifest(tool_name: &str) -> Value { + json!([{ + "name": tool_name, + "description": "fixture tool", + "inputSchema": {"type": "object", "additionalProperties": false} + }]) +} + +fn config(tools: Value) -> ServerConfig { + ServerConfig::new("fixture", "1.2.3", tools, OriginPolicy::RejectPresented) +} + +fn request(method: &str, id: u64, params: &Value) -> Request { + Request::post("/") + .header("content-type", "application/json; charset=utf-8") + .header("accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", PROTOCOL_VERSION) + .header("Mcp-Method", method) + .body(Body::from( + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(), + )) + .unwrap() +} + +fn params() -> Value { + json!({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION, + "io.modelcontextprotocol/clientCapabilities": {} + } + }) +} + +async fn json_body(response: Response) -> Value { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +#[tokio::test] +async fn discover_list_and_call_use_the_real_router_boundary() { + let tools = manifest("echo"); + let calls = Arc::new(Mutex::new(Vec::new())); + let calls_for_dispatch = calls.clone(); + let app = router(config(tools.clone()), move |name, arguments| { + let calls = calls_for_dispatch.clone(); + async move { + calls.lock().unwrap().push((name, arguments)); + Ok(json!({"answer": 42})) + } + }) + .unwrap(); + + let discover = app + .clone() + .oneshot(request("server/discover", 1, ¶ms())) + .await + .unwrap(); + assert_eq!(discover.status(), StatusCode::OK); + let discover_body = json_body(discover).await; + assert_eq!(discover_body["result"]["resultType"], "complete"); + assert_eq!( + discover_body["result"]["supportedVersions"], + json!([PROTOCOL_VERSION]) + ); + assert_eq!( + discover_body["result"]["_meta"]["io.modelcontextprotocol/serverInfo"], + json!({"name": "fixture", "version": "1.2.3"}) + ); + + let list = app + .clone() + .oneshot(request("tools/list", 2, ¶ms())) + .await + .unwrap(); + assert_eq!(list.status(), StatusCode::OK); + assert_eq!(json_body(list).await["result"]["tools"], tools); + + let mut call_params = params(); + call_params["name"] = json!("echo"); + call_params["arguments"] = json!({"value": "hello"}); + let call_request = Request::post("/") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", PROTOCOL_VERSION) + .header("Mcp-Method", "tools/call") + .header("Mcp-Name", "echo") + .body(Body::from( + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": call_params + }) + .to_string(), + )) + .unwrap(); + let call = app.oneshot(call_request).await.unwrap(); + assert_eq!(call.status(), StatusCode::OK); + assert!(call.headers().get("Mcp-Session-Id").is_none()); + let call_body = json_body(call).await; + assert_eq!( + call_body["result"]["structuredContent"], + json!({"answer": 42}) + ); + assert_eq!(call_body["result"]["content"][0]["text"], "{\"answer\":42}"); + assert_eq!( + calls.lock().unwrap().as_slice(), + &[("echo".to_owned(), json!({"value": "hello"}))] + ); +} + +#[tokio::test] +async fn dispatch_errors_are_tool_results_and_invalid_ids_are_null() { + let app = router(config(manifest("echo")), |_, _| async { + Err::("safe failure".to_owned()) + }) + .unwrap(); + + let mut invalid_id = request("tools/list", 1, ¶ms()); + *invalid_id.body_mut() = Body::from( + json!({ + "jsonrpc": "2.0", + "id": {"not": "a valid JSON-RPC id"}, + "method": "tools/list", + "params": params() + }) + .to_string(), + ); + let invalid_id_response = app.clone().oneshot(invalid_id).await.unwrap(); + let invalid_id_body = json_body(invalid_id_response).await; + assert_eq!(invalid_id_body["id"], Value::Null); + assert_eq!(invalid_id_body["error"]["code"], -32600); + + let mut call_params = params(); + call_params["name"] = json!("echo"); + call_params["arguments"] = json!({}); + let mut call = request("tools/call", 2, &call_params); + call.headers_mut() + .insert("Mcp-Name", "echo".parse().unwrap()); + let call_body = app.oneshot(call).await.unwrap(); + assert_eq!(call_body.status(), StatusCode::OK); + let call_body = json_body(call_body).await; + assert_eq!(call_body["result"]["isError"], true); + assert_eq!(call_body["result"]["content"][0]["text"], "safe failure"); +} + +#[tokio::test] +async fn header_mismatch_happens_before_dispatch() { + let dispatched = Arc::new(Mutex::new(false)); + let dispatched_for_closure = dispatched.clone(); + let app = router(config(manifest("echo")), move |_, _| { + let dispatched = dispatched_for_closure.clone(); + async move { + *dispatched.lock().unwrap() = true; + Ok(json!(null)) + } + }) + .unwrap(); + let mut req = request("tools/list", 1, ¶ms()); + req.headers_mut() + .insert("Mcp-Method", "tools/call2".parse().unwrap()); + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = json_body(response).await; + assert_eq!(body["error"]["code"], -32020); + assert_eq!(body["error"]["message"], "HeaderMismatch"); + assert!(!*dispatched.lock().unwrap()); +} + +#[tokio::test] +async fn origin_is_checked_before_decoding_and_exact_allowlists_are_inbound_only() { + let app = router(config(manifest("echo")), |_, _| async { Ok(json!(null)) }).unwrap(); + let rejected = app + .clone() + .oneshot( + Request::post("/") + .header("origin", "https://untrusted.example") + .body(Body::from("not-json")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::FORBIDDEN); + assert!( + rejected + .headers() + .get("access-control-allow-origin") + .is_none() + ); + + let allowed_config = ServerConfig::new( + "fixture", + "1.2.3", + manifest("echo"), + OriginPolicy::AllowExact(vec!["https://trusted.example".to_owned()]), + ); + let allowed = router(allowed_config, |_, _| async { Ok(json!(null)) }) + .unwrap() + .oneshot({ + let mut request = request("tools/list", 1, ¶ms()); + request + .headers_mut() + .insert("origin", "https://trusted.example".parse().unwrap()); + request + }) + .await + .unwrap(); + assert_eq!(allowed.status(), StatusCode::OK); +} + +#[tokio::test] +async fn sentinel_encoded_tool_names_are_compared_after_decoding() { + let name = "世界"; + let encoded = STANDARD.encode(name); + let app = router(config(manifest(name)), |_, _| async { Ok(json!("ok")) }).unwrap(); + let mut call_params = params(); + call_params["name"] = json!(name); + let call_request = Request::post("/") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", PROTOCOL_VERSION) + .header("Mcp-Method", "tools/call") + .header("Mcp-Name", format!("=?base64?{encoded}?=")) + .body(Body::from( + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": call_params + }) + .to_string(), + )) + .unwrap(); + let response = app.oneshot(call_request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + json_body(response).await["result"]["structuredContent"], + "ok" + ); +} + +#[tokio::test] +async fn unsupported_version_and_legacy_methods_are_truthful() { + let app = router(config(manifest("echo")), |_, _| async { Ok(json!(null)) }).unwrap(); + let mut unsupported = request("tools/list", 1, ¶ms()); + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {"_meta": { + "io.modelcontextprotocol/protocolVersion": "2099-01-01", + "io.modelcontextprotocol/clientCapabilities": {} + }} + }); + *unsupported.body_mut() = Body::from(body.to_string()); + unsupported + .headers_mut() + .insert("MCP-Protocol-Version", "2099-01-01".parse().unwrap()); + let response = app.clone().oneshot(unsupported).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = json_body(response).await; + assert_eq!(body["error"]["code"], -32022); + assert_eq!( + body["error"]["data"]["supportedVersions"], + json!([PROTOCOL_VERSION]) + ); + + let get = app + .oneshot(Request::get("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(get.status(), StatusCode::METHOD_NOT_ALLOWED); + + let legacy = router(config(manifest("echo")), |_, _| async { Ok(json!(null)) }) + .unwrap() + .oneshot(request("initialize", 2, ¶ms())) + .await + .unwrap(); + assert_eq!(legacy.status(), StatusCode::NOT_FOUND); + assert_eq!(json_body(legacy).await["error"]["code"], -32601); + + let options = router(config(manifest("echo")), |_, _| async { Ok(json!(null)) }) + .unwrap() + .oneshot(Request::options("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(options.status(), StatusCode::METHOD_NOT_ALLOWED); + assert!( + options + .headers() + .get("access-control-allow-origin") + .is_none() + ); +} + +#[test] +fn construction_rejects_duplicate_tools_and_header_annotations() { + let duplicate = json!([ + {"name": "same", "inputSchema": {"type": "object"}}, + {"name": "same", "inputSchema": {"type": "object"}} + ]); + assert!(router(config(duplicate), |_, _| async { Ok(json!(null)) }).is_err()); + + let header_annotation = json!([{ + "name": "header", + "inputSchema": {"type": "object", "properties": {"token": {"x-mcp-header": "X-Token"}}} + }]); + let error = router(config(header_annotation), |_, _| async { Ok(json!(null)) }) + .unwrap_err() + .to_string(); + assert!(error.contains("header")); + assert!(error.contains("x-mcp-header")); +} diff --git a/examples/notes/Cargo.toml b/examples/notes/Cargo.toml index fd1c8c5..0ac181e 100644 --- a/examples/notes/Cargo.toml +++ b/examples/notes/Cargo.toml @@ -11,6 +11,7 @@ publish = false workspace = true [dependencies] +hydra-mcp-http = { workspace = true } hydra-mcp-stdio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/examples/notes/src/bin/notes-mcp-http.rs b/examples/notes/src/bin/notes-mcp-http.rs new file mode 100644 index 0000000..a336ab7 --- /dev/null +++ b/examples/notes/src/bin/notes-mcp-http.rs @@ -0,0 +1,6 @@ +//! Notes example MCP Streamable HTTP server binary. + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + notes_example::run_mcp_http().await +} diff --git a/examples/notes/src/lib.rs b/examples/notes/src/lib.rs index 0e5fd95..670fe29 100644 --- a/examples/notes/src/lib.rs +++ b/examples/notes/src/lib.rs @@ -460,17 +460,88 @@ pub async fn run_cli() -> anyhow::Result<()> { } } +/// Extract the generated parameter-location table used by both MCP +/// transports. The table is emitted by Hydra; no parameter-name inference is +/// performed here. +#[must_use] +pub fn mcp_locations(tools: &Value) -> Value { + tools + .get("locations") + .and_then(|locations| locations.get("tools")) + .cloned() + .unwrap_or_else(|| tools.get("locations").cloned().unwrap_or(Value::Null)) +} + +/// Adapt a generated MCP tool call to the one Notes operation dispatcher. +/// +/// This function is deliberately shared by stdio and the HTTP transport so a +/// transport cannot grow a second parameter-routing implementation. +pub async fn dispatch_mcp_tool( + state: &AppState, + locations: &Value, + name: String, + args: Value, +) -> Result { + let Some(args) = args.as_object() else { + return Err("tool arguments must be an object".to_owned()); + }; + let mut path = std::collections::BTreeMap::new(); + let mut query = std::collections::BTreeMap::new(); + let mut body = Value::Null; + let op_locations = locations + .as_object() + .and_then(|object| object.get(&name)) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let mut body_map = serde_json::Map::new(); + for (key, location) in &op_locations { + let value = args.get(key).cloned().unwrap_or(Value::Null); + match location.as_str().unwrap_or("body") { + "path" => { + path.insert(key.clone(), value.as_str().unwrap_or_default().to_owned()); + } + "query" => { + query.insert(key.clone(), value.to_string().trim_matches('"').to_owned()); + } + _ => { + body_map.insert(key.clone(), value); + } + } + } + if !body_map.is_empty() { + body = Value::Object(body_map); + } + let input = generated::GeneratedOperationInput { path, query, body }; + execute_operation(state, &name, input) + .await + .map_err(|error| error.message) +} + +/// Build the Notes MCP-over-HTTP router without binding a listener. +pub fn mcp_http_router(state: AppState) -> anyhow::Result { + let tools: Value = serde_json::from_str(GENERATED_MCP_JSON)?; + let locations = mcp_locations(&tools); + let config = hydra_mcp_http::ServerConfig::new( + "notes", + env!("CARGO_PKG_VERSION"), + tools, + hydra_mcp_http::OriginPolicy::RejectPresented, + ); + let runtime = hydra_mcp_http::router(config, move |name, args| { + let state = state.clone(); + let locations = locations.clone(); + async move { dispatch_mcp_tool(&state, &locations, name, args).await } + }) + .map_err(|error| anyhow::anyhow!("mcp HTTP configuration error: {error}"))?; + Ok(Router::new().nest("/mcp", runtime)) +} + /// MCP entry: serve tools from generated mcp.json over stdio. pub async fn run_mcp() -> anyhow::Result<()> { let tools: Value = serde_json::from_str(GENERATED_MCP_JSON)?; let state = AppState::with_fixtures(); - // Route tool arguments into path/query/body using the location metadata - // emitted alongside the tool schemas — no name inference. - let locations = tools - .get("locations") - .and_then(|l| l.get("tools")) - .cloned() - .unwrap_or_else(|| tools.get("locations").cloned().unwrap_or(Value::Null)); + let locations = mcp_locations(&tools); hydra_mcp_stdio::serve( "notes", @@ -479,49 +550,20 @@ pub async fn run_mcp() -> anyhow::Result<()> { move |name, args| { let state = state.clone(); let locations = locations.clone(); - async move { - let mut path = std::collections::BTreeMap::new(); - let mut query = std::collections::BTreeMap::new(); - let mut body = Value::Null; - let op_locations = locations - .as_object() - .and_then(|o| o.get(&name)) - .and_then(|v| v.as_object()) - .cloned() - .unwrap_or_default(); - let mut body_map = serde_json::Map::new(); - for (key, location) in &op_locations { - let value = args.get(key).cloned().unwrap_or(Value::Null); - match location.as_str().unwrap_or("body") { - "path" => { - path.insert( - key.clone(), - value.as_str().unwrap_or_default().to_string(), - ); - } - "query" => { - query.insert( - key.clone(), - value.to_string().trim_matches('"').to_string(), - ); - } - _ => { - body_map.insert(key.clone(), value); - } - } - } - if !body_map.is_empty() { - body = Value::Object(body_map); - } - let input = generated::GeneratedOperationInput { path, query, body }; - match execute_operation(&state, &name, input).await { - Ok(value) => Ok(value), - Err(err) => Err(err.message), - } - } + async move { dispatch_mcp_tool(&state, &locations, name, args).await } }, ) .await - .map_err(|e| anyhow::anyhow!("mcp stdio error: {e}"))?; + .map_err(|error| anyhow::anyhow!("mcp stdio error: {error}"))?; + Ok(()) +} + +/// MCP-over-HTTP entry: bind the reference consumer to loopback and mount the +/// reusable runtime at the explicit `/mcp` path. +pub async fn run_mcp_http() -> anyhow::Result<()> { + let app = mcp_http_router(AppState::with_fixtures())?; + let listener = tokio::net::TcpListener::bind("127.0.0.1:8942").await?; + println!("listening on http://127.0.0.1:8942/mcp"); + axum::serve(listener, app).await?; Ok(()) } diff --git a/examples/notes/tests/mcp_http.rs b/examples/notes/tests/mcp_http.rs new file mode 100644 index 0000000..65192b2 --- /dev/null +++ b/examples/notes/tests/mcp_http.rs @@ -0,0 +1,126 @@ +use std::{fmt::Write as _, net::SocketAddr}; + +use notes_example::{AppState, GENERATED_MCP_JSON, mcp_http_router}; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, + sync::oneshot, +}; + +async fn post_json( + address: SocketAddr, + method: &str, + name: Option<&str>, + params: Value, + id: u64, +) -> (u16, String, Value) { + let body = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + }) + .to_string(); + let mut request = format!( + "POST /mcp HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nMCP-Protocol-Version: 2026-07-28\r\nMcp-Method: {method}\r\nContent-Length: {}\r\n", + body.len() + ); + if let Some(name) = name { + let _ = write!(request, "Mcp-Name: {name}\r\n"); + } + request.push_str("\r\n"); + + let mut stream = TcpStream::connect(address).await.unwrap(); + stream.write_all(request.as_bytes()).await.unwrap(); + stream.write_all(body.as_bytes()).await.unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + + let separator = response + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("HTTP response headers"); + let header_bytes = &response[..separator]; + let body_bytes = &response[separator + 4..]; + let header_text = String::from_utf8(header_bytes.to_vec()).unwrap(); + let status = header_text + .lines() + .next() + .unwrap() + .split_whitespace() + .nth(1) + .unwrap() + .parse() + .unwrap(); + let value = serde_json::from_slice(body_bytes).unwrap(); + (status, header_text, value) +} + +fn metadata() -> Value { + json!({ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + } + }) +} + +#[tokio::test] +async fn independent_loopback_client_discovers_lists_and_calls_notes() { + let app = mcp_http_router(AppState::with_fixtures()).unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, shutdown_signal) = oneshot::channel(); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_signal.await; + }) + .await + .unwrap(); + }); + + let (status, discover_headers, discover) = + post_json(address, "server/discover", None, metadata(), 1).await; + assert_eq!(status, 200); + assert!(discover_headers.contains("content-type: application/json")); + assert!( + !discover_headers + .to_ascii_lowercase() + .contains("mcp-session-id") + ); + assert_eq!(discover["result"]["resultType"], "complete"); + assert_eq!( + discover["result"]["supportedVersions"], + json!(["2026-07-28"]) + ); + + let (status, list_headers, list) = post_json(address, "tools/list", None, metadata(), 2).await; + assert_eq!(status, 200); + assert!( + !list_headers + .to_ascii_lowercase() + .contains("access-control-allow") + ); + let generated: Value = serde_json::from_str(GENERATED_MCP_JSON).unwrap(); + assert_eq!(list["result"]["tools"], generated["tools"]); + + let mut call_params = metadata(); + call_params["name"] = json!("list_notes"); + call_params["arguments"] = json!({"limit": 1}); + let (status, call_headers, call) = + post_json(address, "tools/call", Some("list_notes"), call_params, 3).await; + assert_eq!(status, 200); + assert!(!call_headers.to_ascii_lowercase().contains("mcp-session-id")); + assert_eq!(call["result"]["resultType"], "complete"); + assert_eq!(call["result"]["structuredContent"][0]["id"], "n2"); + assert_eq!( + call["result"]["structuredContent"][0]["title"], + "spike followup" + ); + assert_eq!(call["result"]["content"][0]["type"], "text"); + + shutdown.send(()).unwrap(); + server.await.unwrap(); +} diff --git a/openspec/changes/add-mcp-streamable-http/tasks.md b/openspec/changes/add-mcp-streamable-http/tasks.md index ba98339..88863ec 100644 --- a/openspec/changes/add-mcp-streamable-http/tasks.md +++ b/openspec/changes/add-mcp-streamable-http/tasks.md @@ -2,24 +2,40 @@ ## Spec review -- [ ] 1. Review and merge this OpenSpec-only contract before any runtime, +- [x] 1. Review and merge this OpenSpec-only contract before any runtime, workspace, example, or generated-artifact implementation begins. ## Implementation (blocked by spec review) -- [ ] 2. Add `hydra-mcp-http` to the workspace with the explicit configuration, +- [x] 2. Add `hydra-mcp-http` to the workspace with the explicit configuration, Origin policy, manifest validation, and Axum router boundary described here. -- [ ] 3. Implement current `2026-07-28` stateless POST handling for +- [x] 3. Implement current `2026-07-28` stateless POST handling for `server/discover`, `tools/list`, and `tools/call`, including media, JSON-RPC, metadata, protocol-version, method/name, and safe-error handling. -- [ ] 4. Add focused runtime tests for protocol/security failures, POST-only +- [x] 4. Add focused runtime tests for protocol/security failures, POST-only and no-implicit-CORS/PNA-preflight behavior, statelessness, tool result/error envelopes, and unsupported `x-mcp-header` annotations. -- [ ] 5. Wire the Notes reference consumer through the same MCP dispatch +- [x] 5. Wire the Notes reference consumer through the same MCP dispatch adapter as stdio; add `notes-mcp-http` and a real loopback modern-client acceptance test for discover → list → call. -- [ ] 6. Update README and committed guidance with explicit embedding, +- [x] 6. Update README and committed guidance with explicit embedding, localhost, Origin-policy, protocol-version, and intentionally unsupported capability boundaries. -- [ ] 7. Run the complete Hydra gates: build, tests, strict Clippy, fmt, both +- [x] 7. Run the complete Hydra gates: build, tests, strict Clippy, fmt, both example codegen checks, Creed diff, and `git diff --check`. + +## Verification evidence + +- `cargo build --locked --all-targets` passed. +- `cargo test --locked --all-targets` passed: 60 codegen tests, 3 runtime unit + tests, 7 runtime transport tests, 1 real Notes loopback test, 14 Notes + surface tests, and 6 security-scan tests. +- `cargo clippy --locked --workspace --all-targets -- -D warnings`, + `cargo fmt --all -- --check`, `creed diff`, and `git diff --check` passed. +- `cargo run --locked -p hydra-codegen --bin hydra-codegen -- check` passed in + `examples/notes`, `examples/security-scan`, and `examples/ts-client`. + Two `write` passes in each example were byte-identical to the committed + four-artifact snapshots and to each other; the final `check` passed. +- In `examples/ts-client`, `npm ci --ignore-scripts --no-audit --no-fund`, + `npm run check:generated`, `npm run typecheck`, `npm run build`, and + `npm run test:nested-schema` passed.