Merge branch 'wt/review-002-cov-deployment-knobs'

# Conflicts:
#	src/gateway/routes.rs
This commit is contained in:
2026-08-31 01:55:12 +00:00
6 changed files with 300 additions and 0 deletions
+65
View File
@@ -1604,6 +1604,38 @@ mod tests {
}
}
#[test]
fn array_of_ref_resolves_each_item() {
// The array branch of `resolve_refs_bounded` (the arm a coverage
// pass initially mis-flagged): `$ref`s inside `items` resolve to
// the component schema, not pass through verbatim.
let spec = schema_test_spec(json!({
"Tag": {"type": "string", "minLength": 1},
"Tags": {"type": "array", "items": {"$ref": "#/components/schemas/Tag"}}
}));
let schema = spec
.components
.as_ref()
.expect("test spec has schemas")
.schemas
.get("Tags")
.expect("test schema present")
.clone();
let resolved = spec
.resolve_refs_recursive(&schema)
.expect("array-of-$ref is acyclic and must resolve");
assert_eq!(resolved["type"], "array");
assert_eq!(
resolved["items"],
serde_json::json!({"type": "string", "minLength": 1}),
"the items $ref must expand to the Tag component schema"
);
let resolved_twice = spec
.resolve_refs_recursive(&schema)
.expect("second resolution hits the memo");
assert_eq!(resolved, resolved_twice);
}
#[test]
fn cycle_via_all_of_errors() {
let spec = schema_test_spec(json!({
@@ -1839,6 +1871,39 @@ mod tests {
}
}
#[test]
fn simple_style_with_explode_true_is_rejected() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/f": {"get": {
"operationId": "f",
"parameters": [{
"name": "X-Id",
"in": "header",
"style": "simple",
"explode": true,
"schema": {"type": "string"}
}],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("simple") && message.contains("explode"),
"the error must name the simple+explode conflict: {message}"
);
assert!(message.contains("X-Id"), "message was: {message}");
assert!(message.contains("OAI-06"), "message was: {message}");
}
Ok(_) => panic!("simple+explode=true has no wire meaning here; must fail loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn default_style_and_explode_forms_still_import() {
let doc = r##"{
+67
View File
@@ -1098,6 +1098,73 @@ mod tests {
);
}
#[tokio::test]
async fn schema_tool_without_arguments_is_invalid_input() {
let registry = full_registry_with_ops(vec![(
"fs/readFile".to_string(),
OperationType::Query,
AccessControl::default(),
)]);
let gateway = ToMcpGateway::new(dispatch(registry));
let result = invoke_tool(&gateway, "schema", None, None).await;
assert_eq!(result.is_error, Some(true));
let structured = result.structured_content.expect("structured error present");
assert_eq!(
structured.get("code"),
Some(&Value::String("INVALID_INPUT".to_string()))
);
let message = structured
.get("message")
.and_then(Value::as_str)
.expect("message");
assert!(
message.contains("`name` is required"),
"missing tool arguments must name the required field: {message}"
);
}
#[tokio::test]
async fn batch_tool_without_calls_argument_is_invalid_input_without_dispatching() {
let registry = full_registry_with_ops(vec![(
"echo/run".to_string(),
OperationType::Query,
AccessControl::default(),
)]);
let gateway = ToMcpGateway::new(dispatch(registry));
let none_args = invoke_tool(&gateway, "batch", None, None).await;
assert_eq!(none_args.is_error, Some(true));
let structured = none_args
.structured_content
.expect("structured error present");
assert_eq!(
structured.get("code"),
Some(&Value::String("INVALID_INPUT".to_string()))
);
assert_eq!(structured.get("retryable"), Some(&Value::Bool(false)));
let message = structured
.get("message")
.and_then(Value::as_str)
.expect("message");
assert!(
message.contains("`calls` is required"),
"missing batch arguments must name the required field: {message}"
);
let mut args = Map::new();
args.insert("calls".to_string(), Value::String("nope".to_string()));
let non_array = invoke_tool(&gateway, "batch", Some(args), None).await;
assert_eq!(non_array.is_error, Some(true));
let structured = non_array
.structured_content
.expect("structured error present");
assert_eq!(
structured.get("code"),
Some(&Value::String("INVALID_INPUT".to_string()))
);
}
#[tokio::test]
async fn call_returns_structured_error_for_call_error() {
let registry = full_registry_with_ops(vec![]);
+14
View File
@@ -2528,6 +2528,20 @@ mod tests {
assert_eq!(body.get("code"), Some(&json!("INVALID_INPUT")));
}
#[tokio::test]
async fn publish_first_line_invalid_json_returns_422_invalid_input() {
let router = build_router(publish_registry(), unused_provider());
let req = raw_request("POST", "/publish", b"not-json-at-all\n".to_vec());
let (status, body) = send(router, req).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(body.get("code"), Some(&json!("INVALID_INPUT")));
let message = body.get("message").and_then(Value::as_str).unwrap_or("");
assert!(
message.contains("not valid JSON") || message.contains("JSON"),
"the 422 must name the JSON parse failure: {message}"
);
}
#[tokio::test]
async fn publish_first_line_missing_chunk_returns_422_invalid_input() {
let router = build_router(publish_registry(), unused_provider());
+20
View File
@@ -286,6 +286,26 @@ mod tests {
assert_eq!(&bytes[..], b"<p>about</p>");
}
#[tokio::test]
async fn static_site_decoy_directory_request_resolves_to_index_html() {
let dir = tempfile_dir();
tokio::fs::create_dir_all(dir.join("docs")).await.unwrap();
tokio::fs::write(dir.join("docs").join("index.html"), "dir index")
.await
.unwrap();
let decoy = DecoyConfig::StaticSite { root: dir };
let resp = send(decoy_router(decoy), "/docs").await;
assert_eq!(resp.status(), StatusCode::OK);
let ctype = resp
.headers()
.get(header::CONTENT_TYPE)
.map(|v| v.to_str().unwrap().to_string());
assert!(ctype.as_deref().unwrap_or("").starts_with("text/html"));
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"dir index");
}
#[tokio::test]
async fn static_site_decoy_missing_file_returns_fake_404() {
let dir = tempfile_dir();
+31
View File
@@ -420,6 +420,37 @@ async fn import_refuses_tool_name_containing_slash() {
}
}
#[tokio::test]
async fn forwarding_handler_wraps_non_object_input_as_value_field() {
// A scalar/array tool argument cannot be a JSON object on the MCP
// wire; the adapter wraps it as {"value": <input>} (review-002
// from_mcp :460-468 arm). Proven over the real rmcp round trip: the
// echo server reflects the arguments object back.
let (endpoint, _handle) = spawn_server().await;
let adapter = FromMCP::new(endpoint, "echo");
let bundles = adapter.import().await.expect("import succeeds");
let echo = bundles
.into_iter()
.find(|b| b.spec.name == "echo/echo")
.expect("echo tool present");
let ctx = test_context("req-wrap", Capabilities::new());
let response = match &echo.handler {
HandlerKind::Once(h) => h(serde_json::json!("bare-scalar"), ctx).await,
HandlerKind::Stream(_) | HandlerKind::Sink(_) => panic!("expected Once handler"),
};
match response.result {
Ok(Value::Object(obj)) => {
assert_eq!(
obj.get("echoed"),
Some(&serde_json::json!({ "value": "bare-scalar" })),
"non-object input must reach the wire as {{\"value\": …}}: got {obj:?}"
);
}
other => panic!("expected object structured content, got {other:?}"),
}
}
#[tokio::test]
async fn forwarding_handler_maps_json_rpc_tool_error_with_code_fidelity() {
// A server whose `call_tool` returns a JSON-RPC error (rmcp
+103
View File
@@ -581,3 +581,106 @@ async fn gateway_endpoints_exist_with_bearer_enforcement() {
"unknown op is NOT_FOUND regardless of auth"
);
}
/// COV-11b: the rmcp-entered `ServerHandler::call_tool` routing shell is
/// what production runs; the dispatch-level tests below it bypass it.
/// One real rmcp-protocol `peer.call_tool` round-trip per gateway tool
/// through the served `/mcp` mount.
#[tokio::test]
async fn to_mcp_call_tool_production_dispatch_round_trips_all_tools() {
use rmcp::model::{CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation};
use rmcp::service::RoleClient;
use rmcp::transport::streamable_http_client::{
StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
};
use rmcp::{Peer, ServiceExt};
let registry = local_registry();
let provider = provider_with(vec![("tok-1", identity("alice", &["user"]))]);
let base = spawn_full_server(registry, provider).await;
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_static("Bearer tok-1"),
);
let http = reqwest::Client::builder()
.default_headers(default_headers)
.build()
.unwrap();
let url = format!("{base}/mcp");
let transport = StreamableHttpClientTransport::with_client(
http,
StreamableHttpClientTransportConfig::with_uri(url),
);
let client_info = ClientInfo::new(
ClientCapabilities::default(),
Implementation::new("integration-test", "0.1.0"),
);
let running = client_info.serve(transport).await.expect("initialize");
let peer: Peer<RoleClient> = running.peer().clone();
let schema_params = CallToolRequestParams::new("schema".to_string()).with_arguments(
serde_json::json!({ "name": "echo/run" })
.as_object()
.unwrap()
.clone(),
);
let schema = peer.call_tool(schema_params).await.expect("schema call");
assert_eq!(schema.is_error, Some(false));
let structured = schema.structured_content.expect("structured present");
assert_eq!(structured["name"], "echo/run");
assert!(structured.get("input_schema").is_some());
let call_params = CallToolRequestParams::new("call".to_string()).with_arguments(
serde_json::json!({ "operation": "echo/run", "input": { "v": 7 } })
.as_object()
.unwrap()
.clone(),
);
let call_result = peer.call_tool(call_params).await.expect("call call");
assert_eq!(call_result.is_error, Some(false));
assert_eq!(
call_result.structured_content,
Some(serde_json::json!({ "v": 7 }))
);
let batch_params = CallToolRequestParams::new("batch".to_string()).with_arguments(
serde_json::json!({ "calls": [
{ "operation": "echo/run", "input": { "n": 1 } },
{ "operation": "echo/run", "input": { "n": 2 } }
] })
.as_object()
.unwrap()
.clone(),
);
let batch = peer.call_tool(batch_params).await.expect("batch call");
assert_eq!(batch.is_error, Some(false));
let results = batch
.structured_content
.and_then(|v| v.get("results").cloned())
.expect("results array");
assert_eq!(
results,
serde_json::json!([
{ "isError": false, "output": { "n": 1 } },
{ "isError": false, "output": { "n": 2 } }
])
);
let unknown = peer
.call_tool(CallToolRequestParams::new("bogus".to_string()))
.await
.expect("unknown tool call resolves, not errors");
assert_eq!(unknown.is_error, Some(true));
let err = unknown
.structured_content
.expect("structured error present");
assert_eq!(err["code"], "NOT_FOUND");
assert!(err["message"]
.as_str()
.unwrap_or_default()
.contains("unknown gateway tool"));
let _ = running.cancel().await;
}