diff --git a/.secrets.baseline b/.secrets.baseline index 8f4c5ab..01d7538 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1,9 +1,9 @@ { "exclude": { - "files": "Cargo\\.lock$|\\.lock$|target/|^.secrets.baseline$", + "files": "(?x)(Cargo\\.lock$|\\.lock$)|^\\.secrets\\.baseline$", "lines": null }, - "generated_at": "2026-08-19T10:02:56Z", + "generated_at": "2026-08-19T10:59:12Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -80,371 +80,371 @@ "assets/contextforgeCA/contextforge-client.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "assets/contextforgeCA/contextforge-server.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "assets/contextforgeCA/contextforge.ca.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "assets/contextforgeCA/contextforge.intermediate.key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "assets/jwt.key": [ { "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "assets/tls_key.pem": [ { "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 1, "type": "Private Key", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "crates/contextforge-data-plane-lib/src/common.rs": [ { "hashed_secret": "4a4645604f0b9e29503be96a87f6f47a6e4a7890", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 154, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "427f5e1b530d4a544883308d876a11d724060c86", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 157, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "bfc6000db1195a9522813fc405c666dd4ce669ad", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 263, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "crates/contextforge-data-plane-lib/src/telemetry.rs": [ { "hashed_secret": "0a24796d4c71ce722a92f450f69dc36c60b21de4", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 87, "type": "Hex High Entropy String", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "crates/contextforge-data-plane-lib/tests/support/client.rs": [ { "hashed_secret": "a453c8b2640819a451ce875ac1e04d0dbab7b403", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 12, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "crates/contextforge-data-plane-lib/tests/support/mod.rs": [ { "hashed_secret": "a453c8b2640819a451ce875ac1e04d0dbab7b403", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 17, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "crates/contextforge-data-plane/Cargo.toml": [ { "hashed_secret": "58e7dc38ba3a7d4a720006d2f3cc4cda774d89dc", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 19, "type": "Hex High Entropy String", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "crates/plugins/cpex-secrets-detection/src/lib.rs": [ { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 609, "type": "AWS Access Key", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "crates/plugins/cpex-secrets-detection/src/scanner.rs": [ { "hashed_secret": "9249e2590f5d19742260cb5296cb76fe0677f147", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 238, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "199da8f71b7dced64f82cf6e96483134cace9b14", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 239, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "c0026c4c848882618c987859077ffbae92130625", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 242, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "078553dc10635837abb80f404302c70cba91b879", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 278, "type": "Base64 High Entropy String", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "e175c6f5f2a92e8623bd9a4820edb4e8c1b0fd10", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 278, "type": "GitHub Token", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "97d99a51e5ac827bb36fe6273facfda35245917a", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 279, "type": "Base64 High Entropy String", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 282, "type": "Private Key", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "b1775a785f09a6ebaf2dc33d6eaeb98974d9cdb8", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 284, "type": "Hex High Entropy String", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "eae9124e42e2ef05ba727bd1a1c0c6fa61a05b9e", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 302, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 401, "type": "AWS Access Key", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "9d7235fe33b6612ed7ebca4b63afd00d4adf5d66", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 410, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "27a39044bff80a4c196689dfa8dcf129cb27fef8", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 431, "type": "Base64 High Entropy String", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "crates/plugins/cpex-secrets-detection/tests/plugin_manager.rs": [ { "hashed_secret": "436da7d4d22c39c0165ab0d5b40073d0f2fc11c5", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 197, "type": "AWS Access Key", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "8b4510a576d82f38bd2730436bf5e20c4e15b30e", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 198, "type": "AWS Access Key", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "e4ea017859bcad962c8ab551fe29da9147877eee", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 199, "type": "AWS Access Key", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "86de8c52637ec530fe39b0a8471da9b8764d5242", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 268, "type": "AWS Access Key", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "docker/docker-compose-langfuse.yaml": [ { "hashed_secret": "cb1fde0682fbd1ac0faf2a9f297167ac9d06434b", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 16, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "cb58df830a45cc33df1a313e616ecad78cd796c5", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 77, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "2e0c522bfe4e7885492862df2e0b987c0ca02623", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 100, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "d9d007c8de197b3f36a3a0ba4f13c0f7df175d5a", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 255, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "docker/docker-compose.yml": [ { "hashed_secret": "2a8bfc0ce436d55ca907d0162989481bcb7677b4", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 189, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "fdda45b7f6d2ead95d9991fc4678640c3bab0d84", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 363, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "093d378410a5cfa4bd5088f3fef62fbdb8a95665", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 369, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "c3de40d5e3fc71ed62771c2127a8e42585026c97", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 371, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "4d4acd9b084d13f5fdb23807d857e1c48a1cfd0f", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 460, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "bd0160c2cf35d950843c88f3be2b9412ed71f485", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 495, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false }, { "hashed_secret": "293324f6824bb3a6db5c4dc42a60ddd4a9851c99", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 658, "type": "Hex High Entropy String", - "verified_result": null + "verified_result": true, + "is_secret": false } ], "scripts/git/resolve-secrets-baseline-conflict.sh": [ { "hashed_secret": "44ffd1bfb94772d5f91d528e7aca703990edbbd7", - "is_secret": false, - "is_verified": false, + "is_verified": true, "line_number": 31, "type": "Secret Keyword", - "verified_result": null + "verified_result": true, + "is_secret": false } ] }, diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs index 4962441..1c3d0a6 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs @@ -1,16 +1,17 @@ use contextforge_data_plane_cpex::PromptPreFetchResult; use rmcp::{ ErrorData, RoleServer, - model::{GetPromptRequestParams, GetPromptResponse, ListPromptsResult, PaginatedRequestParams}, + model::{ErrorCode, GetPromptRequestParams, GetPromptResponse, ListPromptsResult, PaginatedRequestParams}, service::RequestContext, }; use tracing::info; use super::McpService; use crate::gateway::{ - identifier_routing::{backend_forward_error, route_identifier_to_backend}, + identifier_routing::{backend_forward_error, resolve_tool_route}, list_aggregation::{decode_gateway_cursor, fan_out_list, merge_prompts}, mcp_call_validator::AuthorizedCallValidator, + mcp_service::initialization::connect_backend_for_request, session_manager::SessionManager, session_store::UserSessionStore, }; @@ -74,28 +75,39 @@ where T: UserSessionStore + Send + Sync + 'static, { let mcp_call_validator = AuthorizedCallValidator::new("get_prompt", &cx); - let (virtual_host, session_id, claims) = mcp_call_validator.validate()?; - let session_manager = SessionManager::new(virtual_host, session_id, claims.sub.as_str(), &mcp_service.transports); + let (virtual_host, _claims) = mcp_call_validator.validate_stateless()?; + let backend_names: Vec<&str> = virtual_host.backends.keys().map(String::as_str).collect(); + let Some((backend_name, prompt_name)) = resolve_tool_route(virtual_host, &request.name, &backend_names) else { + return Err(ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: "Routing problem... promtp not found".into(), + data: None, + }); + }; - let (service_name, service, prompt_name) = route_identifier_to_backend( - &session_manager, - "get_prompt", - &request.name, - "Routing problem... invalid prompt name", - ) - .await?; + let backend_name = backend_name.to_owned(); + let prompt_name = prompt_name.to_owned(); + let backend = virtual_host.backends.get(&backend_name).ok_or_else(|| ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: "Routing problem... backend not found".into(), + data: None, + })?; + let service_name = backend_name.clone(); let pre_result = if let Some(plugin_runtime) = &mcp_service.plugin_runtime { plugin_runtime.before_get_prompt(&request, &prompt_name, &service_name).await? } else { PromptPreFetchResult::unchanged() }; + let mut backend_service = + connect_backend_for_request(mcp_service, &backend_name, backend, virtual_host.backends.len() > 1, &cx).await?; let mut routed_request = request; pre_result.arguments.apply_to_request(&mut routed_request, &prompt_name); - let response = service - .get_prompt(routed_request) - .await - .map_err(|error| backend_forward_error("get_prompt", &service_name, &error))?; + let response = backend_service.get_prompt(routed_request).await; + if let Err(error) = backend_service.close().await { + tracing::warn!("get_prompt: backend cleanup failed backend_name = {service_name} error = {error:?}"); + } + let response = response.map_err(|error| backend_forward_error("get_prompt", &service_name, &error))?; info!("get_prompt: backend {service_name} returned {} messages", response.messages.len()); let response = if let Some(plugin_runtime) = &mcp_service.plugin_runtime { plugin_runtime.after_get_prompt(&prompt_name, response, pre_result.state).await? diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs index d75dea0..504cad6 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs @@ -1,7 +1,7 @@ use rmcp::{ ErrorData, RoleServer, model::{ - ListResourceTemplatesResult, ListResourcesResult, PaginatedRequestParams, ReadResourceRequestParams, + ErrorCode, ListResourceTemplatesResult, ListResourcesResult, PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResponse, SubscribeRequestParams, UnsubscribeRequestParams, }, service::RequestContext, @@ -10,9 +10,10 @@ use tracing::info; use super::McpService; use crate::gateway::{ - identifier_routing::{backend_forward_error, route_identifier_to_backend}, + identifier_routing::{backend_forward_error, resolve_tool_route, route_identifier_to_backend}, list_aggregation::{decode_gateway_cursor, fan_out_list, merge_resource_templates, merge_resources}, mcp_call_validator::AuthorizedCallValidator, + mcp_service::initialization::connect_backend_for_request, session_manager::SessionManager, session_store::UserSessionStore, }; @@ -76,24 +77,39 @@ where T: UserSessionStore + Send + Sync + 'static, { let mcp_call_validator = AuthorizedCallValidator::new("read_resource", &cx); - let (virtual_host, session_id, claims) = mcp_call_validator.validate()?; - let session_manager = SessionManager::new(virtual_host, session_id, claims.sub.as_str(), &mcp_service.transports); + let (virtual_host, _claims) = mcp_call_validator.validate_stateless()?; + let backend_names: Vec<&str> = virtual_host.backends.keys().map(String::as_str).collect(); + let Some((backend_name, resource_uri)) = resolve_tool_route(virtual_host, &request.uri, &backend_names) else { + return Err(ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: "Routing problem... resource not found".into(), + data: None, + }); + }; + let backend_name = backend_name.to_owned(); + let resource_uri = resource_uri.to_owned(); - let (service_name, service, resource_uri) = route_identifier_to_backend( - &session_manager, - "read_resource", - &request.uri, - "Routing problem... wrong resource name", - ) - .await?; + let backend = virtual_host.backends.get(&backend_name).ok_or_else(|| ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: "Routing problem... backend not found".into(), + data: None, + })?; + + let service_name = backend_name.clone(); + let mut backend_service = + connect_backend_for_request(mcp_service, &backend_name, backend, virtual_host.backends.len() > 1, &cx).await?; let mut routed_request = request; routed_request.uri = resource_uri; - let response = service - .read_resource(routed_request) - .await - .map_err(|error| backend_forward_error("read_resource", &service_name, &error))?; + + let response = backend_service.read_resource(routed_request).await; + if let Err(error) = backend_service.close().await { + tracing::warn!("read_resource: backend cleanup failed backend_name = {service_name} error = {error:?}"); + } + let response = response.map_err(|error| backend_forward_error("read_resource", &service_name, &error))?; + info!("read_resource: backend {service_name} returned {} contents", response.contents.len()); + Ok(response.into()) } diff --git a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs index 4dce7a8..51560f6 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs @@ -377,44 +377,90 @@ async fn disabled_runtime_does_not_invoke_registered_plugin() { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn stateless_tool_call_reaches_backend_without_session() { let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; - let response = reqwest::Client::new() - .post(gateway.gateway_url()) - .bearer_auth(token(TEST_USER_ID)) - .header(http::header::CONTENT_TYPE, "application/json") - .header(http::header::ACCEPT, "application/json, text/event-stream") - .header("MCP-Protocol-Version", "2026-07-28") - .header("MCP-Method", "tools/call") - .header("MCP-Name", "sum") - .json(&json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "sum", - "arguments": { "a": 1, "b": 2 }, - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { - "name": "stateless-test-client", - "version": "0.1.0" - }, - "io.modelcontextprotocol/clientCapabilities": {} - } - } - })) - .send() - .await - .expect("stateless tool call is sent"); + let service = support::connect_modern_client( + gateway.gateway_url(), + support::create_client(TEST_USER_ID), + support::modern_client_info(), + ) + .await; + let result = service.call_tool(sum_request("sum", 1, 2)).await.expect("stateless tool call succeeds"); + assert_eq!("3", text(&result)); +} - let status = response.status(); - let body = response.text().await.expect("stateless tool response body is read"); - assert!(status.is_success(), "stateless tool call failed with status {status}: {body}"); - let messages = sse_data_values(&body); - let result = messages +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn stateless_tool_error_round_trips() { + let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; + let service = support::connect_modern_client( + gateway.gateway_url(), + support::create_client(TEST_USER_ID), + support::modern_client_info(), + ) + .await; + let error = service.call_tool(CallToolRequestParams::new("missing_tool")).await.unwrap_err(); + let rmcp::service::ServiceError::McpError(error) = error else { + panic!("expected backend MCP error, got {error:?}"); + }; + assert_eq!(ErrorCode::METHOD_NOT_FOUND, error.code); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn stateless_alias_and_namespaced_tool_names_route() { + let gateway_port = support::create_ports(1)[0]; + let support::ListToolsGatewaySettings { handle, gateway_url, expected_tool_names, .. } = + support::create_gateway_with_four_counters(TEST_USER_ID, support::plaintext_config(gateway_port)) + .await + .expect("gateway starts"); + let service = support::connect_modern_client( + &gateway_url, + support::create_client(TEST_USER_ID), + support::modern_client_info(), + ) + .await; + let alias = expected_tool_names .iter() - .find(|message| message.get("id").and_then(Value::as_i64) == Some(1)) - .unwrap_or_else(|| panic!("missing response id 1 in body: {body}")); - assert_eq!(Some("3"), result.pointer("/result/content/0/text").and_then(Value::as_str)); + .find(|name| std::path::Path::new(name).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("sum"))) + .expect("sum alias is advertised"); + let backend_port = alias + .strip_prefix("backend-") + .and_then(|name| name.strip_suffix(".sum")) + .expect("alias contains the backend port"); + let namespaced_name = format!("00000000-0000-0000-0000-{backend_port:0>12}-sum"); + let alias_result = service.call_tool(sum_request(alias, 1, 2)).await.expect("alias routes"); + let namespaced_result = service.call_tool(sum_request(&namespaced_name, 3, 4)).await.expect("namespace routes"); + assert_eq!("3", text(&alias_result)); + assert_eq!("7", text(&namespaced_result)); + handle.abort(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stateless_concurrent_progress_calls_remain_request_scoped() { + let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; + let client = RecordingClient::default(); + let progress = Arc::clone(&client.progress); + let service = + support::connect_modern_client(gateway.gateway_url(), support::create_client(TEST_USER_ID), client).await; + let first = send_progress_call(&service, "progress_sum").await; + let first_progress_token = first.progress_token.clone(); + let second = send_progress_call(&service, "progress_sum").await; + let second_progress_token = second.progress_token.clone(); + assert_ne!(first_progress_token, second_progress_token); + let (first, second) = tokio::time::timeout(std::time::Duration::from_secs(3), async { + tokio::join!(first.await_response(), second.await_response()) + }) + .await + .expect("both stateless calls complete"); + let ServerResult::CallToolResult(first) = first.expect("first call succeeds") else { + panic!("expected first tool result"); + }; + let ServerResult::CallToolResult(second) = second.expect("second call succeeds") else { + panic!("expected second tool result"); + }; + assert_eq!("completed 4 packages", text(&first)); + assert_eq!("completed 4 packages", text(&second)); + wait_for_event_count(&progress, 8).await; + let progress = progress.lock().expect("progress lock poisoned"); + assert_eq!(4, progress.iter().filter(|event| event.progress_token == first_progress_token).count()); + assert_eq!(4, progress.iter().filter(|event| event.progress_token == second_progress_token).count()); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] diff --git a/crates/contextforge-data-plane-lib/tests/support/client.rs b/crates/contextforge-data-plane-lib/tests/support/client.rs index d0d7e59..dfebe34 100644 --- a/crates/contextforge-data-plane-lib/tests/support/client.rs +++ b/crates/contextforge-data-plane-lib/tests/support/client.rs @@ -71,3 +71,42 @@ where } } } + +pub(crate) fn modern_client_info() -> InitializeRequestParams { + use rmcp::model::{ClientCapabilities, Implementation, ProtocolVersion}; + InitializeRequestParams::new(ClientCapabilities::default(), Implementation::new("stateless-test-client", "0.1.0")) + .with_protocol_version(ProtocolVersion::V_2026_07_28) +} + +pub(crate) async fn connect_modern_client( + gateway_url: &str, + client: reqwest::Client, + handler: H, +) -> rmcp::service::RunningService +where + H: rmcp::ClientHandler + Clone, +{ + let deadline = Instant::now() + CLIENT_CONNECT_TIMEOUT; + loop { + let transport = StreamableHttpClientTransport::with_client( + client.clone(), + StreamableHttpClientTransportConfig::with_uri(gateway_url.to_owned()), + ); + match rmcp::service::serve_client_with_lifecycle( + handler.clone(), + transport, + rmcp::ClientLifecycleMode::Discover { + preferred_versions: vec![rmcp::model::ProtocolVersion::V_2026_07_28], + }, + ) + .await + { + Ok(service) => return service, + Err(error) if Instant::now() < deadline => { + warn!("No Service {error:?}"); + tokio::time::sleep(TEST_POLL_INTERVAL).await; + }, + Err(error) => panic!("modern stateless client connects: {error:?}"), + } + } +} diff --git a/crates/contextforge-data-plane-lib/tests/support/mod.rs b/crates/contextforge-data-plane-lib/tests/support/mod.rs index a1ce24b..ffb1ae2 100644 --- a/crates/contextforge-data-plane-lib/tests/support/mod.rs +++ b/crates/contextforge-data-plane-lib/tests/support/mod.rs @@ -16,8 +16,8 @@ pub(crate) const TEST_USER_EMAIL: &str = "admin@example.com"; pub(crate) use auth::token; pub(crate) use client::{ - CLIENT_CONNECT_TIMEOUT, TEST_POLL_INTERVAL, connect_client, connect_client_with_handler, create_client, - create_tls_client, + CLIENT_CONNECT_TIMEOUT, TEST_POLL_INTERVAL, connect_client, connect_client_with_handler, connect_modern_client, + create_client, create_tls_client, modern_client_info, }; pub(crate) use list_tools_gateway::{ ListToolsGatewaySettings, create_gateway_with_four_counters, create_ports, diff --git a/tests/conformance/expected-failures.yml b/tests/conformance/expected-failures.yml index 5f21562..f39c373 100644 --- a/tests/conformance/expected-failures.yml +++ b/tests/conformance/expected-failures.yml @@ -1,37 +1,28 @@ +# Generated by `make conformance-bless` from scored dataplane findings. +# Pinned fixture findings are excluded; see upstream-fixture-failures.yml. server: - - server-stateless:sep-2575-server-declares-prompts-in-discover - - server-stateless:sep-2575-discover-capabilities-match-handlers - - server-stateless:sep-2575-http-server-no-independent-requests-on-stream - - completion-complete:completion-complete - - tools-list:tools-list - # Dataplane-owned on main. The fixture passes these behavior checks itself; - # its separate wire-schema failures live in upstream-fixture-failures.yml. - - tools-call-with-progress:tools-call-with-progress - - resources-list:resources-list - - resources-read-text:resources-read-text - - resources-read-binary:resources-read-binary - - resources-templates-read:resources-templates-read - - sep-2164-resource-not-found:sep-2164-error-code - - sep-2164-resource-not-found:sep-2164-data-uri - - prompts-list:prompts-list - - prompts-get-simple:prompts-get-simple - - prompts-get-with-args:prompts-get-with-args - - prompts-get-embedded-resource:prompts-get-embedded-resource - - prompts-get-with-image:prompts-get-with-image - - caching:sep-2549-tools-list-caching-hints + - caching:sep-2549-cache-scope-valid - caching:sep-2549-prompts-list-caching-hints - caching:sep-2549-resources-list-caching-hints - caching:sep-2549-resources-templates-list-caching-hints + - caching:sep-2549-tools-list-caching-hints - caching:sep-2549-ttl-non-negative - - caching:sep-2549-cache-scope-valid + - completion-complete:completion-complete - input-required-result-basic-elicitation:sep-2322-elicitation-incomplete - - input-required-result-basic-sampling:sep-2322-sampling-incomplete - input-required-result-basic-list-roots:sep-2322-list-roots-incomplete - - input-required-result-request-state:sep-2322-request-state-incomplete - - input-required-result-multiple-input-requests:sep-2322-multiple-inputs-incomplete - - input-required-result-multi-round:sep-2322-multi-round-r1 + - input-required-result-basic-sampling:sep-2322-sampling-incomplete + - input-required-result-capability-check:sep-2322-respect-client-capabilities - input-required-result-missing-input-response:sep-2322-missing-response-rerequests + - input-required-result-multi-round:sep-2322-multi-round-r1 + - input-required-result-multiple-input-requests:sep-2322-multiple-inputs-incomplete - input-required-result-non-tool-request:sep-2322-non-tool-incomplete + - input-required-result-request-state:sep-2322-request-state-incomplete - input-required-result-result-type:sep-2322-result-type-included - input-required-result-tampered-state:sep-2322-reject-tampered-state - - input-required-result-capability-check:sep-2322-respect-client-capabilities + - prompts-list:prompts-list + - resources-list:resources-list + - server-stateless:sep-2575-discover-capabilities-match-handlers + - server-stateless:sep-2575-http-server-no-independent-requests-on-stream + - server-stateless:sep-2575-server-declares-prompts-in-discover + - tools-call-with-progress:tools-call-with-progress + - tools-list:tools-list