From 21e19abb60677ba9f307a707842efe078388e788 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Mon, 31 Aug 2026 00:38:38 +0000 Subject: [PATCH 1/6] test(mcp): rmcp-protocol call_tool round-trip per gateway tool + unknown (COV-11b review-002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peer.call_tool through the real rmcp streamable-HTTP transport for schema/call/batch/unknown — the production ServerHandler::call_tool routing shell, previously only exercised via the invoke_tool bypass for search. Verification: cargo test --features 'mcp test-support' --test full_surface --- tests/full_surface.rs | 103 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/tests/full_surface.rs b/tests/full_surface.rs index 9edbf75..7c5dd9f 100644 --- a/tests/full_surface.rs +++ b/tests/full_surface.rs @@ -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 = 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; +} From 8a98a52624b633e6b613bb96503ed42a86f64935 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Mon, 31 Aug 2026 00:40:23 +0000 Subject: [PATCH 2/6] test(mcp): schema/batch missing-argument INVALID_INPUT arms (COV-12 review-002) schema tool with None arguments and batch tool without a calls array both return INVALID_INPUT naming the required field, without dispatching. --- src/adapters/to_mcp.rs | 67 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/adapters/to_mcp.rs b/src/adapters/to_mcp.rs index e9c67c3..334792b 100644 --- a/src/adapters/to_mcp.rs +++ b/src/adapters/to_mcp.rs @@ -1106,6 +1106,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![]); From d0e9d4e608fcfcd72da4c5f0e39e0a3d86f13be9 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Mon, 31 Aug 2026 00:41:37 +0000 Subject: [PATCH 3/6] =?UTF-8?q?test(mcp):=20from=5Fmcp=20wraps=20non-objec?= =?UTF-8?q?t=20tool=20arguments=20as=20{"value":=20=E2=80=A6}=20(COV-12=20?= =?UTF-8?q?review-002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire-level round trip through the real rmcp server: a scalar input reaches the remote tool as {"value": }, matching the value_to_json_object wrap. --- tests/from_mcp_integration.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/from_mcp_integration.rs b/tests/from_mcp_integration.rs index b67e273..079cdc6 100644 --- a/tests/from_mcp_integration.rs +++ b/tests/from_mcp_integration.rs @@ -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": } (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 From 5f872f408460a29af3b46edc0de683bbe0587b27 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Mon, 31 Aug 2026 00:43:03 +0000 Subject: [PATCH 4/6] test(gateway): /publish first-line invalid JSON returns 400 INVALID_INPUT (COV-12 review-002) --- src/gateway/routes.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/gateway/routes.rs b/src/gateway/routes.rs index 1207165..c26f4e3 100644 --- a/src/gateway/routes.rs +++ b/src/gateway/routes.rs @@ -2288,6 +2288,20 @@ mod tests { assert_eq!(body.get("code"), Some(&json!("INVALID_INPUT"))); } + #[tokio::test] + async fn publish_first_line_invalid_json_returns_400() { + 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::BAD_REQUEST); + 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"), + "the 400 must name the JSON parse failure: {message}" + ); + } + #[tokio::test] async fn publish_first_line_missing_chunk_returns_400_invalid_input() { let router = build_router(publish_registry(), unused_provider()); From e6077fdb6b9aab44cf927dddf93eed1235431d04 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Mon, 31 Aug 2026 00:43:32 +0000 Subject: [PATCH 5/6] test(server): static-site decoy resolves directory requests to index.html (COV-12 review-002) --- src/server/decoy.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/server/decoy.rs b/src/server/decoy.rs index 9525011..a551da9 100644 --- a/src/server/decoy.rs +++ b/src/server/decoy.rs @@ -286,6 +286,26 @@ mod tests { assert_eq!(&bytes[..], b"

about

"); } + #[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(); From 02a59dfa2253f91a65df05327620934073b0b73d Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Mon, 31 Aug 2026 00:44:18 +0000 Subject: [PATCH 6/6] test(adapters): array-of-$ref resolution + style:simple/explode:true rejection (COV-12 review-002) Array branch of resolve_refs_bounded expands items $refs (memo hit on repeat resolution); simple+explode=true import fails naming OAI-06. --- src/adapters/openapi_spec.rs | 65 ++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/adapters/openapi_spec.rs b/src/adapters/openapi_spec.rs index fb4a5c3..9a5b455 100644 --- a/src/adapters/openapi_spec.rs +++ b/src/adapters/openapi_spec.rs @@ -1335,6 +1335,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!({ @@ -1570,6 +1602,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##"{