From 085d694c423f0b44e02436778304ce2ce7f79229 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 18 Aug 2026 10:10:07 +0100 Subject: [PATCH] fix: update harness for latest dataplane Signed-off-by: lucarlig --- .env.example | 46 +- README.md | 111 +++-- crates/compliance/src/conformance.rs | 5 +- crates/compliance/src/conformance_fixture.rs | 8 +- crates/compliance/src/profile.rs | 4 +- crates/compliance/tests/conformance.rs | 8 +- .../compliance/tests/conformance_fixture.rs | 2 + crates/load/src/goose.rs | 128 ++++- crates/load/tests/load_goose.rs | 150 +++++- crates/load/tests/python_adapters.rs | 108 +++++ crates/mcp/src/auth_proxy.rs | 15 +- crates/mcp/src/http_transport.rs | 16 +- crates/mcp/src/mcp.rs | 70 ++- crates/mcp/src/probe.rs | 228 ++++++++- crates/mcp/tests/auth_proxy.rs | 8 + crates/mcp/tests/backend_identity.rs | 3 +- crates/mcp/tests/http_transport.rs | 43 ++ crates/mcp/tests/mcp.rs | 42 +- crates/mcp/tests/probe.rs | 52 +++ crates/platform/src/compose.rs | 22 +- crates/platform/src/config.rs | 88 ++-- crates/platform/tests/compose.rs | 125 ++++- crates/platform/tests/config.rs | 2 +- crates/platform/tests/stack.rs | 2 + docker/docker-compose.cf-conformance.yaml | 17 +- ...-compose.cf-controlplane-build-labels.yaml | 9 + docker/docker-compose.cf-dataplane-build.yaml | 2 +- docker/docker-compose.cf-dataplane.yaml | 38 +- docker/mcp-conformance-server.Dockerfile | 2 +- docker/nginx.cf-conformance-proxy.conf | 18 + docker/nginx.cf-dataplane.conf | 25 +- reports/mcp-conformance-comparison.md | 92 ++-- scripts/locustfile_mcp.py | 105 ++++- src/app.rs | 82 ++-- src/cli.rs | 34 +- src/runtime/compliance.rs | 31 +- src/runtime/inspect.rs | 22 +- src/runtime/live.rs | 102 ++-- src/runtime/mod.rs | 439 ++++++++++++++++-- src/runtime/stack.rs | 9 +- src/runtime/workloads.rs | 30 +- tests/cli.rs | 83 +++- tests/dispatch.rs | 24 - 43 files changed, 2013 insertions(+), 437 deletions(-) create mode 100644 docker/nginx.cf-conformance-proxy.conf diff --git a/.env.example b/.env.example index 0ffd9e0..db903d6 100644 --- a/.env.example +++ b/.env.example @@ -8,9 +8,9 @@ # Default single-stack mode. Possible: controlplane, dataplane. CF_MCP_STACK_MODE=dataplane -# cf-controlplane checkout. Default: v1.0.6, matching the default image release. +# cf-controlplane checkout. Default: v1.0.7, matching the publisher contract. # Possible: any branch, tag, or commit accepted by git checkout. -CF_CONTROLPLANE_REF=v1.0.6 +CF_CONTROLPLANE_REF=v1.0.7 # cf-controlplane repository. Default: IBM upstream. # Possible: any git clone URL. @@ -52,22 +52,22 @@ CF_INTEGRATION_DIR=.integration # Possible: main, user/luca/cp-parity-tool-names, any ref accepted by git checkout. # CF_DATAPLANE_REF=user/luca/cp-parity-tool-names -# cf-dataplane repository. Default: contextforge-gateway-rs upstream. +# cf-dataplane repository. Default: contextforge-org upstream. # Possible: any git clone URL. -CF_DATAPLANE_REPO=https://github.com/contextforge-gateway-rs/contextforge-gateway-rs.git +CF_DATAPLANE_REPO=https://github.com/contextforge-org/contextforge-data-plane.git -# cf-dataplane checkout directory. Default: .integration/contextforge-gateway-rs. -CF_DATAPLANE_DIR=.integration/contextforge-gateway-rs +# cf-dataplane checkout directory. Default: .integration/contextforge-data-plane. +CF_DATAPLANE_DIR=.integration/contextforge-data-plane # Local cf-dataplane image tag used when CF_DATAPLANE_REF is set and CF_DATAPLANE_IMAGE is unset. # Possible: any Docker image reference. -CF_DATAPLANE_LOCAL_IMAGE=contextforge-gateway-rs/contextforge-gateway-rs:local +CF_DATAPLANE_LOCAL_IMAGE=contextforge-org/contextforge-data-plane:local # Full cf-dataplane image override. With no source ref or full override, the -# published default derives from CF_DATAPLANE_VERSION (currently 0.1.0). Leave +# published default derives from CF_DATAPLANE_VERSION (currently latest). Leave # the full override unset in source mode to use CF_DATAPLANE_LOCAL_IMAGE. -# CF_DATAPLANE_IMAGE=ghcr.io/contextforge-gateway-rs/contextforge-gateway-rs: -CF_DATAPLANE_VERSION=0.1.0 +# CF_DATAPLANE_IMAGE=ghcr.io/contextforge-org/contextforge-data-plane: +CF_DATAPLANE_VERSION=latest # cf-dataplane image platform. Default: auto. # auto resolves to linux/amd64 in published image mode, or the Docker server platform @@ -83,28 +83,40 @@ CF_DATAPLANE_PUBLISHER_INTERVAL_SECONDS=2 # Possible: non-negative integer. Use 60 to restore the dataplane image default for load benchmarks. CF_DATAPLANE_USER_CONFIG_CACHE_EXPIRY_SECONDS=0 +# Optional comma-separated transport allowlist overrides. Defaults cover the +# loopback nginx origin using NGINX_PORT and the internal `nginx` service name +# used by containerized Locust runs. +# CF_DATAPLANE_MCP_ALLOWED_HOSTS=127.0.0.1:8080,localhost:8080,nginx +# CF_DATAPLANE_MCP_ALLOWED_ORIGINS=http://127.0.0.1:8080,http://localhost:8080 + # Guarded Fast Time contract and default registered virtual server. CF_FAST_TIME_EXPECTED_IMAGE=ghcr.io/ibm/cfex-mcp-fast-time-server:latest CF_FAST_TIME_SERVER_ID=9779b6698cbd4b4995ee04a4fab38737 # Public nginx port. Default: 8080. NGINX_PORT=8080 + +# Optional fixed loopback port for the direct conformance fixture. Zero asks +# Docker to allocate an ephemeral port. +# CF_CONFORMANCE_PORT=0 # Direct public-origin override; otherwise derived from NGINX_PORT. # MCP_CLI_BASE_URL=http://127.0.0.1:8080 -# Default MCP protocol version for probe, load, live, and Inspector workflows. -# Conformance run keeps its pinned 2026-07-28 default unless overridden. -MCP_PROTOCOL_VERSION=2025-11-25 +# Optional global MCP protocol override. Current probe/load/Inspector/live +# workflows default to the latest dataplane-compatible session protocol, +# 2025-11-25. Conformance keeps its pinned 2026-07-28 readiness default. +# MCP_PROTOCOL_VERSION=2026-07-28 -# Admin/test token identity. Stable random local secrets are created automatically -# under CF_INTEGRATION_DIR when these overrides are unset. -MCP_JWT_SUBJECT=admin@example.com +# Local integration administrator. Stable random signing/encryption secrets are +# created automatically under CF_INTEGRATION_DIR when their overrides are unset. PLATFORM_ADMIN_EMAIL=admin@example.com +PLATFORM_ADMIN_PASSWORD=changeme # JWT_SECRET_KEY= # AUTH_ENCRYPTION_SECRET= # Optional existing fixture and token overrides. Empty values use the harness -# Fast Time fixture and a Rust-generated mode-appropriate JWT. +# Fast Time fixture; managed dataplane workflows request and revoke a +# control-plane API token whose UUID subject matches the published snapshot. # MCP_SERVER_ID= # MCPGATEWAY_BEARER_TOKEN= diff --git a/README.md b/README.md index 62b8c26..a7ad800 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,11 @@ The public routing contract is fixed: `/contextforge-rs/servers/{virtual_host_id}/mcp`. - Raw `/mcp`, UI traffic, and API traffic stay on `cf-controlplane`. +The `/servers/{id}/mcp` route does not fall back to the Python control plane on +dataplane errors. This makes routing failures visible and keeps the harness +aligned with the planned split between legacy slow-path traffic and modern +Rust dataplane traffic. + The harness owns Docker Compose overlays, nginx routing, reproducible stack lifecycle, public-route probes, Locust and Goose load tests, and official MCP conformance orchestration. Generated checkout, build, and runtime state stays @@ -70,10 +75,13 @@ Single-lane commands resolve their lane in this order: 3. `dataplane`. They resolve the protocol version from explicit `--protocol-version`, then -`MCP_PROTOCOL_VERSION`, then `2025-11-25`. Live protocol tests and conformance -also accept `fixture-direct`; other workflows reject it because they have no -direct-fixture execution path. Conformance defaults to all three lanes and its -pinned `2026-07-28` protocol version. +`MCP_PROTOCOL_VERSION`, then `2025-11-25`. That session-oriented default is +the working contract of the current `latest` dataplane image. Pass +`--protocol-version 2026-07-28` explicitly to exercise the implemented +stateless readiness path as the future architecture lands. Live protocol tests +and conformance also accept `fixture-direct`; other workflows reject it +because they have no direct-fixture execution path. Conformance defaults to +all three lanes and its pinned `2026-07-28` protocol version. `--topology` remains a compatibility alias for `--lane` on workflows. Conformance also retains `--client-version` and `--spec-version` as aliases for @@ -85,7 +93,7 @@ because they operate on physical stacks, not test lanes. Probe the dataplane public MCP route: ```bash -cf-integration probe --lane dataplane --protocol-version 2025-11-25 +cf-integration probe --lane dataplane ``` `stack up` synchronizes the required source checkouts, validates the Compose @@ -168,12 +176,14 @@ fresh start. ### Probe ```bash -cf-integration probe --lane dataplane --protocol-version 2025-11-25 +cf-integration probe --lane dataplane ``` -The probe checks unauthenticated rejection, initialization, -`notifications/initialized`, session reuse, `tools/list`, and one known-safe -`tools/call`. It targets `/mcp` in controlplane topology and +The modern dataplane probe checks unauthenticated rejection, +`server/discover`, required per-request metadata and routing headers, +`tools/list`, and one known-safe `tools/call` without creating a session. The +legacy control-plane probe retains initialize, `notifications/initialized`, +and session reuse. It targets `/mcp` in controlplane topology and `/servers/{id}/mcp` in dataplane topology. ### Locust and Goose @@ -181,9 +191,9 @@ The probe checks unauthenticated rejection, initialization, Both load engines exercise the same MCP lifecycle and remain first-class: ```bash -cf-integration load --lane dataplane --protocol-version 2025-11-25 \ +cf-integration load --lane dataplane \ --engine locust --smoke -cf-integration load --lane dataplane --protocol-version 2025-11-25 \ +cf-integration load --lane dataplane \ --engine goose --smoke cf-integration load --lane dataplane --engine locust \ @@ -198,10 +208,12 @@ CLI settings override `.env`; explicitly exported `LOCUST_USERS`, engines. Smoke defaults are one user, one user/second, and ten seconds. Locust uses the framework-required Python adapter. Goose is the native Rust -runner. Both initialize real MCP sessions, send -`notifications/initialized`, discover tools, call only a finite allowlist of -safe fixture tools, exercise ping, and audit generated artifacts for credential -leakage. +runner. On the modern dataplane lane both use `server/discover`, attach the +mandatory client `_meta` plus `Mcp-Method`/`Mcp-Name` headers to every request, +and avoid sessions and the removed `ping` method. The legacy control-plane +lane retains initialize, `notifications/initialized`, session cleanup, and +ping. Both engines call only a finite allowlist of safe fixture tools and audit +generated artifacts for credential leakage. ### Upstream live tests @@ -220,6 +232,10 @@ cf-integration live \ --protocol-version 2025-06-18 ``` +`--group all` is the exact union of the `mcp`, `rbac`, and `protocol` groups. +Upstream plugin and SSO suites are excluded because this harness does not +start their additional services. + The `mcp` and `all` groups start the upstream profile-gated `fast_test_server`, run its one-shot registration job, and, for the dataplane topology, wait until the publisher snapshot contains its fixed virtual server before launching the @@ -234,9 +250,9 @@ cannot emit it. ## Official MCP conformance The official runner is pinned to -`@modelcontextprotocol/conformance@0.2.0-alpha.9`. The official TypeScript +`@modelcontextprotocol/conformance@0.2.0-alpha.11`. The official TypeScript fixture is built from matching source revision -`794dcab99ed1ef2b89607be9999574140ea5c96e`. +`c321dd32035556e6769d3724a8ee97d87c3faaac`. The default command is intentionally complete and reproducible: @@ -337,7 +353,6 @@ Debug commands are useful for manual diagnosis but are not compliance gates. ```bash cf-integration debug inspect \ --lane dataplane \ - --protocol-version 2025-11-25 \ --method tools/list cf-integration debug token \ @@ -347,9 +362,14 @@ cf-integration debug token \ cf-integration debug token --kind admin ``` -Inspector is pinned to `@modelcontextprotocol/inspector@0.22.0` and uses the -same loopback authentication proxy as conformance. The proxy applies the -selected protocol version to Inspector's initialize request. +Token generation now authenticates against a running control plane using +`PLATFORM_ADMIN_EMAIL` and `PLATFORM_ADMIN_PASSWORD`. Scoped debug tokens +are catalog-backed, restricted to the selected virtual server, expire after +one day, and are intentionally left active for manual use. + +Inspector is pinned to `@modelcontextprotocol/inspector@2.2.0` and uses the +same loopback authentication proxy as conformance. Select `2026-07-28` to use +its modern MCP SDK path for stateless dataplane requests. ## Configuration @@ -363,13 +383,13 @@ CF_MCP_STACK_MODE=dataplane CF_INTEGRATION_DIR=.integration CF_CONTROLPLANE_REPO=https://github.com/IBM/mcp-context-forge.git -CF_CONTROLPLANE_REF=v1.0.6 +CF_CONTROLPLANE_REF=v1.0.7 CF_CONTROLPLANE_IMAGE=ghcr.io/ibm/mcp-context-forge:latest CF_CONTROLPLANE_VERSION=latest -CF_DATAPLANE_REPO=https://github.com/contextforge-gateway-rs/contextforge-gateway-rs.git +CF_DATAPLANE_REPO=https://github.com/contextforge-org/contextforge-data-plane.git CF_DATAPLANE_REF= -CF_DATAPLANE_IMAGE=ghcr.io/contextforge-gateway-rs/contextforge-gateway-rs:0.1.0 +CF_DATAPLANE_IMAGE=ghcr.io/contextforge-org/contextforge-data-plane:latest CF_DATAPLANE_PLATFORM=auto CF_COMPOSE_BUILD=auto @@ -377,12 +397,14 @@ CF_FAST_TIME_EXPECTED_IMAGE=ghcr.io/ibm/cfex-mcp-fast-time-server:latest CF_FAST_TIME_SERVER_ID=9779b6698cbd4b4995ee04a4fab38737 MCP_CLI_BASE_URL=http://127.0.0.1:8080 -MCP_PROTOCOL_VERSION=2025-11-25 +# Optional global override; leave unset for the current 2025-11-25 default. +# MCP_PROTOCOL_VERSION=2026-07-28 NGINX_PORT=8080 ``` -Published control-plane and dataplane images are the defaults. The control-plane -checkout defaults to the release matching the current `latest` image. Set +Published control-plane and dataplane images are the defaults; the dataplane +uses its `latest` tag. The control-plane checkout defaults to v1.0.7, whose +publisher uses UUID token subjects and the current backend snapshot schema. Set `CF_DATAPLANE_REF` to build an explicit local dataplane ref. `CF_COMPOSE_BUILD=auto` pulls or reuses prebuilt images and rebuilds a missing or revision-stale source dataplane; `true` always builds and `false` never @@ -391,19 +413,48 @@ builds. Token and endpoint overrides used by probe, load, and debug commands: ```bash -# Optional overrides. Without them, stable random local values are generated -# once under CF_INTEGRATION_DIR. +# Optional overrides. Without them, stable random local signing values are +# generated once under CF_INTEGRATION_DIR. JWT_SECRET_KEY= AUTH_ENCRYPTION_SECRET= -MCP_JWT_SUBJECT=admin@example.com +PLATFORM_ADMIN_EMAIL=admin@example.com +PLATFORM_ADMIN_PASSWORD= MCPGATEWAY_BEARER_TOKEN= MCP_SERVER_ID= MCP_TOOL_NAMES= ``` +Managed workflows authenticate through the control-plane email-login endpoint. +Dataplane probe, load, Inspector, and conformance runs then request a one-day, +server-scoped API token from the token catalog and revoke it before stack +teardown. This ensures the token's UUID subject selects the same `UserConfig` +snapshot the publisher wrote. `MCPGATEWAY_BEARER_TOKEN` bypasses that +lifecycle and is never revoked by the harness. + Conformance ignores caller-managed fixture IDs and tokens so every lane uses the same official fixture. Never commit `.env` or generated tokens. +## Future architecture alignment + +The dataplane repository's tentative ContextForge 2.0 wiki describes a +management plane, a legacy Python MCP slow path, and a modern `2026-07-28` +Rust fast path consuming revisioned effective configuration from a shared +store. This harness prepares for that split by keeping management and raw +`/mcp` traffic on control-plane, routing `/servers/{id}/mcp` strictly to the +dataplane, providing explicit stateless modern probe/load/Inspector paths, and +obtaining dataplane credentials from the management plane. The ordinary +workflow default remains `2025-11-25` until the current upstream expected +failure baseline for stateless aggregate and targeted operations is retired. + +The remaining boundary belongs upstream rather than in this harness: +control-plane must publish atomic compiled configuration and perform discovery, +catalog normalization, pagination, and liveness; dataplane must serve aggregate +catalog methods from that configuration and route targeted operations to one +backend without live fan-out. When those phases land, the harness should add +revision-isolation and tenant/principal partition tests instead of compatibility +fallbacks. See the +[`_context/wiki` architecture notes](https://github.com/contextforge-org/contextforge-data-plane/tree/main/_context/wiki). + ## Repository layout ```text diff --git a/crates/compliance/src/conformance.rs b/crates/compliance/src/conformance.rs index 800307f..ad32639 100644 --- a/crates/compliance/src/conformance.rs +++ b/crates/compliance/src/conformance.rs @@ -124,11 +124,12 @@ pub fn is_trusted_official_fixture(fixture: Option<&ConformanceFixtureMetadata>) }) } -// Exact server catalogs emitted by @modelcontextprotocol/conformance@0.2.0-alpha.9. +// Exact server catalogs emitted by @modelcontextprotocol/conformance@0.2.0-alpha.11. // Keep these coupled to OFFICIAL_CONFORMANCE_PACKAGE and verify the pin with // the ignored package-backed test before updating either. -const SERVER_SCENARIOS_2025_06_18: [&str; 26] = [ +const SERVER_SCENARIOS_2025_06_18: [&str; 27] = [ "server-initialize", + "server-session-lifecycle", "logging-set-level", "ping", "completion-complete", diff --git a/crates/compliance/src/conformance_fixture.rs b/crates/compliance/src/conformance_fixture.rs index 21f7516..4109f4a 100644 --- a/crates/compliance/src/conformance_fixture.rs +++ b/crates/compliance/src/conformance_fixture.rs @@ -13,8 +13,10 @@ use url::Url; pub use crate::profile::{OFFICIAL_CONFORMANCE_REPOSITORY, OFFICIAL_CONFORMANCE_REVISION}; /// Docker Compose service name for the official conformance server. pub const OFFICIAL_CONFORMANCE_SERVICE: &str = "mcp_conformance_server"; -/// Backend URL reachable from the control-plane container. -pub const OFFICIAL_CONFORMANCE_BACKEND_URL: &str = "http://mcp_conformance_server:3000/mcp"; +/// Docker Compose service name for the fixture's backend-only Host proxy. +pub const OFFICIAL_CONFORMANCE_PROXY_SERVICE: &str = "mcp_conformance_proxy"; +/// Backend URL reachable from the control-plane and dataplane containers. +pub const OFFICIAL_CONFORMANCE_BACKEND_URL: &str = "http://mcp_conformance_proxy/mcp"; /// Reserved gateway name used by the fixture. /// /// `_` intentionally produces an empty gateway slug when paired with the @@ -221,6 +223,8 @@ impl ConformanceFixtureClient { "name": OFFICIAL_CONFORMANCE_GATEWAY_NAME, "url": backend_url, "transport": GATEWAY_TRANSPORT, + "authType": "authheaders", + "authHeaders": [{"key": "Host", "value": "localhost:3000"}], "description": GATEWAY_DESCRIPTION, }), ) diff --git a/crates/compliance/src/profile.rs b/crates/compliance/src/profile.rs index e7a61e5..74ba5b7 100644 --- a/crates/compliance/src/profile.rs +++ b/crates/compliance/src/profile.rs @@ -1,12 +1,12 @@ //! Coherent official conformance runner, fixture, and protocol pins. /// Published official CLI package used as the conformance client. -pub const OFFICIAL_CONFORMANCE_PACKAGE: &str = "@modelcontextprotocol/conformance@0.2.0-alpha.9"; +pub const OFFICIAL_CONFORMANCE_PACKAGE: &str = "@modelcontextprotocol/conformance@0.2.0-alpha.11"; /// Official repository containing the matching TypeScript fixture server. pub const OFFICIAL_CONFORMANCE_REPOSITORY: &str = "https://github.com/modelcontextprotocol/conformance"; /// Exact source revision behind the published CLI and TypeScript fixture. -pub const OFFICIAL_CONFORMANCE_REVISION: &str = "794dcab99ed1ef2b89607be9999574140ea5c96e"; +pub const OFFICIAL_CONFORMANCE_REVISION: &str = "c321dd32035556e6769d3724a8ee97d87c3faaac"; /// Default draft protocol revision exercised by official conformance commands. pub const DEFAULT_MCP_SPEC_VERSION: &str = "2026-07-28"; /// Previous stable revision supported by the pinned official conformance package. diff --git a/crates/compliance/tests/conformance.rs b/crates/compliance/tests/conformance.rs index 2722734..4f2a2be 100644 --- a/crates/compliance/tests/conformance.rs +++ b/crates/compliance/tests/conformance.rs @@ -149,9 +149,9 @@ fn pinned_server_scenario_catalog_has_exact_suite_differences() { let draft_all = expected_server_scenarios("all", "2026-07-28") .expect("draft all scenario catalog should be pinned"); - assert_eq!(stable_active.len(), 30); - assert_eq!(stable_all.len(), 32); - assert_eq!(previous.len(), 26); + assert_eq!(stable_active.len(), 31); + assert_eq!(stable_all.len(), 33); + assert_eq!(previous.len(), 27); assert_eq!(draft_active.len(), 20); assert_eq!(draft_all.len(), 40); assert_eq!( @@ -185,7 +185,7 @@ fn official_command_is_pinned_complete_and_ordered() { assert_eq!( OFFICIAL_CONFORMANCE_PACKAGE, - "@modelcontextprotocol/conformance@0.2.0-alpha.9" + "@modelcontextprotocol/conformance@0.2.0-alpha.11" ); assert_eq!(DEFAULT_MCP_SPEC_VERSION, "2026-07-28"); assert!(!spec.inherits_environment()); diff --git a/crates/compliance/tests/conformance_fixture.rs b/crates/compliance/tests/conformance_fixture.rs index 25ed8d9..313ace8 100644 --- a/crates/compliance/tests/conformance_fixture.rs +++ b/crates/compliance/tests/conformance_fixture.rs @@ -769,6 +769,8 @@ async fn provision_uses_authenticated_admin_api_in_exact_order() { "name":OFFICIAL_CONFORMANCE_GATEWAY_NAME, "url":OFFICIAL_CONFORMANCE_BACKEND_URL, "transport":"STREAMABLEHTTP", + "authType":"authheaders", + "authHeaders":[{"key":"Host", "value":"localhost:3000"}], "description":"Official MCP conformance fixture" }) ); diff --git a/crates/load/src/goose.rs b/crates/load/src/goose.rs index 6d0aac7..a743dad 100644 --- a/crates/load/src/goose.rs +++ b/crates/load/src/goose.rs @@ -27,8 +27,8 @@ use cf_integration_platform::config::AppConfig; use cf_integration_mcp::backend_identity::BackendIdentity; use cf_integration_mcp::mcp::{ - ACCEPT, PROTOCOL_VERSION, initialize_with_id_and_version, jsonrpc_with_id, parse_mcp_body, - tool_call_args, + ACCEPT, PROTOCOL_VERSION, initialize_with_id_and_version, is_stateless_protocol, + jsonrpc_with_id, parse_mcp_body, routing_name, stateless_jsonrpc_with_id, tool_call_args, }; use super::LoadSettings; @@ -409,6 +409,7 @@ struct UserSession { endpoint: String, bearer_token: BearerToken, session_id: Option, + ready: bool, callable_tools: Vec, next_tool: usize, next_request_id: u64, @@ -423,6 +424,7 @@ impl fmt::Debug for UserSession { .field("endpoint", &self.endpoint) .field("bearer_token", &self.bearer_token) .field("session_id", &self.session_id.as_ref().map(|_| "[PRESENT]")) + .field("ready", &self.ready) .field("callable_tools", &self.callable_tools) .field("next_tool", &self.next_tool) .field("next_request_id", &self.next_request_id) @@ -448,6 +450,7 @@ enum ExpectedStatus { fn build_scenario( shared: Arc, ) -> std::result::Result { + let stateless = is_stateless_protocol(&shared.protocol_version); let initialize_config = Arc::clone(&shared); let initialize: TransactionFunction = Arc::new(move |user| { let config = Arc::clone(&initialize_config); @@ -465,6 +468,7 @@ fn build_scenario( // can return a diagnostic-rich failure. if let Some(state) = user.get_session_data_mut::() { state.session_id = None; + state.ready = false; state.callable_tools.clear(); } Ok(()) @@ -476,7 +480,11 @@ fn build_scenario( let mut scenario = Scenario::new("MCP streamable HTTP").register_transaction( Transaction::new(initialize) - .set_name("initialize MCP session") + .set_name(if stateless { + "discover MCP server" + } else { + "initialize MCP session" + }) .set_on_start(), ); scenario = scenario.register_transaction( @@ -489,16 +497,18 @@ fn build_scenario( .set_name("call MCP tool") .set_weight(6)?, ); - scenario = scenario.register_transaction( - transaction(ping_server) - .set_name("ping MCP server") - .set_weight(1)?, - ); - scenario = scenario.register_transaction( - transaction(delete_session) - .set_name("delete MCP session") - .set_on_stop(), - ); + if !stateless { + scenario = scenario.register_transaction( + transaction(ping_server) + .set_name("ping MCP server") + .set_weight(1)?, + ); + scenario = scenario.register_transaction( + transaction(delete_session) + .set_name("delete MCP session") + .set_on_stop(), + ); + } Ok(scenario) } @@ -526,6 +536,7 @@ async fn initialize_user(user: &mut GooseUser, config: &SharedRunConfig) -> Tran endpoint: config.endpoint.clone(), bearer_token: config.bearer_token.clone(), session_id: None, + ready: false, callable_tools: Vec::new(), next_tool: 0, next_request_id: 2, @@ -535,15 +546,28 @@ async fn initialize_user(user: &mut GooseUser, config: &SharedRunConfig) -> Tran let state = session(user)?.clone(); let request_id = json!(1); - let payload = - initialize_with_id_and_version(request_id.clone(), config.protocol_version.as_str()); + let stateless = is_stateless_protocol(&config.protocol_version); + let payload = if stateless { + stateless_jsonrpc_with_id( + "server/discover", + None, + request_id.clone(), + &config.protocol_version, + ) + } else { + initialize_with_id_and_version(request_id.clone(), config.protocol_version.as_str()) + }; let mut response = send_mcp_request( user, &state, GooseMethod::Post, Some(&payload), false, - "initialize", + if stateless { + "server/discover" + } else { + "initialize" + }, ExpectedStatus::Ok, ) .await?; @@ -551,6 +575,34 @@ async fn initialize_user(user: &mut GooseUser, config: &SharedRunConfig) -> Tran Ok(result) => result, Err(tag) => return fail_request(user, &mut response.request, tag), }; + if stateless { + let supported = result + .get("supportedVersions") + .and_then(Value::as_array) + .is_some_and(|versions| { + versions + .iter() + .any(|version| version.as_str() == Some(config.protocol_version.as_str())) + }); + if !supported + || result + .get("capabilities") + .and_then(Value::as_object) + .is_none() + || result.get("resultType").and_then(Value::as_str).is_none() + || result.get("cacheScope").and_then(Value::as_str).is_none() + || result.get("ttlMs").and_then(Value::as_u64).is_none() + { + return fail_request( + user, + &mut response.request, + "server/discover response is missing required stateless fields", + ); + } + session_mut(user)?.ready = true; + return list_tools(user).await; + } + if result.get("protocolVersion").and_then(Value::as_str) != Some(config.protocol_version.as_str()) { @@ -604,7 +656,9 @@ async fn initialize_user(user: &mut GooseUser, config: &SharedRunConfig) -> Tran ); } }; - session_mut(user)?.session_id = Some(session_id); + let state = session_mut(user)?; + state.session_id = Some(session_id); + state.ready = true; let state = session(user)?.clone(); let notification = json!({ @@ -627,11 +681,11 @@ async fn initialize_user(user: &mut GooseUser, config: &SharedRunConfig) -> Tran fn list_tools(user: &mut GooseUser) -> transaction_future::TransactionFuture<'_> { Box::pin(async move { - if session(user)?.session_id.is_none() { + if !session(user)?.ready { return dormant_user().await; } let (state, request_id) = next_request(user)?; - let payload = jsonrpc_with_id("tools/list", None, request_id.clone()); + let payload = request_payload(&state, "tools/list", None, request_id.clone()); let mut response = send_mcp_request( user, &state, @@ -691,7 +745,7 @@ fn list_tools(user: &mut GooseUser) -> transaction_future::TransactionFuture<'_> fn call_tool(user: &mut GooseUser) -> transaction_future::TransactionFuture<'_> { Box::pin(async move { - if session(user)?.session_id.is_none() { + if !session(user)?.ready { return dormant_user().await; } let (state, request_id, name) = next_tool_call(user)?; @@ -700,7 +754,8 @@ fn call_tool(user: &mut GooseUser) -> transaction_future::TransactionFuture<'_> "selected MCP tool is not on the safe allowlist", )); }; - let payload = jsonrpc_with_id( + let payload = request_payload( + &state, "tools/call", Some(json!({"name": name, "arguments": arguments})), request_id.clone(), @@ -736,7 +791,7 @@ fn call_tool(user: &mut GooseUser) -> transaction_future::TransactionFuture<'_> fn ping_server(user: &mut GooseUser) -> transaction_future::TransactionFuture<'_> { Box::pin(async move { - if session(user)?.session_id.is_none() { + if !session(user)?.ready { return dormant_user().await; } let (state, request_id) = next_request(user)?; @@ -776,6 +831,7 @@ fn delete_session(user: &mut GooseUser) -> transaction_future::TransactionFuture .await?; let state = session_mut(user)?; state.session_id = None; + state.ready = false; state.callable_tools.clear(); Ok(()) }) @@ -837,6 +893,19 @@ fn next_tool_call( Ok((state.clone(), json!(request_id), name)) } +fn request_payload( + state: &UserSession, + method: &str, + params: Option, + request_id: Value, +) -> Value { + if is_stateless_protocol(&state.protocol_version) { + stateless_jsonrpc_with_id(method, params, request_id, &state.protocol_version) + } else { + jsonrpc_with_id(method, params, request_id) + } +} + async fn send_mcp_request( user: &mut GooseUser, state: &UserSession, @@ -855,7 +924,20 @@ async fn send_mcp_request( .header("content-type", "application/json") .body(payload.to_string()); } - if include_session { + if is_stateless_protocol(&state.protocol_version) { + request_builder = + request_builder.header("mcp-protocol-version", state.protocol_version.as_str()); + if let Some(method) = payload + .and_then(|payload| payload.get("method")) + .and_then(Value::as_str) + { + request_builder = request_builder.header("mcp-method", method); + if let Some(name) = routing_name(method, payload.and_then(|value| value.get("params"))) + { + request_builder = request_builder.header("mcp-name", name); + } + } + } else if include_session { let session_id = state .session_id .as_deref() diff --git a/crates/load/tests/load_goose.rs b/crates/load/tests/load_goose.rs index 04cf412..34ca637 100644 --- a/crates/load/tests/load_goose.rs +++ b/crates/load/tests/load_goose.rs @@ -10,7 +10,7 @@ use axum::extract::State; use axum::http::{HeaderMap, Method, Request, Response, StatusCode}; use axum::routing::any; use cf_integration_load::{GooseLoadConfig, GooseRunError, LoadEngine, LoadRequest, LoadSettings}; -use cf_integration_mcp::mcp::{ACCEPT, PROTOCOL_VERSION}; +use cf_integration_mcp::mcp::{ACCEPT, PROTOCOL_VERSION, STATELESS_PROTOCOL_VERSION}; use cf_integration_platform::StackMode; use cf_integration_platform::config::{AppConfig, Environment}; use serde_json::{Value, json}; @@ -182,6 +182,9 @@ struct Observation { authenticated: bool, session: Option, protocol_version: Option, + routing_method: Option, + routing_name: Option, + metadata_protocol_version: Option, initialize_protocol_version: Option, called_tool: Option, } @@ -215,6 +218,15 @@ async fn mcp_handler(State(state): State, request: Request) -> == Some("Bearer secret.goose.jwt"), session: header(&parts.headers, "mcp-session-id"), protocol_version: header(&parts.headers, "mcp-protocol-version"), + routing_method: header(&parts.headers, "mcp-method"), + routing_name: header(&parts.headers, "mcp-name"), + metadata_protocol_version: payload + .as_ref() + .and_then(|value| { + value.pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion") + }) + .and_then(Value::as_str) + .map(str::to_owned), initialize_protocol_version: payload .as_ref() .and_then(|value| value.pointer("/params/protocolVersion")) @@ -247,6 +259,32 @@ async fn mcp_handler(State(state): State, request: Request) -> }; let id = payload.get("id").cloned().unwrap_or(Value::Null); match rpc_method.as_deref() { + Some("server/discover") => { + if !has_stateless_headers( + &parts.headers, + &payload, + "server/discover", + None, + state.protocol_version, + ) { + return response( + StatusCode::BAD_REQUEST, + "text/plain", + "bad discover request", + ); + } + json_response(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "supportedVersions": [state.protocol_version], + "capabilities": {"tools": {}}, + "resultType": "complete", + "cacheScope": "private", + "ttlMs": 0 + } + })) + } Some("initialize") => { if header(&parts.headers, "mcp-session-id").is_some() || header(&parts.headers, "mcp-protocol-version").is_some() @@ -302,7 +340,18 @@ async fn mcp_handler(State(state): State, request: Request) -> } } Some("tools/list") => { - if !has_session_headers(&parts.headers, state.protocol_version) { + let valid_headers = if state.protocol_version == STATELESS_PROTOCOL_VERSION { + has_stateless_headers( + &parts.headers, + &payload, + "tools/list", + None, + state.protocol_version, + ) + } else { + has_session_headers(&parts.headers, state.protocol_version) + }; + if !valid_headers { return response(StatusCode::BAD_REQUEST, "text/plain", "bad list headers"); } json_response(json!({ @@ -317,7 +366,18 @@ async fn mcp_handler(State(state): State, request: Request) -> })) } Some("tools/call") => { - if !has_session_headers(&parts.headers, state.protocol_version) + let valid_headers = if state.protocol_version == STATELESS_PROTOCOL_VERSION { + has_stateless_headers( + &parts.headers, + &payload, + "tools/call", + Some("echo"), + state.protocol_version, + ) + } else { + has_session_headers(&parts.headers, state.protocol_version) + }; + if !valid_headers || payload.pointer("/params/name").and_then(Value::as_str) != Some("echo") || payload .pointer("/params/arguments/message") @@ -376,6 +436,23 @@ fn has_session_headers(headers: &HeaderMap, protocol_version: &str) -> bool { && header(headers, "mcp-protocol-version").as_deref() == Some(protocol_version) } +fn has_stateless_headers( + headers: &HeaderMap, + payload: &Value, + method: &str, + name: Option<&str>, + protocol_version: &str, +) -> bool { + header(headers, "mcp-session-id").is_none() + && header(headers, "mcp-protocol-version").as_deref() == Some(protocol_version) + && header(headers, "mcp-method").as_deref() == Some(method) + && header(headers, "mcp-name").as_deref() == name + && payload + .pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion") + .and_then(Value::as_str) + == Some(protocol_version) +} + fn response(status: StatusCode, content_type: &str, body: &str) -> Response { Response::builder() .status(status) @@ -686,6 +763,73 @@ async fn execute_emits_the_selected_protocol_version_in_body_and_headers() { ); } +#[tokio::test] +async fn execute_runs_the_stateless_dataplane_lifecycle_with_routing_metadata() { + let (host, state, server) = spawn_mock_with_options( + true, + false, + false, + Some("dataplane"), + StatusCode::NO_CONTENT, + None, + STATELESS_PROTOCOL_VERSION, + ) + .await; + let root = repository_root(); + let config = app_config(root.path(), &environment(&[("MCP_CLI_BASE_URL", &host)])); + let settings = load_settings(&config, "1s"); + let goose = GooseLoadConfig::new_with_protocol_version( + &config, + StackMode::Dataplane, + &settings, + TOKEN, + Some("server"), + STATELESS_PROTOCOL_VERSION, + ) + .expect("stateless Goose configuration should build"); + + let outcome = goose + .execute() + .await + .expect("stateless dataplane flow should pass"); + server.abort(); + assert_eq!(outcome.failed_requests(), 0); + assert_eq!(outcome.failed_transactions(), 0); + + let observations = state + .observations + .lock() + .expect("mock observation lock should not be poisoned"); + assert_eq!( + observations[0].rpc_method.as_deref(), + Some("server/discover") + ); + assert!(observations.iter().all(|value| value.session.is_none())); + assert!(observations.iter().all(|value| { + value.protocol_version.as_deref() == Some(STATELESS_PROTOCOL_VERSION) + && value.routing_method == value.rpc_method + && value.metadata_protocol_version.as_deref() == Some(STATELESS_PROTOCOL_VERSION) + })); + assert!(observations.iter().all(|value| { + value.routing_name.as_deref() + == if value.rpc_method.as_deref() == Some("tools/call") { + Some("echo") + } else { + None + } + })); + assert!( + observations + .iter() + .all(|value| value.http_method == Method::POST) + ); + assert!( + observations + .iter() + .all(|value| value.rpc_method.as_deref() != Some("ping")) + ); +} + #[tokio::test] async fn dataplane_goose_rejects_absent_fallback_forged_and_duplicate_backend_markers() { for marker in [ diff --git a/crates/load/tests/python_adapters.rs b/crates/load/tests/python_adapters.rs index 879303e..025be35 100644 --- a/crates/load/tests/python_adapters.rs +++ b/crates/load/tests/python_adapters.rs @@ -106,6 +106,7 @@ sse = "data: not-json\r\n\r\n" + sse assert adapter.parse_mcp_body(sse, "text/event-stream; charset=utf-8") == { "jsonrpc": "2.0", "id": "1", "result": {} } + assert adapter.safe_diagnostic("reflected token and session-id") == "reflected and session-id" assert adapter.tool_call_args("echo") == {"message": "cf-integration"} @@ -158,6 +159,113 @@ assert empty_environment.process_exit_code == 1 ); } +#[test] +fn locust_adapter_emits_stateless_metadata_and_routing_headers() { + let stub = locust_stub(); + let python_path = std::env::join_paths([stub.path(), scripts_dir().as_path()]) + .expect("Python path should join"); + let code = r#" +import json +import locustfile_mcp as adapter + +assert adapter.STATELESS +assert adapter.PROTOCOL_VERSION == "2026-07-28" + +class FakeResponse: + def __init__(self, payload): + self.status_code = 200 + self.headers = { + "Content-Type": "application/json", + "X-CF-Integration-Backend": "dataplane", + } + self.text = json.dumps({ + "jsonrpc": "2.0", + "id": payload["id"], + "result": {"content": [], "isError": False}, + }) + self.content = self.text.encode() + self.failures = [] + self.successes = 0 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def failure(self, detail): + self.failures.append(detail) + + def success(self): + self.successes += 1 + +class FakeClient: + def __init__(self): + self.requests = [] + + def post(self, path, *, data, headers, **_kwargs): + payload = json.loads(data) + self.requests.append((path, payload, headers)) + return FakeResponse(payload) + + def delete(self, *_args, **_kwargs): + raise AssertionError("stateless lifecycle must not delete a session") + +user = adapter.MCPGatewayUser.__new__(adapter.MCPGatewayUser) +user._session_id = None +user._ready = True +user.client = FakeClient() +result = user._mcp_request( + "tools/call", + {"name": "echo", "arguments": {"message": "hello"}}, + name="tools/call", +) +assert result == {"content": [], "isError": False} +path, payload, headers = user.client.requests[0] +assert path == "/servers/server-id/mcp" +assert payload["params"]["_meta"] == { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "cf-integration-locust", "version": "1.0" + }, + "io.modelcontextprotocol/clientCapabilities": {}, +} +assert headers["Mcp-Protocol-Version"] == "2026-07-28" +assert headers["Mcp-Method"] == "tools/call" +assert headers["Mcp-Name"] == "echo" +assert "Mcp-Session-Id" not in headers +user.on_stop() +before = len(user.client.requests) +user.ping() +assert len(user.client.requests) == before + +adapter.validate_result("server/discover", { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "resultType": "complete", + "cacheScope": "private", + "ttlMs": 0, +}) +"#; + + let output = Command::new(python()) + .arg("-c") + .arg(code) + .env("PYTHONPATH", python_path) + .env("MCP_SERVER_ID", "server-id") + .env("MCPGATEWAY_BEARER_TOKEN", "token") + .env("MCP_PROTOCOL_VERSION", "2026-07-28") + .output() + .expect("Python stateless adapter check should run"); + + assert!( + output.status.success(), + "Python stateless adapter check failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn locust_adapter_validates_and_applies_timeout_to_every_request() { let stub = locust_stub(); diff --git a/crates/mcp/src/auth_proxy.rs b/crates/mcp/src/auth_proxy.rs index 24f3f67..76260fa 100644 --- a/crates/mcp/src/auth_proxy.rs +++ b/crates/mcp/src/auth_proxy.rs @@ -8,7 +8,7 @@ use axum::Router; use axum::body::{Body, Bytes, to_bytes}; use axum::extract::{Request, State}; use axum::http::header::{ - AUTHORIZATION, CONNECTION, CONTENT_LENGTH, HOST, HeaderName, HeaderValue, + AUTHORIZATION, CONNECTION, CONTENT_LENGTH, HOST, HeaderName, HeaderValue, ORIGIN, }; use axum::http::{HeaderMap, Method, Response, StatusCode}; use reqwest::Client; @@ -225,6 +225,7 @@ async fn forward(State(state): State>, request: Request) -> Resp // mutated Host values remain untouched for the rebinding scenario. headers.remove(HOST); } + rewrite_loopback_origin(&mut headers, &state.loopback_authority, &state.upstream); headers.insert(AUTHORIZATION, state.authorization.clone()); let upstream_response = match state @@ -256,6 +257,18 @@ async fn forward(State(state): State>, request: Request) -> Resp response } +fn rewrite_loopback_origin(headers: &mut HeaderMap, loopback_authority: &str, upstream: &Url) { + let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) else { + return; + }; + if origin != format!("http://{loopback_authority}") { + return; + } + if let Ok(value) = HeaderValue::from_str(&upstream.origin().ascii_serialization()) { + headers.insert(ORIGIN, value); + } +} + fn rewrite_initialize_protocol_version(body: Bytes, protocol_version: &str) -> Bytes { let Ok(mut request) = serde_json::from_slice::(&body) else { return body; diff --git a/crates/mcp/src/http_transport.rs b/crates/mcp/src/http_transport.rs index 5ead64f..f5b8069 100644 --- a/crates/mcp/src/http_transport.rs +++ b/crates/mcp/src/http_transport.rs @@ -9,7 +9,7 @@ use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderValue}; use url::Url; use crate::backend_identity::{BackendIdentity, is_dataplane_endpoint}; -use crate::mcp::{ACCEPT as MCP_ACCEPT, parse_mcp_body}; +use crate::mcp::{ACCEPT as MCP_ACCEPT, is_stateless_protocol, parse_mcp_body, routing_name}; use crate::probe::{ProbeRequest, ProbeResponse, ProbeTransport}; const REDACTED: &str = ""; @@ -65,6 +65,20 @@ impl ProbeTransport for ReqwestProbeTransport { let protocol_version = safe_header(protocol_version, "MCP-Protocol-Version")?; builder = builder.header("MCP-Protocol-Version", protocol_version); } + if request + .protocol_version + .as_deref() + .is_some_and(is_stateless_protocol) + && let Some(method) = request + .payload + .get("method") + .and_then(serde_json::Value::as_str) + { + builder = builder.header("MCP-Method", safe_header(method, "MCP-Method")?); + if let Some(name) = routing_name(method, request.payload.get("params")) { + builder = builder.header("MCP-Name", safe_header(name, "MCP-Name")?); + } + } if let Some(token) = request.bearer_token.as_deref() { let mut authorization = safe_header(&format!("Bearer {token}"), "Authorization")?; authorization.set_sensitive(true); diff --git a/crates/mcp/src/mcp.rs b/crates/mcp/src/mcp.rs index 3d69ec8..21caa6c 100644 --- a/crates/mcp/src/mcp.rs +++ b/crates/mcp/src/mcp.rs @@ -3,8 +3,10 @@ use serde_json::{Map, Value, json}; use uuid::Uuid; -/// MCP protocol version sent by the integration harness. +/// Legacy session-oriented MCP protocol version used by the control-plane lane. pub const PROTOCOL_VERSION: &str = "2025-11-25"; +/// Stateless MCP protocol version used by the modern dataplane lane. +pub const STATELESS_PROTOCOL_VERSION: &str = "2026-07-28"; /// Accepted MCP streamable-HTTP response media types. pub const ACCEPT: &str = "application/json, text/event-stream"; @@ -27,6 +29,72 @@ pub fn jsonrpc_with_id(method: &str, params: Option, id: Value) -> Value Value::Object(payload) } +/// Returns whether a date-based MCP revision uses the stateless request lifecycle. +#[must_use] +pub fn is_stateless_protocol(protocol_version: &str) -> bool { + protocol_version >= STATELESS_PROTOCOL_VERSION +} + +/// Builds the mandatory per-request metadata for stateless MCP requests. +#[must_use] +pub fn request_metadata(protocol_version: &str) -> Value { + json!({ + "io.modelcontextprotocol/protocolVersion": protocol_version, + "io.modelcontextprotocol/clientInfo": { + "name": "cf-integration", + "version": "1.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }) +} + +/// Adds mandatory stateless metadata to an object-shaped request `params` value. +#[must_use] +pub fn with_request_metadata(params: Option, protocol_version: &str) -> Value { + let mut params = match params { + Some(Value::Object(params)) => params, + _ => Map::new(), + }; + let mut metadata = match params.remove("_meta") { + Some(Value::Object(metadata)) => metadata, + _ => Map::new(), + }; + let required = request_metadata(protocol_version) + .as_object() + .expect("request metadata is always an object") + .clone(); + metadata.extend(required); + params.insert("_meta".to_owned(), Value::Object(metadata)); + Value::Object(params) +} + +/// Builds a stateless JSON-RPC request with mandatory per-request metadata. +#[must_use] +pub fn stateless_jsonrpc_with_id( + method: &str, + params: Option, + id: Value, + protocol_version: &str, +) -> Value { + jsonrpc_with_id( + method, + Some(with_request_metadata(params, protocol_version)), + id, + ) +} + +/// Returns the MCP routing-name header value for name-targeted methods. +#[must_use] +pub fn routing_name<'a>(method: &str, params: Option<&'a Value>) -> Option<&'a str> { + let params = params?.as_object()?; + match method { + "tools/call" | "prompts/get" => params.get("name")?.as_str(), + "resources/read" => params.get("uri")?.as_str(), + "tasks/get" | "tasks/update" | "tasks/cancel" => params.get("taskId")?.as_str(), + _ => None, + } +} + /// Builds an MCP initialize request with a generated v4 UUID string ID. #[must_use] pub fn initialize() -> Value { diff --git a/crates/mcp/src/probe.rs b/crates/mcp/src/probe.rs index 113ffc6..4fe8f44 100644 --- a/crates/mcp/src/probe.rs +++ b/crates/mcp/src/probe.rs @@ -12,7 +12,10 @@ use url::Url; use crate::GatewayTopology; use crate::backend_identity::BackendIdentity; -use crate::mcp::{initialize_with_id_and_version, jsonrpc_with_id, tool_call_args}; +use crate::mcp::{ + initialize_with_id_and_version, is_stateless_protocol, jsonrpc_with_id, + stateless_jsonrpc_with_id, tool_call_args, +}; const REDACTED: &str = ""; const INITIALIZE_ID: u64 = 1; @@ -167,6 +170,10 @@ pub async fn run_probe( "failed to write probe URL", )?; + if is_stateless_protocol(&config.protocol_version) { + return run_stateless_probe(transport, config, output, url).await; + } + let initialize_payload = initialize_with_id_and_version(json!(INITIALIZE_ID), &config.protocol_version); let unauthenticated = post_with_timeout( @@ -387,6 +394,225 @@ pub async fn run_probe( Ok(()) } +async fn run_stateless_probe( + transport: &T, + config: &ProbeConfig, + output: &mut W, + url: String, +) -> Result<()> { + let discover_payload = stateless_jsonrpc_with_id( + "server/discover", + None, + json!(INITIALIZE_ID), + &config.protocol_version, + ); + let unauthenticated = post_with_timeout( + transport, + ProbeRequest { + url: url.clone(), + payload: discover_payload.clone(), + bearer_token: None, + session_id: None, + protocol_version: Some(config.protocol_version.clone()), + }, + config.request_timeout, + "auth_negative", + config.mode, + ) + .await?; + if unauthenticated.status != 401 { + bail!( + "auth_negative=FAIL expected 401 without Authorization, got {}", + unauthenticated.status + ); + } + write_line( + output, + "auth_negative=PASS status=401", + "failed to write negative authentication result", + )?; + + let started = Instant::now(); + let authenticated = loop { + let attempt_timeout = if config.config_timeout.is_zero() { + config.request_timeout + } else { + config + .request_timeout + .min(config.config_timeout.saturating_sub(started.elapsed())) + }; + let response = post_with_timeout( + transport, + ProbeRequest { + url: url.clone(), + payload: discover_payload.clone(), + bearer_token: Some(config.bearer_token.clone()), + session_id: None, + protocol_version: Some(config.protocol_version.clone()), + }, + attempt_timeout, + "server_discover", + config.mode, + ) + .await?; + if response.status == 200 + || config.config_timeout.is_zero() + || started.elapsed() >= config.config_timeout + { + break response; + } + write_line( + output, + &format!( + "server_discover=RETRY status={} (waiting for dataplane config)", + response.status + ), + "failed to write server discovery retry result", + )?; + let remaining = config.config_timeout.saturating_sub(started.elapsed()); + if remaining.is_zero() { + break response; + } + tokio::time::sleep(config.retry_interval.max(MIN_RETRY_INTERVAL).min(remaining)).await; + if started.elapsed() >= config.config_timeout { + break response; + } + }; + + let discovery = result_of("server_discover", &authenticated, INITIALIZE_ID)?; + let supports_version = discovery + .get("supportedVersions") + .and_then(Value::as_array) + .is_some_and(|versions| { + versions + .iter() + .any(|version| version.as_str() == Some(config.protocol_version.as_str())) + }); + if !supports_version { + bail!("server_discover=FAIL requested protocol version is not advertised by the server"); + } + if discovery + .get("capabilities") + .and_then(Value::as_object) + .is_none() + || discovery + .get("resultType") + .and_then(Value::as_str) + .is_none() + || discovery + .get("cacheScope") + .and_then(Value::as_str) + .is_none() + || discovery.get("ttlMs").and_then(Value::as_u64).is_none() + { + bail!("server_discover=FAIL response is missing required discovery fields"); + } + write_line( + output, + "server_discover=PASS status=200 lifecycle=stateless", + "failed to write server discovery result", + )?; + + let tools_response = post_with_timeout( + transport, + ProbeRequest { + url: url.clone(), + payload: stateless_jsonrpc_with_id( + "tools/list", + Some(json!({})), + json!(TOOLS_LIST_ID), + &config.protocol_version, + ), + bearer_token: Some(config.bearer_token.clone()), + session_id: None, + protocol_version: Some(config.protocol_version.clone()), + }, + config.request_timeout, + "tools_list", + config.mode, + ) + .await?; + let tools_result = result_of("tools_list", &tools_response, TOOLS_LIST_ID)?; + let tools = tools_result + .get("tools") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("tools_list=FAIL unexpected response: missing tools array"))?; + if tools.is_empty() { + bail!("tools_list=FAIL no tools returned"); + } + let mut tool_names = Vec::with_capacity(tools.len()); + for tool in tools { + let Some(name) = tool + .as_object() + .and_then(|tool| tool.get("name")) + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + else { + bail!("tools_list=FAIL every tool must have a nonempty name"); + }; + tool_names.push(name); + } + write_line( + output, + &format!("tools_list=PASS count={}", tool_names.len()), + "failed to write tools list result", + )?; + for name in &tool_names { + write_line( + output, + &format!("tool={}", sanitize_for_output(name)), + "failed to write tool name", + )?; + } + + let callable = tool_names + .iter() + .find_map(|name| tool_call_args(name).map(|arguments| (*name, arguments))); + let Some((tool_name, arguments)) = callable else { + write_line( + output, + "tool_call=SKIP no echo/get_system_time tool available", + "failed to write tool call skip result", + )?; + return Ok(()); + }; + let call_response = post_with_timeout( + transport, + ProbeRequest { + url, + payload: stateless_jsonrpc_with_id( + "tools/call", + Some(json!({"name": tool_name, "arguments": arguments})), + json!(TOOL_CALL_ID), + &config.protocol_version, + ), + bearer_token: Some(config.bearer_token.clone()), + session_id: None, + protocol_version: Some(config.protocol_version.clone()), + }, + config.request_timeout, + "tool_call", + config.mode, + ) + .await?; + let call_result = result_of("tool_call", &call_response, TOOL_CALL_ID)?; + if !matches!(call_result.get("content"), Some(Value::Array(_))) { + bail!("tool_call=FAIL result must contain a content array"); + } + if call_result + .get("isError") + .is_some_and(|value| value != &Value::Bool(false)) + { + bail!("tool_call=FAIL tool returned error or a malformed isError value"); + } + write_line( + output, + &format!("tool_call=PASS tool={}", sanitize_for_output(tool_name)), + "failed to write tool call result", + )?; + Ok(()) +} + async fn post_with_timeout( transport: &T, request: ProbeRequest, diff --git a/crates/mcp/tests/auth_proxy.rs b/crates/mcp/tests/auth_proxy.rs index c4ec137..2253bde 100644 --- a/crates/mcp/tests/auth_proxy.rs +++ b/crates/mcp/tests/auth_proxy.rs @@ -141,6 +141,7 @@ async fn injects_auth_and_preserves_mcp_request_and_response_contract() { .header("mcp-session-id", "client-session") .header("mcp-protocol-version", "2025-11-25") .header("x-end-to-end", "preserve-me") + .header("origin", proxy.url().origin().ascii_serialization()) .header(CONNECTION, "x-remove-me") .header("x-remove-me", "must-not-be-forwarded") .body(r#"{"jsonrpc":"2.0","id":1}"#) @@ -214,6 +215,13 @@ async fn injects_auth_and_preserves_mcp_request_and_response_contract() { request.headers.get("x-end-to-end"), Some(&HeaderValue::from_static("preserve-me")) ); + assert_eq!( + request + .headers + .get("origin") + .and_then(|value| value.to_str().ok()), + Some(upstream.url.origin().ascii_serialization().as_str()) + ); assert!(request.headers.get("x-remove-me").is_none()); assert_eq!(request.body, r#"{"jsonrpc":"2.0","id":1}"#); diff --git a/crates/mcp/tests/backend_identity.rs b/crates/mcp/tests/backend_identity.rs index ffd4cd3..7052465 100644 --- a/crates/mcp/tests/backend_identity.rs +++ b/crates/mcp/tests/backend_identity.rs @@ -85,7 +85,8 @@ fn dataplane_nginx_replaces_upstream_markers_at_every_public_backend_boundary() assert!(nginx.contains("proxy_hide_header X-CF-Integration-Backend;")); assert!(nginx.contains("add_header X-CF-Integration-Backend dataplane always;")); - assert!(nginx.contains("add_header X-CF-Integration-Backend controlplane-fallback always;")); + assert!(!nginx.contains("controlplane_mcp_fallback")); + assert!(!nginx.contains("proxy_intercept_errors on;")); assert!(nginx.contains("add_header X-CF-Integration-Backend controlplane always;")); let primary = nginx diff --git a/crates/mcp/tests/http_transport.rs b/crates/mcp/tests/http_transport.rs index 4473c71..d8ef8a7 100644 --- a/crates/mcp/tests/http_transport.rs +++ b/crates/mcp/tests/http_transport.rs @@ -144,6 +144,49 @@ async fn omits_optional_auth_session_and_protocol_headers() { let _ = shutdown.send(()); } +#[tokio::test] +async fn stateless_requests_send_method_and_target_name_headers() { + let capture = Capture::default(); + let (url, shutdown) = server( + Router::new() + .route("/mcp", any(json_handler)) + .with_state(capture.clone()), + ) + .await; + let mut request = request(url); + request.payload = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "echo", "arguments": {}} + }); + request.protocol_version = Some("2026-07-28".to_owned()); + request.session_id = None; + + ReqwestProbeTransport::new() + .expect("transport") + .post(request) + .await + .expect("request should succeed"); + + let captured = capture.0.lock().expect("capture lock"); + let headers = &captured[0].0; + assert_eq!( + headers + .get("mcp-method") + .and_then(|value| value.to_str().ok()), + Some("tools/call") + ); + assert_eq!( + headers + .get("mcp-name") + .and_then(|value| value.to_str().ok()), + Some("echo") + ); + assert!(headers.get("mcp-session-id").is_none()); + let _ = shutdown.send(()); +} + #[tokio::test] async fn parses_blank_delimited_multiline_sse() { async fn sse() -> Response { diff --git a/crates/mcp/tests/mcp.rs b/crates/mcp/tests/mcp.rs index 74942f1..c248693 100644 --- a/crates/mcp/tests/mcp.rs +++ b/crates/mcp/tests/mcp.rs @@ -1,6 +1,7 @@ use cf_integration_mcp::mcp::{ - ACCEPT, PROTOCOL_VERSION, initialize, initialize_with_id, initialize_with_id_and_version, - jsonrpc, jsonrpc_with_id, parse_mcp_body, tool_call_args, + ACCEPT, PROTOCOL_VERSION, STATELESS_PROTOCOL_VERSION, initialize, initialize_with_id, + initialize_with_id_and_version, is_stateless_protocol, jsonrpc, jsonrpc_with_id, + parse_mcp_body, routing_name, stateless_jsonrpc_with_id, tool_call_args, }; use serde_json::{Value, json}; use uuid::Uuid; @@ -8,9 +9,46 @@ use uuid::Uuid; #[test] fn protocol_constants_match_the_streamable_http_contract() { assert_eq!(PROTOCOL_VERSION, "2025-11-25"); + assert_eq!(STATELESS_PROTOCOL_VERSION, "2026-07-28"); assert_eq!(ACCEPT, "application/json, text/event-stream"); } +#[test] +fn stateless_requests_carry_complete_metadata_and_preserve_existing_params() { + let request = stateless_jsonrpc_with_id( + "tools/call", + Some(json!({ + "name": "echo", + "arguments": {"message": "hello"}, + "_meta": {"extension.example/trace": "trace-1"} + })), + json!(9), + STATELESS_PROTOCOL_VERSION, + ); + + assert_eq!(request["method"], "tools/call"); + assert_eq!(request["params"]["name"], "echo"); + assert_eq!( + request["params"]["_meta"], + json!({ + "extension.example/trace": "trace-1", + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "cf-integration", + "version": "1.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }) + ); + assert_eq!( + routing_name("tools/call", request.get("params")), + Some("echo") + ); + assert!(is_stateless_protocol("2026-07-28")); + assert!(is_stateless_protocol("2027-01-01")); + assert!(!is_stateless_protocol("2025-11-25")); +} + #[test] fn deterministic_jsonrpc_omits_absent_params() { assert_eq!( diff --git a/crates/mcp/tests/probe.rs b/crates/mcp/tests/probe.rs index 5b26d94..4fa63de 100644 --- a/crates/mcp/tests/probe.rs +++ b/crates/mcp/tests/probe.rs @@ -179,6 +179,24 @@ fn call_success() -> ProbeResponse { ) } +fn discover_success() -> ProbeResponse { + response( + 200, + None, + json!({ + "jsonrpc": "2.0", + "id": INITIALIZE_ID, + "result": { + "supportedVersions": ["2026-07-28"], + "capabilities": {"tools": {}}, + "resultType": "complete", + "cacheScope": "private", + "ttlMs": 0 + } + }), + ) +} + #[tokio::test] async fn happy_path_uses_public_route_auth_session_and_deterministic_ids() { let transport = FakeTransport::new([ @@ -269,6 +287,40 @@ async fn forbidden_unauthenticated_response_is_accepted_as_auth_rejection() { assert!(output.contains("auth_negative=PASS status=403")); } +#[tokio::test] +async fn stateless_happy_path_uses_discovery_request_metadata_and_no_session() { + let transport = FakeTransport::new([ + ProbeResponse::new(401, None, None), + discover_success(), + tools_success(json!([{"name": "fast_time_echo"}])), + call_success(), + ]); + let mut configured = config(); + configured.protocol_version = "2026-07-28".to_owned(); + let mut output = Vec::new(); + + run_probe(&transport, &configured, &mut output) + .await + .expect("stateless probe flow should succeed"); + + let requests = transport.requests(); + assert_eq!(requests.len(), 4); + assert_eq!(requests[0].payload["method"], "server/discover"); + assert_eq!(requests[1].payload["method"], "server/discover"); + assert_eq!(requests[2].payload["method"], "tools/list"); + assert_eq!(requests[3].payload["method"], "tools/call"); + assert_eq!(requests[0].bearer_token, None); + assert!(requests.iter().all(|request| request.session_id.is_none() + && request.protocol_version.as_deref() == Some("2026-07-28") + && request.payload["params"]["_meta"]["io.modelcontextprotocol/protocolVersion"] + == "2026-07-28")); + assert_eq!(requests[3].payload["params"]["name"], "fast_time_echo"); + let output = String::from_utf8(output).expect("probe output should be UTF-8"); + assert!(output.contains("server_discover=PASS status=200 lifecycle=stateless")); + assert!(!output.contains("initialize=PASS")); + assert!(!output.contains("initialized=PASS")); +} + #[tokio::test] async fn requested_version_drives_initialize_payload_and_negotiated_version_drives_headers() { let transport = FakeTransport::new([ diff --git a/crates/platform/src/compose.rs b/crates/platform/src/compose.rs index 5739fc6..2e52ece 100644 --- a/crates/platform/src/compose.rs +++ b/crates/platform/src/compose.rs @@ -37,6 +37,7 @@ pub const SERVICE_DISPLAY_NAMES: &[(&str, &str)] = &[ ("mcp_inspector", "cf-mcp-inspector"), ("keycloak", "cf-keycloak"), ("mcp_conformance_server", "cf-conformance-server"), + ("mcp_conformance_proxy", "cf-conformance-proxy"), ]; /// Immutable Compose project invocation. @@ -117,20 +118,31 @@ impl ComposeProject { self } - /// Enables the isolated official MCP conformance server fixture. + /// Applies the official MCP conformance fixture's Compose overrides. + /// + /// This is separate from enabling the fixture profile so services affected + /// by the overlay start with the required configuration before the + /// profile-gated fixture itself is launched. #[must_use] - pub fn with_conformance_fixture(mut self, repository_root: &Path) -> Self { + pub fn with_conformance_overlay(mut self, repository_root: &Path) -> Self { let overlay = repository_root.join("docker/docker-compose.cf-conformance.yaml"); if !self.files.contains(&overlay) { self.files.push(overlay); } + self + } + + /// Enables the isolated official MCP conformance server fixture. + #[must_use] + pub fn with_conformance_fixture(self, repository_root: &Path) -> Self { + let mut project = self.with_conformance_overlay(repository_root); let profile = OsString::from("conformance"); - if !self.profiles.contains(&profile) { - self.profiles.push(profile); + if !project.profiles.contains(&profile) { + project.profiles.push(profile); } - self + project } /// Creates a `docker compose` command with project, files, and profiles. diff --git a/crates/platform/src/config.rs b/crates/platform/src/config.rs index beb6b5c..813491c 100644 --- a/crates/platform/src/config.rs +++ b/crates/platform/src/config.rs @@ -90,7 +90,6 @@ pub struct AppConfig { pub(crate) controlplane_project: SourcedValue, pub(crate) jwt_secret_key: SourcedValue, pub(crate) auth_encryption_secret: SourcedValue, - pub(crate) jwt_subject: SourcedValue, controlplane_image: ImageSetting, dataplane_image: ImageSetting, pub(crate) dataplane_platform: SourcedValue, @@ -99,6 +98,7 @@ pub struct AppConfig { pub(crate) fast_time_expected_image: SourcedValue, pub(crate) base_url: SourcedValue, pub(crate) platform_admin_email: SourcedValue, + pub(crate) platform_admin_password: SourcedValue, pub(crate) key_file_password: SourcedValue, pub(crate) locust_users: SourcedValue, pub(crate) locust_spawn_rate: SourcedValue, @@ -212,7 +212,7 @@ impl AppConfig { &environment, "CF_DATAPLANE_DIR", Path::new(&integration_dir.value) - .join("contextforge-gateway-rs") + .join("contextforge-data-plane") .into_os_string(), ), ); @@ -224,14 +224,12 @@ impl AppConfig { let controlplane_ref = shell_value( &environment, "CF_CONTROLPLANE_REF", - OsString::from("v1.0.6"), + OsString::from("v1.0.7"), ); let dataplane_repo = shell_value( &environment, "CF_DATAPLANE_REPO", - OsString::from( - "https://github.com/contextforge-gateway-rs/contextforge-gateway-rs.git", - ), + OsString::from("https://github.com/contextforge-org/contextforge-data-plane.git"), ); let dataplane_ref = shell_value(&environment, "CF_DATAPLANE_REF", OsString::new()); let integration_project = @@ -268,11 +266,6 @@ impl AppConfig { .auth_encryption_secret, ), }; - let jwt_subject = shell_value( - &environment, - "MCP_JWT_SUBJECT", - OsString::from("admin@example.com"), - ); let controlplane_image = controlplane_image(&environment); let dataplane_image = dataplane_image(&environment, &dataplane_ref); let dataplane_platform = shell_value( @@ -291,9 +284,16 @@ impl AppConfig { .cloned() .unwrap_or_else(|| default_value("ghcr.io/ibm/cfex-mcp-fast-time-server:latest")); let base_url = base_url(&environment); - let platform_admin_email = first_nonempty(&environment, "PLATFORM_ADMIN_EMAIL") - .cloned() - .unwrap_or_else(|| jwt_subject.clone()); + let platform_admin_email = shell_value( + &environment, + "PLATFORM_ADMIN_EMAIL", + OsString::from("admin@example.com"), + ); + let platform_admin_password = shell_value( + &environment, + "PLATFORM_ADMIN_PASSWORD", + OsString::from("changeme"), + ); let key_file_password = shell_value(&environment, "KEY_FILE_PASSWORD", OsString::new()); let locust_users = present_value(&environment, "LOCUST_USERS", "100"); let locust_spawn_rate = present_value(&environment, "LOCUST_SPAWN_RATE", "10"); @@ -314,7 +314,6 @@ impl AppConfig { controlplane_project, jwt_secret_key, auth_encryption_secret, - jwt_subject, controlplane_image, dataplane_image, dataplane_platform, @@ -323,6 +322,7 @@ impl AppConfig { fast_time_expected_image, base_url, platform_admin_email, + platform_admin_password, key_file_password, locust_users, locust_spawn_rate, @@ -405,12 +405,6 @@ impl AppConfig { &self.auth_encryption_secret } - /// Returns the JWT subject setting. - #[must_use] - pub fn jwt_subject(&self) -> &SourcedValue { - &self.jwt_subject - } - /// Returns the resolved control-plane image setting. #[must_use] pub fn controlplane_image(&self) -> &ImageSetting { @@ -459,6 +453,12 @@ impl AppConfig { &self.platform_admin_email } + /// Returns the bootstrap platform administrator password setting. + #[must_use] + pub fn platform_admin_password(&self) -> &SourcedValue { + &self.platform_admin_password + } + /// Returns the private-key password setting. #[must_use] pub fn key_file_password(&self) -> &SourcedValue { @@ -564,13 +564,17 @@ fn dataplane_image(environment: &LoadedEnvironment, dataplane_ref: &SourcedValue shell_value( environment, "CF_DATAPLANE_LOCAL_IMAGE", - OsString::from("contextforge-gateway-rs/contextforge-gateway-rs:local"), + OsString::from("contextforge-org/contextforge-data-plane:local"), ) .value } else { - let version = shell_value(environment, "CF_DATAPLANE_VERSION", OsString::from("0.1.0")); + let version = shell_value( + environment, + "CF_DATAPLANE_VERSION", + OsString::from("latest"), + ); prefixed_value( - "ghcr.io/contextforge-gateway-rs/contextforge-gateway-rs:", + "ghcr.io/contextforge-org/contextforge-data-plane:", &version.value, ) }; @@ -923,19 +927,19 @@ mod tests { ); assert_sourced( &config.controlplane_ref, - OsStr::new("v1.0.6"), + OsStr::new("v1.0.7"), ValueOrigin::Default, ); assert_sourced( &config.dataplane_dir, root.path() - .join(".integration/contextforge-gateway-rs") + .join(".integration/contextforge-data-plane") .as_os_str(), ValueOrigin::Default, ); assert_sourced( &config.dataplane_repo, - OsStr::new("https://github.com/contextforge-gateway-rs/contextforge-gateway-rs.git"), + OsStr::new("https://github.com/contextforge-org/contextforge-data-plane.git"), ValueOrigin::Default, ); assert_sourced(&config.dataplane_ref, OsStr::new(""), ValueOrigin::Default); @@ -953,11 +957,6 @@ mod tests { assert_eq!(config.jwt_secret_key.value.len(), 64); assert_eq!(config.auth_encryption_secret.origin, ValueOrigin::Default); assert_eq!(config.auth_encryption_secret.value.len(), 64); - assert_sourced( - &config.jwt_subject, - OsStr::new("admin@example.com"), - ValueOrigin::Default, - ); assert_eq!( config.controlplane_image.resolved, OsStr::new("ghcr.io/ibm/mcp-context-forge:latest") @@ -966,7 +965,7 @@ mod tests { assert!(config.controlplane_image.prebuilt); assert_eq!( config.dataplane_image.resolved, - OsStr::new("ghcr.io/contextforge-gateway-rs/contextforge-gateway-rs:0.1.0") + OsStr::new("ghcr.io/contextforge-org/contextforge-data-plane:latest") ); assert!(!config.dataplane_image.explicitly_set); assert_sourced( @@ -999,6 +998,11 @@ mod tests { OsStr::new("admin@example.com"), ValueOrigin::Default, ); + assert_sourced( + &config.platform_admin_password, + OsStr::new("changeme"), + ValueOrigin::Default, + ); assert_sourced( &config.key_file_password, OsStr::new(""), @@ -1049,7 +1053,7 @@ mod tests { ); assert_sourced( &config.controlplane_ref, - OsStr::new("v1.0.6"), + OsStr::new("v1.0.7"), ValueOrigin::Default, ); assert_sourced( @@ -1163,7 +1167,7 @@ mod tests { assert_eq!( source_config.dataplane_image.resolved, - OsStr::new("contextforge-gateway-rs/contextforge-gateway-rs:local") + OsStr::new("contextforge-org/contextforge-data-plane:local") ); assert_eq!( local_config.dataplane_image.resolved, @@ -1171,7 +1175,7 @@ mod tests { ); assert_eq!( published_config.dataplane_image.resolved, - OsStr::new("ghcr.io/contextforge-gateway-rs/contextforge-gateway-rs:2.0.0") + OsStr::new("ghcr.io/contextforge-org/contextforge-data-plane:2.0.0") ); assert_eq!( explicit_config.dataplane_image.resolved, @@ -1218,14 +1222,15 @@ mod tests { ("MCP_CLI_BASE_URL", "https://example.test"), ("NGINX_PORT", "9191"), ]); - let port_and_subject = environment(&[ + let port_and_admin = environment(&[ ("MCP_CLI_BASE_URL", ""), ("NGINX_PORT", "9191"), - ("MCP_JWT_SUBJECT", "operator@example.test"), + ("PLATFORM_ADMIN_EMAIL", "operator@example.test"), + ("PLATFORM_ADMIN_PASSWORD", "integration-password"), ]); let direct_config = load_app_config(root.path(), &direct).config; - let fallback_config = load_app_config(root.path(), &port_and_subject).config; + let fallback_config = load_app_config(root.path(), &port_and_admin).config; assert_sourced( &direct_config.base_url, @@ -1242,6 +1247,11 @@ mod tests { OsStr::new("operator@example.test"), ValueOrigin::Process, ); + assert_sourced( + &fallback_config.platform_admin_password, + OsStr::new("integration-password"), + ValueOrigin::Process, + ); } #[test] diff --git a/crates/platform/tests/compose.rs b/crates/platform/tests/compose.rs index 5bffe4d..9d0907a 100644 --- a/crates/platform/tests/compose.rs +++ b/crates/platform/tests/compose.rs @@ -73,7 +73,7 @@ fn readme_documents_the_official_conformance_fixture_contract() { "official TypeScript fixture", "Fast Time remains", "runs fixture-direct, controlplane, and dataplane lanes", - "794dcab99ed1ef2b89607be9999574140ea5c96e", + "c321dd32035556e6769d3724a8ee97d87c3faaac", "defaults to MCP `2026-07-28`", "loopback `MCP_CLI_BASE_URL`", "passes an empty expected-failure file", @@ -140,6 +140,21 @@ fn shared_metadata_overlay_clears_obsolete_fast_time_arguments() { overlay["services"]["fast_time_server"]["command"], serde_yaml::Value::Sequence(Vec::new()) ); + let gateway_environment = overlay["services"]["gateway"]["environment"] + .as_mapping() + .expect("shared gateway environment must be a mapping"); + for key in [ + "PLATFORM_ADMIN_EMAIL", + "PLATFORM_ADMIN_PASSWORD", + "PASSWORD_CHANGE_ENFORCEMENT_ENABLED", + "ADMIN_REQUIRE_PASSWORD_CHANGE_ON_BOOTSTRAP", + "REQUIRE_PASSWORD_CHANGE_FOR_DEFAULT_PASSWORD", + ] { + assert!( + gateway_environment.contains_key(serde_yaml::Value::String(key.to_owned())), + "shared gateway environment must define {key}" + ); + } } #[test] @@ -195,6 +210,77 @@ fn compose_overlays_assign_short_container_display_names() { conformance["services"]["mcp_conformance_server"]["labels"]["name"].as_str(), Some("cf-conformance-server") ); + assert_eq!( + conformance["services"]["mcp_conformance_proxy"]["labels"]["name"].as_str(), + Some("cf-conformance-proxy") + ); +} + +#[test] +fn dataplane_overlays_track_the_current_image_build_and_environment_contract() { + let root = workspace_root(); + let compose = fs::read_to_string(root.join("docker/docker-compose.cf-dataplane.yaml")) + .expect("read dataplane Compose overlay"); + let compose: serde_yaml::Value = + serde_yaml::from_str(&compose).expect("parse dataplane Compose overlay"); + let environment = compose["services"]["dataplane"]["environment"] + .as_mapping() + .expect("dataplane environment must be a mapping"); + + for key in [ + "CONTEXTFORGE_DATA_PLANE_ADDRESS", + "CONTEXTFORGE_DATA_PLANE_REDIS_HOSTNAME", + "CONTEXTFORGE_DATA_PLANE_REDIS_PORT", + "CONTEXTFORGE_DATA_PLANE_REDIS_CONNECTION_MODE", + "CONTEXTFORGE_DATA_PLANE_TOKEN_SECRET", + "CONTEXTFORGE_DATA_PLANE_TOKEN_VERIFICATION_PRIVATE_KEY", + "CONTEXTFORGE_DATA_PLANE_UPSTREAM_CONNECTION_MODE", + "CONTEXTFORGE_DATA_PLANE_USER_CONFIG_CACHE_EXPIRY_SECONDS", + "CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS", + "CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_ORIGINS", + ] { + assert!( + environment.contains_key(serde_yaml::Value::String(key.to_owned())), + "dataplane environment must define {key}" + ); + } + assert_eq!( + environment[serde_yaml::Value::String( + "CONTEXTFORGE_DATA_PLANE_TOKEN_VERIFICATION_PRIVATE_KEY".to_owned() + )] + .as_str(), + Some("/dev/null"), + "the unused local-bootstrap signing key must not add a real private key to the harness" + ); + assert!( + environment + [serde_yaml::Value::String("CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS".to_owned())] + .as_str() + .expect("MCP Host allowlist must be text") + .contains(",nginx}"), + "the default MCP Host allowlist must accept containerized Locust through nginx" + ); + for obsolete in [ + "CONTEXTFORGE_GATEWAY_RS_ADDRESS", + "CONTEXTFORGE_GATEWAY_RS_REDIS_HOSTNAME", + "CONTEXTFORGE_GATEWAY_RS_TOKEN_SECRET", + "CONTEXTFORGE_GATEWAY_RS_UPSTREAM_CONNECTION_MODE", + "CONTEXTFORGE_GATEWAY_RS_USER_CONFIG_CACHE_EXPIRY_SECONDS", + ] { + assert!( + !environment.contains_key(serde_yaml::Value::String(obsolete.to_owned())), + "obsolete dataplane environment key must be absent: {obsolete}" + ); + } + + let build = fs::read_to_string(root.join("docker/docker-compose.cf-dataplane-build.yaml")) + .expect("read dataplane build overlay"); + let build: serde_yaml::Value = + serde_yaml::from_str(&build).expect("parse dataplane build overlay"); + assert_eq!( + build["services"]["dataplane"]["build"]["dockerfile"].as_str(), + Some("docker/Dockerfile") + ); } #[test] @@ -231,19 +317,22 @@ fn conformance_fixture_is_an_explicit_overlay_and_profile() { .any(|file| file.ends_with("docker-compose.cf-conformance.yaml")) ); - let conformance = default_project + let overlay = default_project .clone() .with_profiles(["testing"]) - .with_conformance_fixture(Path::new("/repo")); - assert_eq!(conformance.profiles(), ["testing", "conformance"]); + .with_conformance_overlay(Path::new("/repo")); + assert_eq!(overlay.profiles(), ["testing"]); assert_eq!( - &conformance.files()[..default_project.files().len()], + &overlay.files()[..default_project.files().len()], default_project.files() ); assert_eq!( - conformance.files().last().map(PathBuf::as_path), + overlay.files().last().map(PathBuf::as_path), Some(Path::new("/repo/docker/docker-compose.cf-conformance.yaml")) ); + + let conformance = overlay.with_conformance_fixture(Path::new("/repo")); + assert_eq!(conformance.profiles(), ["testing", "conformance"]); let deduplicated = conformance.with_conformance_fixture(Path::new("/repo")); assert_eq!(deduplicated.profiles(), ["testing", "conformance"]); assert_eq!( @@ -269,7 +358,7 @@ fn conformance_container_inputs_pin_the_runner_revision_and_protocol_fixture() { assert!(dockerfile.contains("FROM node:22-bookworm-slim")); assert!( dockerfile - .contains("ARG MCP_CONFORMANCE_REVISION=794dcab99ed1ef2b89607be9999574140ea5c96e") + .contains("ARG MCP_CONFORMANCE_REVISION=c321dd32035556e6769d3724a8ee97d87c3faaac") ); assert!(dockerfile.contains( "git clone https://github.com/modelcontextprotocol/conformance.git mcp-conformance" @@ -317,7 +406,7 @@ services: GATEWAY_TOOL_NAME_SEPARATOR: "_" mcp_conformance_server: profiles: ["conformance"] - image: cf-integration/mcp-conformance-server:0.2.0-alpha.9 + image: cf-integration/mcp-conformance-server:0.2.0-alpha.11 labels: name: cf-conformance-server build: @@ -328,7 +417,7 @@ services: PORT: "3000" MCP_CONFORMANCE_SERVER_ERA: ${CF_CONFORMANCE_SERVER_ERA:-dual} ports: - - "127.0.0.1::3000" + - "127.0.0.1:${CF_CONFORMANCE_PORT:-0}:3000" networks: - mcpnet healthcheck: @@ -341,10 +430,28 @@ services: timeout: 2s retries: 30 start_period: 2s + mcp_conformance_proxy: + profiles: ["conformance"] + image: nginx:1.30.4-alpine3.24 + labels: + name: cf-conformance-proxy + restart: "no" + volumes: + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/docker/nginx.cf-conformance-proxy.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - mcpnet + depends_on: + mcp_conformance_server: + condition: service_healthy "#, ) .expect("parse expected conformance Compose contract"); assert_eq!(actual_compose, expected_compose); + + let proxy = fs::read_to_string(root.join("docker/nginx.cf-conformance-proxy.conf")) + .expect("read conformance proxy config"); + assert!(proxy.contains("proxy_pass http://mcp_conformance_server:3000;")); + assert!(proxy.contains("proxy_set_header Host localhost:3000;")); } #[test] diff --git a/crates/platform/tests/config.rs b/crates/platform/tests/config.rs index d30abd4..0e447e8 100644 --- a/crates/platform/tests/config.rs +++ b/crates/platform/tests/config.rs @@ -355,7 +355,7 @@ fn explicit_empty_process_images_use_fallbacks_but_remain_explicit() { assert!(loaded.config.controlplane_image().is_prebuilt()); assert_eq!( loaded.config.dataplane_image().resolved(), - OsStr::new("contextforge-gateway-rs/contextforge-gateway-rs:local") + OsStr::new("contextforge-org/contextforge-data-plane:local") ); assert!(loaded.config.dataplane_image().is_explicitly_set()); } diff --git a/crates/platform/tests/stack.rs b/crates/platform/tests/stack.rs index fb7f4c1..7005874 100644 --- a/crates/platform/tests/stack.rs +++ b/crates/platform/tests/stack.rs @@ -218,6 +218,7 @@ fn cleanup_status_logs_and_config_use_typed_compose_commands() { OsString::from("cf-mcp-inspector"), OsString::from("cf-keycloak"), OsString::from("cf-conformance-server"), + OsString::from("cf-conformance-proxy"), OsString::from("custom-service"), ] )), @@ -244,6 +245,7 @@ fn cleanup_status_logs_and_config_use_typed_compose_commands() { "mcp_inspector", "keycloak", "mcp_conformance_server", + "mcp_conformance_proxy", "custom-service", ] )); diff --git a/docker/docker-compose.cf-conformance.yaml b/docker/docker-compose.cf-conformance.yaml index 5c3297c..c61b86d 100644 --- a/docker/docker-compose.cf-conformance.yaml +++ b/docker/docker-compose.cf-conformance.yaml @@ -4,7 +4,7 @@ services: GATEWAY_TOOL_NAME_SEPARATOR: "_" mcp_conformance_server: profiles: ["conformance"] - image: cf-integration/mcp-conformance-server:0.2.0-alpha.9 + image: cf-integration/mcp-conformance-server:0.2.0-alpha.11 labels: name: cf-conformance-server build: @@ -15,7 +15,7 @@ services: PORT: "3000" MCP_CONFORMANCE_SERVER_ERA: ${CF_CONFORMANCE_SERVER_ERA:-dual} ports: - - "127.0.0.1::3000" + - "127.0.0.1:${CF_CONFORMANCE_PORT:-0}:3000" networks: - mcpnet healthcheck: @@ -28,3 +28,16 @@ services: timeout: 2s retries: 30 start_period: 2s + mcp_conformance_proxy: + profiles: ["conformance"] + image: nginx:1.30.4-alpine3.24 + labels: + name: cf-conformance-proxy + restart: "no" + volumes: + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/docker/nginx.cf-conformance-proxy.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - mcpnet + depends_on: + mcp_conformance_server: + condition: service_healthy diff --git a/docker/docker-compose.cf-controlplane-build-labels.yaml b/docker/docker-compose.cf-controlplane-build-labels.yaml index 47c129d..3d35fac 100644 --- a/docker/docker-compose.cf-controlplane-build-labels.yaml +++ b/docker/docker-compose.cf-controlplane-build-labels.yaml @@ -4,6 +4,15 @@ services: gateway: labels: name: cf-controlplane + environment: + # Keep both harness topologies usable through the current email-login + # flow. These values are local integration credentials, not production + # defaults. + PLATFORM_ADMIN_EMAIL: ${PLATFORM_ADMIN_EMAIL:-admin@example.com} + PLATFORM_ADMIN_PASSWORD: ${PLATFORM_ADMIN_PASSWORD:-changeme} + PASSWORD_CHANGE_ENFORCEMENT_ENABLED: ${PASSWORD_CHANGE_ENFORCEMENT_ENABLED:-false} + ADMIN_REQUIRE_PASSWORD_CHANGE_ON_BOOTSTRAP: ${ADMIN_REQUIRE_PASSWORD_CHANGE_ON_BOOTSTRAP:-false} + REQUIRE_PASSWORD_CHANGE_FOR_DEFAULT_PASSWORD: ${REQUIRE_PASSWORD_CHANGE_FOR_DEFAULT_PASSWORD:-false} build: labels: org.opencontainers.image.revision: ${CF_CONTROLPLANE_CHECKOUT_REVISION:-unknown} diff --git a/docker/docker-compose.cf-dataplane-build.yaml b/docker/docker-compose.cf-dataplane-build.yaml index dd5df24..b99433d 100644 --- a/docker/docker-compose.cf-dataplane-build.yaml +++ b/docker/docker-compose.cf-dataplane-build.yaml @@ -4,7 +4,7 @@ services: dataplane: build: context: ${CF_DATAPLANE_DIR:?Set CF_DATAPLANE_DIR to the cf-dataplane checkout} - dockerfile: docker/dev.Dockerfile + dockerfile: docker/Dockerfile labels: org.opencontainers.image.revision: ${CF_DATAPLANE_CHECKOUT_REVISION:-unknown} org.opencontainers.image.ref.name: ${CF_DATAPLANE_CHECKOUT_REF:-unknown} diff --git a/docker/docker-compose.cf-dataplane.yaml b/docker/docker-compose.cf-dataplane.yaml index be10bb3..762a9ca 100644 --- a/docker/docker-compose.cf-dataplane.yaml +++ b/docker/docker-compose.cf-dataplane.yaml @@ -2,7 +2,7 @@ # # Usage from this repo: # export CF_INTEGRATION_ROOT="$PWD" -# export CF_DATAPLANE_IMAGE="ghcr.io/contextforge-gateway-rs/contextforge-gateway-rs:0.1.0" +# export CF_DATAPLANE_IMAGE="ghcr.io/contextforge-org/contextforge-data-plane:latest" # export CF_DATAPLANE_PLATFORM="linux/amd64" # # Or let `cf-integration stack up --topology dataplane` resolve `auto`. # docker compose \ @@ -22,13 +22,6 @@ services: - cf-controlplane environment: DATAPLANE_PUBLISHER: "true" - # Fresh databases bootstrap the admin with password_change_required, - # which 303-redirects every /admin/* route to an HTML page and breaks - # admin-API-driven test fixtures (e.g. the runtime-mode suite). The - # control-plane-only commands already disable enforcement; match them. - PASSWORD_CHANGE_ENFORCEMENT_ENABLED: ${PASSWORD_CHANGE_ENFORCEMENT_ENABLED:-false} - ADMIN_REQUIRE_PASSWORD_CHANGE_ON_BOOTSTRAP: ${ADMIN_REQUIRE_PASSWORD_CHANGE_ON_BOOTSTRAP:-false} - REQUIRE_PASSWORD_CHANGE_FOR_DEFAULT_PASSWORD: ${REQUIRE_PASSWORD_CHANGE_FOR_DEFAULT_PASSWORD:-false} # Short snapshot interval so runtime-created users/servers reach the # dataplane quickly in functional runs; raise for load benchmarks. # Requires a control-plane image with configurable publisher interval; @@ -48,17 +41,26 @@ services: expose: - "4445" environment: - CONTEXTFORGE_GATEWAY_RS_ADDRESS: 0.0.0.0:4445 - CONTEXTFORGE_GATEWAY_RS_REDIS_HOSTNAME: redis - CONTEXTFORGE_GATEWAY_RS_REDIS_PORT: "6379" - CONTEXTFORGE_GATEWAY_RS_REDIS_CONNECTION_MODE: plain-text - CONTEXTFORGE_GATEWAY_RS_TOKEN_SECRET: ${JWT_SECRET_KEY:-my-test-key-but-now-longer-than-32-bytes} - CONTEXTFORGE_GATEWAY_RS_UPSTREAM_CONNECTION_MODE: plain-text-or-tls + CONTEXTFORGE_DATA_PLANE_ADDRESS: 0.0.0.0:4445 + CONTEXTFORGE_DATA_PLANE_REDIS_HOSTNAME: redis + CONTEXTFORGE_DATA_PLANE_REDIS_PORT: "6379" + CONTEXTFORGE_DATA_PLANE_REDIS_CONNECTION_MODE: plain-text + CONTEXTFORGE_DATA_PLANE_TOKEN_SECRET: ${JWT_SECRET_KEY:-my-test-key-but-now-longer-than-32-bytes} + # The published image currently includes its non-production `with_tools` + # bootstrap routes, whose clap model requires an RSA signing-key path. + # This harness never exposes or calls those routes and uses control-plane + # catalog tokens, so satisfy the unused path without adding a test key. + CONTEXTFORGE_DATA_PLANE_TOKEN_VERIFICATION_PRIVATE_KEY: /dev/null + CONTEXTFORGE_DATA_PLANE_UPSTREAM_CONNECTION_MODE: plain-text-or-tls + # These two MCP transport settings intentionally retain the historical + # prefix in the current dataplane configuration contract. + CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS: ${CF_DATAPLANE_MCP_ALLOWED_HOSTS:-127.0.0.1:${NGINX_PORT:-8080},localhost:${NGINX_PORT:-8080},nginx} + CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_ORIGINS: ${CF_DATAPLANE_MCP_ALLOWED_ORIGINS:-http://127.0.0.1:${NGINX_PORT:-8080},http://localhost:${NGINX_PORT:-8080}} # Disable the per-subject config cache for functional runs: its sliding # TTL freezes stale configs under steady traffic (retry loops renew it # forever), hiding servers created after first contact. Set to 60 to # restore the image default for load benchmarks. - CONTEXTFORGE_GATEWAY_RS_USER_CONFIG_CACHE_EXPIRY_SECONDS: ${CF_DATAPLANE_USER_CONFIG_CACHE_EXPIRY_SECONDS:-0} + CONTEXTFORGE_DATA_PLANE_USER_CONFIG_CACHE_EXPIRY_SECONDS: ${CF_DATAPLANE_USER_CONFIG_CACHE_EXPIRY_SECONDS:-0} RUST_LOG: ${CF_DATAPLANE_LOG:-info} depends_on: redis: @@ -66,12 +68,6 @@ services: deploy: replicas: 1 - # SSE registration runs stock: the dataplane will not implement SSE - # upstreams (deprecated; removed in the 2026-07-28 MCP protocol update), - # but the publisher exports streamable-HTTP backends only, so SSE-backed - # virtual servers are absent from dataplane config and nginx replays their - # /servers/{id}/mcp requests on the control plane, which serves them fully. - nginx: volumes: - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/docker/nginx.cf-dataplane.conf:/etc/nginx/nginx.conf:ro diff --git a/docker/mcp-conformance-server.Dockerfile b/docker/mcp-conformance-server.Dockerfile index a67667b..38e3655 100644 --- a/docker/mcp-conformance-server.Dockerfile +++ b/docker/mcp-conformance-server.Dockerfile @@ -1,6 +1,6 @@ FROM node:22-bookworm-slim -ARG MCP_CONFORMANCE_REVISION=794dcab99ed1ef2b89607be9999574140ea5c96e +ARG MCP_CONFORMANCE_REVISION=c321dd32035556e6769d3724a8ee97d87c3faaac RUN apt-get update \ && apt-get install --yes --no-install-recommends ca-certificates git \ diff --git a/docker/nginx.cf-conformance-proxy.conf b/docker/nginx.cf-conformance-proxy.conf new file mode 100644 index 0000000..14f572c --- /dev/null +++ b/docker/nginx.cf-conformance-proxy.conf @@ -0,0 +1,18 @@ +server { + listen 80 default_server; + server_name _; + + location / { + proxy_pass http://mcp_conformance_server:3000; + proxy_http_version 1.1; + proxy_set_header Host localhost:3000; + proxy_set_header Connection ""; + proxy_request_buffering on; + proxy_buffering off; + proxy_cache off; + proxy_connect_timeout 30s; + proxy_send_timeout 1h; + proxy_read_timeout 1h; + add_header X-Accel-Buffering "no" always; + } +} diff --git a/docker/nginx.cf-dataplane.conf b/docker/nginx.cf-dataplane.conf index ff9f609..50a6327 100644 --- a/docker/nginx.cf-dataplane.conf +++ b/docker/nginx.cf-dataplane.conf @@ -54,6 +54,8 @@ http { proxy_set_header Authorization $http_authorization; proxy_set_header Mcp-Session-Id $http_mcp_session_id; proxy_set_header Mcp-Protocol-Version $http_mcp_protocol_version; + proxy_set_header Mcp-Method $http_mcp_method; + proxy_set_header Mcp-Name $http_mcp_name; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; @@ -81,16 +83,6 @@ http { location ~ ^/servers/([^/]+)/mcp/?$ { proxy_pass $cf_dataplane_backend_url/contextforge-rs/servers/$1/mcp$is_args$args; - # Replay requests the dataplane cannot serve on the control - # plane: 404 = virtual host absent from dataplane config (most - # notably servers whose backends use transports the dataplane - # does not serve — the publisher exports streamable-HTTP - # backends only); 400 = no config for the subject at all (e.g. - # a just-created user before the next publisher snapshot). - # Requires request buffering so the body can be resent. - proxy_intercept_errors on; - error_page 400 404 = @controlplane_mcp_fallback; - proxy_request_buffering on; proxy_buffering off; proxy_cache off; @@ -102,19 +94,6 @@ http { proxy_read_timeout 1h; } - location @controlplane_mcp_fallback { - proxy_pass $cf_controlplane_backend_url; - - proxy_buffering off; - proxy_cache off; - add_header X-Accel-Buffering "no" always; - add_header X-CF-Integration-Backend controlplane-fallback always; - - proxy_connect_timeout 30s; - proxy_send_timeout 1h; - proxy_read_timeout 1h; - } - location / { proxy_pass $cf_controlplane_backend_url; add_header X-CF-Integration-Backend controlplane always; diff --git a/reports/mcp-conformance-comparison.md b/reports/mcp-conformance-comparison.md index b8693f9..7a6eb8e 100644 --- a/reports/mcp-conformance-comparison.md +++ b/reports/mcp-conformance-comparison.md @@ -1,31 +1,31 @@ # MCP Conformance Comparison -- Official oracle: `@modelcontextprotocol/conformance@0.2.0-alpha.9` -- Client specification: `2025-11-25` +- Official oracle: `@modelcontextprotocol/conformance@0.2.0-alpha.11` +- Client specification: `2026-07-28` - Upstream server era: `dual` - Suite: `all` -- Fixture source: `https://github.com/modelcontextprotocol/conformance` at `794dcab99ed1ef2b89607be9999574140ea5c96e` +- Fixture source: `https://github.com/modelcontextprotocol/conformance` at `c321dd32035556e6769d3724a8ee97d87c3faaac` ## Target outcomes | Target | Compliant scenarios | Failed scenarios | Failed checks | Fixture failures | Not applicable | Ambiguous | Missing | |---|---:|---:|---:|---:|---:|---:|---:| -| Fixture direct | 32 | 0 | 0 | 0 | 0 | 0 | 0 | -| Control plane | 22 | 10 | 10 | 0 | 0 | 0 | 0 | -| Dataplane | 6 | 26 | 26 | 0 | 0 | 0 | 0 | +| Fixture direct | 31 | 9 | 17 | 0 | 0 | 0 | 0 | +| Control plane | 0 | 40 | 110 | 0 | 0 | 0 | 0 | +| Dataplane | 7 | 33 | 50 | 0 | 0 | 0 | 0 | ## Comparison summary | Classification | Scenarios | |---|---:| -| all compliant | 5 | +| all compliant | 0 | | fixture-only failure | 0 | -| control-plane only failure | 1 | -| dataplane only failure | 17 | +| control-plane only failure | 7 | +| dataplane only failure | 0 | | fixture + control-plane failure | 0 | | fixture + dataplane failure | 0 | -| both gateways only failure | 9 | -| shared failure | 0 | +| both gateways only failure | 24 | +| shared failure | 9 | | fixture failure | 0 | | not applicable | 0 | | ambiguous | 0 | @@ -34,35 +34,43 @@ | Scenario | Fixture direct | Control plane | Dataplane | Classification | Specification references | |---|---|---|---|---|---| -| completion-complete | compliant | failure | failure | both gateways only failure | [MCP-Completion](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/completion) | -| dns-rebinding-protection | compliant | failure | failure | both gateways only failure | [MCP-DNS-Rebinding-Protection](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise)
[MCP-Transport-Security](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning) | -| elicitation-sep1034-defaults | compliant | failure | failure | both gateways only failure | [SEP-1034](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034) | -| elicitation-sep1330-enums | compliant | failure | failure | both gateways only failure | [SEP-1330](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330) | -| json-schema-2020-12 | compliant | compliant | failure | dataplane only failure | [SEP-1613](https://github.com/modelcontextprotocol/specification/pull/655)
[SEP-2106](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2106) | -| logging-set-level | compliant | compliant | failure | dataplane only failure | [MCP-Logging](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging) | -| ping | compliant | compliant | compliant | all compliant | [MCP-Ping](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/ping) | -| prompts-get-embedded-resource | compliant | failure | failure | both gateways only failure | [MCP-Prompts-Embedded-Resources](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts#embedded-resources) | -| prompts-get-simple | compliant | compliant | failure | dataplane only failure | [MCP-Prompts-Get](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts#getting-prompts) | -| prompts-get-with-args | compliant | compliant | failure | dataplane only failure | [MCP-Prompts-Get](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts#getting-prompts) | -| prompts-get-with-image | compliant | compliant | failure | dataplane only failure | [MCP-Prompts-Image](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts#image-content) | -| prompts-list | compliant | compliant | compliant | all compliant | [MCP-Prompts-List](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts#listing-prompts) | -| resources-list | compliant | compliant | compliant | all compliant | [MCP-Resources-List](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#listing-resources) | -| resources-read-binary | compliant | failure | failure | both gateways only failure | [MCP-Resources-Read](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#reading-resources) | -| resources-read-text | compliant | compliant | failure | dataplane only failure | [MCP-Resources-Read](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#reading-resources) | -| resources-subscribe | compliant | compliant | failure | dataplane only failure | [MCP-Resources-Subscribe](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#resource-subscriptions) | -| resources-templates-read | compliant | failure | failure | both gateways only failure | [MCP-Resources-Templates](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#resource-templates) | -| resources-unsubscribe | compliant | compliant | failure | dataplane only failure | [MCP-Resources-Subscribe](https://modelcontextprotocol.io/specification/2025-06-18/schema#unsubscriberequest)
[MCP-Resources-Subscribe](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#resource-subscriptions) | -| server-initialize | compliant | compliant | compliant | all compliant | [MCP-Initialize](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#initialization)
[MCP-Session-Management](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management) | +| caching | compliant | failure | failure | both gateways only failure | [MCP-Caching](https://modelcontextprotocol.io/specification/draft/server/utilities/caching)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549) | +| completion-complete | compliant | failure | failure | both gateways only failure | [MCP-Completion](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/completion)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | +| dns-rebinding-protection | compliant | failure | compliant | control-plane only failure | [MCP-DNS-Rebinding-Protection](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise)
[MCP-Transport-Security](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning) | +| http-custom-header-server-validation | failure | failure | failure | shared failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2243-Custom-Headers](https://modelcontextprotocol.io/specification/draft/basic/transports#server-behavior-for-custom-headers) | +| http-header-validation | failure | failure | failure | shared failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[RFC-9110-5.5-Field-Values](https://www.rfc-editor.org/rfc/rfc9110#section-5.5)
[SEP-2243-Case-Sensitivity](https://modelcontextprotocol.io/specification/draft/basic/transports#case-sensitivity)
[SEP-2243-Server-Validation](https://modelcontextprotocol.io/specification/draft/basic/transports#server-validation) | +| input-required-result-basic-elicitation | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-basic-list-roots | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-basic-sampling | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-capability-check | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-ignore-extra-params | compliant | failure | compliant | control-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-missing-input-response | compliant | failure | compliant | control-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-multi-round | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-multiple-input-requests | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-non-tool-request | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-request-state | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-result-type | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-tampered-state | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-unsupported-methods | compliant | failure | compliant | control-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-validate-input | compliant | failure | compliant | control-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| json-schema-2020-12 | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-1613](https://github.com/modelcontextprotocol/specification/pull/655)
[SEP-2106](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2106) | +| prompts-get-embedded-resource | compliant | failure | failure | both gateways only failure | [MCP-Prompts-Embedded-Resources](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts#embedded-resources)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | +| prompts-get-simple | compliant | failure | failure | both gateways only failure | [MCP-Prompts-Get](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts#getting-prompts)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | +| prompts-get-with-args | compliant | failure | failure | both gateways only failure | [MCP-Prompts-Get](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts#getting-prompts)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | +| prompts-get-with-image | compliant | failure | failure | both gateways only failure | [MCP-Prompts-Image](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts#image-content)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | +| prompts-list | compliant | failure | failure | both gateways only failure | [MCP-Prompts-List](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts#listing-prompts)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | +| resources-list | compliant | failure | failure | both gateways only failure | [MCP-Resources-List](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#listing-resources)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | +| resources-read-binary | compliant | failure | failure | both gateways only failure | [MCP-Resources-Read](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#reading-resources)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | +| resources-read-text | compliant | failure | failure | both gateways only failure | [MCP-Resources-Read](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#reading-resources)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | +| resources-templates-read | compliant | failure | failure | both gateways only failure | [MCP-Resources-Templates](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#resource-templates)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | +| sep-2164-resource-not-found | compliant | failure | compliant | control-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2164](https://modelcontextprotocol.io/specification/draft/server/resources#error-handling) | | server-sse-multiple-streams | compliant | failure | compliant | control-plane only failure | [SEP-1699](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699) | -| server-sse-polling | compliant | compliant | compliant | all compliant | [SEP-1699](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699) | -| tools-call-audio | compliant | compliant | failure | dataplane only failure | [MCP-Tools-Call](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) | -| tools-call-elicitation | compliant | failure | failure | both gateways only failure | [MCP-Elicitation](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/elicitation) | -| tools-call-embedded-resource | compliant | compliant | failure | dataplane only failure | [MCP-Tools-Call](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) | -| tools-call-error | compliant | compliant | failure | dataplane only failure | [MCP-Error-Handling](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle) | -| tools-call-image | compliant | compliant | failure | dataplane only failure | [MCP-Tools-Call](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) | -| tools-call-mixed-content | compliant | compliant | failure | dataplane only failure | [MCP-Tools-Call](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) | -| tools-call-sampling | compliant | failure | failure | both gateways only failure | [MCP-Sampling](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/sampling) | -| tools-call-simple-text | compliant | compliant | failure | dataplane only failure | [MCP-Tools-Call](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) | -| tools-call-with-logging | compliant | compliant | failure | dataplane only failure | [MCP-Logging](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging) | -| tools-call-with-progress | compliant | compliant | failure | dataplane only failure | [MCP-Progress](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/progress) | -| tools-list | compliant | compliant | failure | dataplane only failure | [MCP-Tools-List](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#listing-tools)
[MCP-Tools-List](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#listing-tools)
[SEP-986](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names) | +| server-stateless | compliant | failure | failure | both gateways only failure | [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575) | +| tools-call-audio | failure | failure | failure | shared failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[MCP-Tools-Call](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) | +| tools-call-embedded-resource | failure | failure | failure | shared failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[MCP-Tools-Call](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) | +| tools-call-error | failure | failure | failure | shared failure | [MCP-Error-Handling](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | +| tools-call-image | failure | failure | failure | shared failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[MCP-Tools-Call](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) | +| tools-call-mixed-content | failure | failure | failure | shared failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[MCP-Tools-Call](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) | +| tools-call-simple-text | failure | failure | failure | shared failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[MCP-Tools-Call](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) | +| tools-call-with-progress | failure | failure | failure | shared failure | [MCP-Progress](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/progress)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | +| tools-list | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[MCP-Tools-List](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#listing-tools)
[MCP-Tools-List](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#listing-tools)
[SEP-986](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names) | diff --git a/scripts/locustfile_mcp.py b/scripts/locustfile_mcp.py index c383f11..5423ea4 100644 --- a/scripts/locustfile_mcp.py +++ b/scripts/locustfile_mcp.py @@ -24,6 +24,7 @@ from locust import HttpUser, between, events, task PROTOCOL_VERSION = os.environ.get("MCP_PROTOCOL_VERSION", "2025-11-25") +STATELESS = PROTOCOL_VERSION >= "2026-07-28" ACCEPT = "application/json, text/event-stream" _REQUEST_TIMEOUT_ERROR = ( "LOCUST_REQUEST_TIMEOUT_SECONDS must be a finite number greater than zero" @@ -62,6 +63,24 @@ def jsonrpc(method: str, params: dict | None = None) -> dict: return payload +def stateless_params(params: dict | None = None) -> dict: + """Add the mandatory 2026 per-request client metadata.""" + result = dict(params or {}) + metadata = dict(result.get("_meta") or {}) + metadata.update( + { + "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION, + "io.modelcontextprotocol/clientInfo": { + "name": "cf-integration-locust", + "version": "1.0", + }, + "io.modelcontextprotocol/clientCapabilities": {}, + } + ) + result["_meta"] = metadata + return result + + def _sse_data_events(text: str): data_lines: list[str] = [] for line in text.splitlines(): @@ -118,6 +137,18 @@ def validate_result(method: str, result) -> dict: for field in ("name", "version") ): raise ValueError("initialize result must include serverInfo name and version") + elif method == "server/discover": + versions = result.get("supportedVersions") + if not isinstance(versions, list) or PROTOCOL_VERSION not in versions: + raise ValueError("server/discover must advertise the requested protocol version") + if not isinstance(result.get("capabilities"), dict): + raise ValueError("server/discover result must include capabilities") + if not isinstance(result.get("resultType"), str): + raise ValueError("server/discover result must include resultType") + if not isinstance(result.get("cacheScope"), str): + raise ValueError("server/discover result must include cacheScope") + if not isinstance(result.get("ttlMs"), int) or result["ttlMs"] < 0: + raise ValueError("server/discover result must include a non-negative ttlMs") elif method == "tools/list": tools = result.get("tools") if not isinstance(tools, list): @@ -181,6 +212,7 @@ class MCPGatewayUser(HttpUser): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._session_id: str | None = None + self._ready = False self._tool_names: list[str] = list(TOOL_NAMES) def on_start(self): @@ -191,21 +223,31 @@ def on_start(self): raise RuntimeError("MCP_SERVER_ID or MCP_VIRTUAL_SERVER_ID is required") if not BEARER_TOKEN: raise RuntimeError("MCPGATEWAY_BEARER_TOKEN is required") - result = self._mcp_request( - "initialize", - { - "protocolVersion": PROTOCOL_VERSION, - "capabilities": {}, - "clientInfo": {"name": "cf-integration-locust", "version": "1.0"}, - }, - name="MCP initialize", - include_protocol_version=False, - ) + if STATELESS: + result = self._mcp_request( + "server/discover", None, name="MCP server/discover" + ) + else: + result = self._mcp_request( + "initialize", + { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { + "name": "cf-integration-locust", + "version": "1.0", + }, + }, + name="MCP initialize", + include_protocol_version=False, + ) if result is None: return - if not self._session_id: + self._ready = True + if not STATELESS and not self._session_id: raise RuntimeError("initialize response did not include Mcp-Session-Id") - self._mcp_notification("notifications/initialized", None, name="MCP initialized") + if not STATELESS: + self._mcp_notification("notifications/initialized", None, name="MCP initialized") if not self._tool_names: listed = self._mcp_request("tools/list", {}, name="MCP tools/list") if listed: @@ -218,7 +260,7 @@ def on_start(self): ] def on_stop(self): - if not self._session_id: + if STATELESS or not self._session_id: return with self.client.delete( mcp_path(), @@ -235,14 +277,36 @@ def on_stop(self): return response.success() - def _headers(self, *, include_protocol_version: bool = True) -> dict[str, str]: + def _headers( + self, + *, + include_protocol_version: bool = True, + method: str | None = None, + params: dict | None = None, + ) -> dict[str, str]: headers = { "Content-Type": "application/json", "Accept": ACCEPT, "Authorization": f"Bearer {BEARER_TOKEN}", } - if include_protocol_version: + if include_protocol_version or STATELESS: headers["Mcp-Protocol-Version"] = PROTOCOL_VERSION + if STATELESS and method: + headers["Mcp-Method"] = method + if method in {"tools/call", "prompts/get"} and isinstance(params, dict): + name = params.get("name") + if isinstance(name, str) and name: + headers["Mcp-Name"] = name + elif method == "resources/read" and isinstance(params, dict): + uri = params.get("uri") + if isinstance(uri, str) and uri: + headers["Mcp-Name"] = uri + elif method in {"tasks/get", "tasks/update", "tasks/cancel"} and isinstance( + params, dict + ): + task_id = params.get("taskId") + if isinstance(task_id, str) and task_id: + headers["Mcp-Name"] = task_id if self._session_id: headers["Mcp-Session-Id"] = self._session_id return headers @@ -266,11 +330,16 @@ def _mcp_request( include_protocol_version: bool = True, ) -> dict | None: """Send an MCP JSON-RPC request; return the result field or None.""" - payload = jsonrpc(method, params) + request_params = stateless_params(params) if STATELESS else params + payload = jsonrpc(method, request_params) with self.client.post( mcp_path(), data=json.dumps(payload), - headers=self._headers(include_protocol_version=include_protocol_version), + headers=self._headers( + include_protocol_version=include_protocol_version, + method=method, + params=request_params, + ), name=name, catch_response=True, allow_redirects=False, @@ -353,4 +422,6 @@ def tools_call(self): @task(2) def ping(self): + if STATELESS: + return self._mcp_request("ping", None, name="MCP ping") diff --git a/src/app.rs b/src/app.rs index b9a141c..132206e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -110,21 +110,35 @@ pub enum DebugAction { pub fn resolve_action(cli: Cli, environment: &Environment) -> Result { match cli.command { Command::Stack(args) => resolve_stack(args.command, environment).map(Action::Stack), - Command::Probe(args) => Ok(Action::Probe { - topology: resolve_routed_lane(args.lane, environment)?, - protocol_version: resolve_protocol_version(args.protocol_version, environment)?, - }), - Command::Load(args) => Ok(Action::Load(ResolvedLoadArgs { - topology: resolve_routed_lane(args.target.lane, environment)?, - protocol_version: resolve_protocol_version(args.target.protocol_version, environment)?, - request: LoadRequest { - engine: args.engine.into(), - smoke: args.smoke, - users: args.users, - spawn_rate: args.spawn_rate, - run_time: args.run_time, - }, - })), + Command::Probe(args) => { + let topology = resolve_topology(args.lane, environment)?; + Ok(Action::Probe { + topology, + protocol_version: resolve_protocol_version( + args.protocol_version, + environment, + ProtocolVersion::default(), + )?, + }) + } + Command::Load(args) => { + let topology = resolve_topology(args.target.lane, environment)?; + Ok(Action::Load(ResolvedLoadArgs { + topology, + protocol_version: resolve_protocol_version( + args.target.protocol_version, + environment, + ProtocolVersion::default(), + )?, + request: LoadRequest { + engine: args.engine.into(), + smoke: args.smoke, + users: args.users, + spawn_rate: args.spawn_rate, + run_time: args.run_time, + }, + })) + } Command::Live(args) => { let lane = resolve_live_lane(args.target.lane, environment)?; if lane == LiveLane::Fixture && args.group != LiveGroup::Protocol { @@ -136,6 +150,7 @@ pub fn resolve_action(cli: Cli, environment: &Environment) -> Result { protocol_version: resolve_protocol_version( args.target.protocol_version, environment, + ProtocolVersion::default(), )?, }) } @@ -152,15 +167,19 @@ pub fn resolve_action(cli: Cli, environment: &Environment) -> Result { }, })), Command::Debug(args) => Ok(Action::Debug(match args.command { - DebugCommand::Inspect(args) => DebugAction::Inspect { - topology: resolve_routed_lane(args.target.lane, environment)?, - protocol_version: resolve_protocol_version( - args.target.protocol_version, - environment, - )?, - method: args.method, - server_id: args.server_id, - }, + DebugCommand::Inspect(args) => { + let topology = resolve_topology(args.target.lane, environment)?; + DebugAction::Inspect { + topology, + protocol_version: resolve_protocol_version( + args.target.protocol_version, + environment, + ProtocolVersion::default(), + )?, + method: args.method, + server_id: args.server_id, + } + } DebugCommand::Token(args) => { if args.kind == TokenKind::Admin && args.server_id.is_some() { bail!("--server-id is only valid with --kind scoped"); @@ -186,31 +205,22 @@ fn resolve_live_lane(lane: Option, environment: &Environment) -> Result }) } -fn resolve_routed_lane(lane: Option, environment: &Environment) -> Result { - match resolve_live_lane(lane, environment)? { - LiveLane::Fixture => { - bail!("--lane fixture-direct is only supported by live and conformance run") - } - LiveLane::Controlplane => Ok(StackMode::Controlplane), - LiveLane::Dataplane => Ok(StackMode::Dataplane), - } -} - fn resolve_protocol_version( explicit: Option, environment: &Environment, + fallback: ProtocolVersion, ) -> Result { if let Some(version) = explicit { return Ok(version); } let Some(value) = environment.get(OsStr::new(PROTOCOL_VERSION_ENV)) else { - return Ok(ProtocolVersion::default()); + return Ok(fallback); }; let value = value .to_str() .ok_or_else(|| anyhow::anyhow!("{PROTOCOL_VERSION_ENV} must be UTF-8"))?; if value.is_empty() { - return Ok(ProtocolVersion::default()); + return Ok(fallback); } ProtocolVersion::from_str(value) .map_err(|error| anyhow::anyhow!("invalid {PROTOCOL_VERSION_ENV}: {error}")) diff --git a/src/cli.rs b/src/cli.rs index decee4a..7a4ecce 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -86,7 +86,7 @@ pub enum Command { /// Manage Compose stacks. Stack(StackArgs), /// Probe one public MCP route. - Probe(WorkflowTargetArgs), + Probe(RoutedWorkflowTargetArgs), /// Run an MCP load test. Load(LoadArgs), /// Run upstream live gateway tests. @@ -152,14 +152,26 @@ pub struct TopologyArgs { pub topology: Option, } -/// Shared target selection for MCP workflows. +/// Target selection for routed MCP workflows. +#[derive(Debug, Clone, PartialEq, Eq, Args)] +pub struct RoutedWorkflowTargetArgs { + /// Execution lane; defaults to CF_MCP_STACK_MODE, then dataplane. + #[arg(long, value_enum, visible_alias = "topology")] + pub lane: Option, + + /// MCP version; defaults to MCP_PROTOCOL_VERSION, then 2025-11-25. + #[arg(long)] + pub protocol_version: Option, +} + +/// Target selection for MCP workflows that support a direct fixture lane. #[derive(Debug, Clone, PartialEq, Eq, Args)] pub struct WorkflowTargetArgs { /// Execution lane; defaults to CF_MCP_STACK_MODE, then dataplane. #[arg(long, value_enum, visible_alias = "topology")] pub lane: Option, - /// MCP protocol version; defaults to MCP_PROTOCOL_VERSION, then 2025-11-25. + /// MCP version; defaults to MCP_PROTOCOL_VERSION, then 2025-11-25. #[arg(long)] pub protocol_version: Option, } @@ -208,9 +220,9 @@ pub enum TopologySelection { /// Load-test options. #[derive(Debug, Clone, PartialEq, Args)] pub struct LoadArgs { - /// Shared lane and protocol-version selection. + /// Routed lane and protocol-version selection. #[command(flatten)] - pub target: WorkflowTargetArgs, + pub target: RoutedWorkflowTargetArgs, /// Load-test engine. #[arg(long, value_enum, default_value = "locust")] @@ -284,7 +296,7 @@ pub enum LiveGroup { Rbac, /// Protocol-specific gateway tests. Protocol, - /// Every upstream live gateway test. + /// Run the MCP, RBAC, and protocol groups. All, } @@ -431,16 +443,16 @@ pub struct DebugArgs { pub enum DebugCommand { /// Debug a live endpoint with the official MCP Inspector. Inspect(InspectArgs), - /// Print a gateway-compatible JWT. + /// Request and print a token from a running control plane. Token(TokenArgs), } /// Official Inspector options. #[derive(Debug, Clone, PartialEq, Eq, Args)] pub struct InspectArgs { - /// Shared lane and protocol-version selection. + /// Routed lane and protocol-version selection. #[command(flatten)] - pub target: WorkflowTargetArgs, + pub target: RoutedWorkflowTargetArgs, /// Inspector method such as tools/list. #[arg(long, default_value = "tools/list")] @@ -466,8 +478,8 @@ pub struct TokenArgs { /// Token privilege level. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum TokenKind { - /// Minimum scopes needed by public MCP tests. + /// Catalog token with the minimum scopes needed by public MCP tests. Scoped, - /// Platform-admin token for fixture setup. + /// Authenticated platform-admin session token. Admin, } diff --git a/src/runtime/compliance.rs b/src/runtime/compliance.rs index 4348754..ad5c4b8 100644 --- a/src/runtime/compliance.rs +++ b/src/runtime/compliance.rs @@ -40,7 +40,13 @@ impl RuntimeExecutor { .env(CONFORMANCE_SERVER_ERA_ENV, server_era.label()); self.runner.run_async(&build).await?; - let up = project.command(["up", "-d", "--wait", OFFICIAL_CONFORMANCE_SERVICE]); + let up = project.command([ + "up", + "-d", + "--wait", + OFFICIAL_CONFORMANCE_SERVICE, + OFFICIAL_CONFORMANCE_PROXY_SERVICE, + ]); let up = self .compose_environment(up, topology, true)? .env(CONFORMANCE_SERVER_ERA_ENV, server_era.label()); @@ -63,6 +69,7 @@ impl RuntimeExecutor { "rm", "--stop", "--force", + OFFICIAL_CONFORMANCE_PROXY_SERVICE, OFFICIAL_CONFORMANCE_SERVICE, ]); let remove = self.compose_environment(remove, topology, true)?; @@ -147,6 +154,7 @@ impl RuntimeExecutor { let mut fixture_metadata = None; let mut fixture_endpoint = None; let mut service_started = false; + let mut managed_token = None; if topology_failure.is_none() { let (start_result, start_interrupted) = finish_phase_after_interrupt( @@ -229,7 +237,7 @@ impl RuntimeExecutor { } if topology_failure.is_none() && run_routed { - match self.admin_token().and_then(|token| { + match self.admin_session_token().await.and_then(|token| { ConformanceFixtureClient::builder(self.base_url()?, token) .build() .map_err(AppFailure::from) @@ -274,14 +282,21 @@ impl RuntimeExecutor { .zip(fixture_metadata.as_ref()); match run_inputs { Some((fixture, metadata)) => { - match self.generated_bearer_token(topology, &fixture.server_id) { + match self + .managed_bearer_token(topology, &fixture.server_id) + .await + { Ok(token) => { + managed_token = Some(token); + let token = managed_token + .as_ref() + .expect("managed token was just stored"); let tests = async { self.run_official_conformance_mode( &OfficialConformanceRun { topology, server_id: &fixture.server_id, - token: &token, + token: &token.value, spec_version, server_era, fixture: metadata, @@ -314,6 +329,12 @@ impl RuntimeExecutor { } } + if let Some(token) = managed_token.as_ref() { + topology_failure = + finish_with_cleanup(topology_failure, self.revoke_managed_token(token).await) + .err(); + } + if let Some((client, fixture)) = fixture_state { let api_cleanup = client .cleanup(Some(&fixture)) @@ -838,7 +859,7 @@ mod tests { ConformanceServerEra::Legacy, OutputStyle::plain(), ), - "────────────\n MCP conformance lane: fixture direct\n Starting 40 scenarios with @modelcontextprotocol/conformance@0.2.0-alpha.9 (client 2026-07-28, server legacy)" + "────────────\n MCP conformance lane: fixture direct\n Starting 40 scenarios with @modelcontextprotocol/conformance@0.2.0-alpha.11 (client 2026-07-28, server legacy)" ); } diff --git a/src/runtime/inspect.rs b/src/runtime/inspect.rs index 6f3f313..4be13e9 100644 --- a/src/runtime/inspect.rs +++ b/src/runtime/inspect.rs @@ -2,7 +2,7 @@ use super::*; -const INSPECTOR_PACKAGE: &str = "@modelcontextprotocol/inspector@0.22.0"; +const INSPECTOR_PACKAGE: &str = "@modelcontextprotocol/inspector@2.2.0"; pub(super) const NPM_ENV_ALLOWLIST: &[&str] = &[ "PATH", "HOME", @@ -28,14 +28,18 @@ impl RuntimeExecutor { let server_id = server_id .unwrap_or_else(|| self.default_server_id()) .to_owned(); - self.with_managed_test_target(mode, &server_id, || async { - let token = self.bearer_token(mode, &server_id)?; - let endpoint = - GatewayClient::new(gateway_topology(mode), self.base_url()?, &server_id, &token) - .context("failed to construct the Inspector gateway endpoint") - .map_err(AppFailure::from)? - .endpoint() - .clone(); + let operation_server_id = server_id.clone(); + self.with_managed_authenticated_target(mode, &server_id, |token| async move { + let endpoint = GatewayClient::new( + gateway_topology(mode), + self.base_url()?, + &operation_server_id, + &token, + ) + .context("failed to construct the Inspector gateway endpoint") + .map_err(AppFailure::from)? + .endpoint() + .clone(); let proxy = AuthProxy::start_with_protocol_version( endpoint, &token, diff --git a/src/runtime/live.rs b/src/runtime/live.rs index fddfe6f..5e0ba3a 100644 --- a/src/runtime/live.rs +++ b/src/runtime/live.rs @@ -3,6 +3,11 @@ use super::*; const FAST_TEST_SERVER_ID: &str = "b8e3f1a2c4d5e6f7a1b2c3d4e5f6a7b8"; +const LIVE_ALL_TARGETS: [&str; 3] = [ + "test-mcp-protocol-e2e", + "test-mcp-rbac", + "test-protocol-compliance-gateway", +]; impl RuntimeExecutor { pub(super) async fn run_live( @@ -105,47 +110,12 @@ impl RuntimeExecutor { topology: StackMode, protocol_version: &ProtocolVersion, ) -> AppResult<()> { - let pass_one = CommandSpec::new("uv") - .args([ - "run", - "--extra", - "plugins", - "pytest", - "-p", - "no:playwright", - "tests/live_gateway/", - "--ignore=tests/live_gateway/sso", - "--ignore=tests/live_gateway/mcp/test_mcp_rbac_transport.py", - "-v", - "--tb=short", - ]) - .cwd(self.config.controlplane_dir()); - let pass_one = self.live_protocol_environment(pass_one, protocol_version)?; - let pass_two = CommandSpec::new("uv") - .args([ - "run", - "--extra", - "plugins", - "pytest", - "-p", - "playwright", - "tests/live_gateway/sso", - "tests/live_gateway/mcp/test_mcp_rbac_transport.py", - "-v", - "--tb=short", - ]) - .cwd(self.config.controlplane_dir()); - let pass_two = self.live_protocol_environment(pass_two, protocol_version)?; - - let first = self - .runner - .run(&self.compose_environment(pass_one, topology, false)?) - .map_err(AppFailure::from); - let second = self - .runner - .run(&self.compose_environment(pass_two, topology, false)?) - .map_err(AppFailure::from); - combine_live_results(first, second) + combine_live_results(LIVE_ALL_TARGETS.map(|target| { + ( + target, + self.run_controlplane_make(topology, target, protocol_version), + ) + })) } fn live_protocol_environment( @@ -192,13 +162,20 @@ const fn live_group_needs_fast_test(group: LiveGroup) -> bool { matches!(group, LiveGroup::Mcp | LiveGroup::All) } -fn combine_live_results(first: AppResult<()>, second: AppResult<()>) -> AppResult<()> { - match (first, second) { - (Ok(()), Ok(())) => Ok(()), - (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), - (Err(first), Err(second)) => Err(AppFailure::from(anyhow!( - "first live-test pass failed: {first}; second live-test pass also failed: {second}" - ))), +fn combine_live_results( + results: impl IntoIterator)>, +) -> AppResult<()> { + let failures = results + .into_iter() + .filter_map(|(group, result)| result.err().map(|error| format!("{group}: {error}"))) + .collect::>(); + if failures.is_empty() { + Ok(()) + } else { + Err(AppFailure::from(anyhow!( + "live-test groups failed: {}", + failures.join("; ") + ))) } } @@ -215,16 +192,29 @@ mod tests { } #[test] - fn both_live_all_failures_are_preserved() { - let first = Err(AppFailure::from(anyhow!("first failure"))); - let second = Err(AppFailure::from(anyhow!("second failure"))); + fn live_all_is_the_exact_union_of_documented_groups() { + assert_eq!( + LIVE_ALL_TARGETS, + [ + "test-mcp-protocol-e2e", + "test-mcp-rbac", + "test-protocol-compliance-gateway" + ] + ); + } - let error = combine_live_results(first, second) - .expect_err("both failures should fail the live workflow") - .to_string(); + #[test] + fn every_live_all_failure_is_preserved() { + let error = combine_live_results([ + ("mcp", Err(AppFailure::from(anyhow!("first failure")))), + ("rbac", Ok(())), + ("protocol", Err(AppFailure::from(anyhow!("second failure")))), + ]) + .expect_err("multiple failures should fail the live workflow") + .to_string(); - assert!(error.contains("first failure")); - assert!(error.contains("second failure")); + assert!(error.contains("mcp: first failure")); + assert!(error.contains("protocol: second failure")); } #[test] diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index edef50f..aa2bcdd 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -16,8 +16,9 @@ use cf_integration_compliance::conformance::{ write_comparison_report, }; use cf_integration_compliance::conformance_fixture::{ - ConformanceFixtureClient, OFFICIAL_CONFORMANCE_BACKEND_URL, OFFICIAL_CONFORMANCE_REPOSITORY, - OFFICIAL_CONFORMANCE_REVISION, OFFICIAL_CONFORMANCE_SERVER_ID, OFFICIAL_CONFORMANCE_SERVICE, + ConformanceFixtureClient, OFFICIAL_CONFORMANCE_BACKEND_URL, OFFICIAL_CONFORMANCE_PROXY_SERVICE, + OFFICIAL_CONFORMANCE_REPOSITORY, OFFICIAL_CONFORMANCE_REVISION, OFFICIAL_CONFORMANCE_SERVER_ID, + OFFICIAL_CONFORMANCE_SERVICE, }; use cf_integration_load::{ GooseLoadConfig, LoadEngine, LoadSettings, LocustCommand, audit_locust_reports, @@ -37,6 +38,7 @@ use cf_integration_platform::stack::{ StackFreshness, resolve_build, }; use cf_integration_platform::{PlatformError, StackMode}; +use serde::Deserialize; use crate::OutputStyle; use crate::app::{ @@ -45,13 +47,13 @@ use crate::app::{ }; use crate::cli::{LiveGroup, ProtocolVersion, TokenKind as CliTokenKind, TopologySelection}; use crate::error::AppFailure; -use crate::token::{TokenKind, make_token}; type AppResult = std::result::Result; const STACK_READY_TIMEOUT: Duration = Duration::from_secs(90); const STACK_READY_POLL_INTERVAL: Duration = Duration::from_millis(250); const STACK_READY_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); +const MANAGED_TOKEN_DESCRIPTION: &str = "Ephemeral cf-integration dataplane credential"; mod compliance; mod inspect; @@ -70,6 +72,28 @@ pub struct RuntimeExecutor { runner: R, } +struct ManagedBearerToken { + value: String, + catalog_id: Option, + catalog_admin_token: Option, +} + +#[derive(Deserialize)] +struct TokenCreateResponse { + token: TokenRecord, + access_token: String, +} + +#[derive(Deserialize)] +struct TokenRecord { + id: String, +} + +#[derive(Deserialize)] +struct AuthenticationResponse { + access_token: String, +} + impl RuntimeExecutor { /// Creates an executor without starting any process. #[must_use] @@ -101,7 +125,7 @@ impl RuntimeExecutor { } => self.run_live(lane, group, &protocol_version).await, Action::Conformance(action) => self.execute_conformance(action).await, Action::Debug(DebugAction::Token { kind, server_id }) => { - self.print_token(kind, server_id) + self.print_token(kind, server_id).await } Action::Debug(DebugAction::Inspect { topology, @@ -117,16 +141,14 @@ impl RuntimeExecutor { } impl RuntimeExecutor { - fn print_token(&self, kind: CliTokenKind, server_id: Option) -> AppResult<()> { - let secret = required_text(&self.config.jwt_secret_key().value, "JWT_SECRET_KEY")?; - let subject = required_text(&self.config.jwt_subject().value, "MCP_JWT_SUBJECT")?; - let token_kind = match kind { - CliTokenKind::Scoped => TokenKind::Scoped { - server_id: Some(server_id.unwrap_or_else(|| self.default_server_id().to_owned())), - }, - CliTokenKind::Admin => TokenKind::Admin, + async fn print_token(&self, kind: CliTokenKind, server_id: Option) -> AppResult<()> { + let token = match kind { + CliTokenKind::Scoped => { + let server_id = server_id.unwrap_or_else(|| self.default_server_id().to_owned()); + self.issue_dataplane_token(&server_id).await?.value + } + CliTokenKind::Admin => self.admin_session_token().await?, }; - let token = make_token(secret, subject, token_kind).map_err(AppFailure::from)?; println!("{token}"); Ok(()) } @@ -146,32 +168,177 @@ impl RuntimeExecutor { required_text(&self.config.base_url().value, "MCP_CLI_BASE_URL") } - fn bearer_token(&self, mode: StackMode, server_id: &str) -> AppResult { + async fn managed_bearer_token( + &self, + mode: StackMode, + server_id: &str, + ) -> AppResult { if let Some(token) = self .environment_text("MCPGATEWAY_BEARER_TOKEN") .filter(|token| !token.is_empty()) { - return Ok(token.to_owned()); + return Ok(ManagedBearerToken { + value: token.to_owned(), + catalog_id: None, + catalog_admin_token: None, + }); } - self.generated_bearer_token(mode, server_id) + if mode == StackMode::Controlplane { + return Ok(ManagedBearerToken { + value: self.admin_session_token().await?, + catalog_id: None, + catalog_admin_token: None, + }); + } + + self.issue_dataplane_token(server_id).await } - fn generated_bearer_token(&self, mode: StackMode, server_id: &str) -> AppResult { - let secret = required_text(&self.config.jwt_secret_key().value, "JWT_SECRET_KEY")?; - let subject = required_text(&self.config.jwt_subject().value, "MCP_JWT_SUBJECT")?; - let kind = match mode { - StackMode::Dataplane => TokenKind::Scoped { - server_id: Some(server_id.to_owned()), - }, - StackMode::Controlplane => TokenKind::Admin, - }; - make_token(secret, subject, kind).map_err(AppFailure::from) + async fn admin_session_token(&self) -> AppResult { + let endpoint = url::Url::parse(self.base_url()?) + .context("MCP_CLI_BASE_URL is not a valid URL") + .and_then(|base| { + base.join("/v1/auth/email/login") + .context("failed to construct control-plane login URL") + }) + .map_err(AppFailure::from)?; + let email = required_text( + &self.config.platform_admin_email().value, + "PLATFORM_ADMIN_EMAIL", + )?; + let password = required_text( + &self.config.platform_admin_password().value, + "PLATFORM_ADMIN_PASSWORD", + )?; + let response = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .timeout(Duration::from_secs(30)) + .build() + .context("failed to build control-plane login client") + .map_err(AppFailure::from)? + .post(endpoint) + .json(&serde_json::json!({"email": email, "password": password})) + .send() + .await + .context("control-plane login failed before receiving a response") + .map_err(AppFailure::from)?; + if !response.status().is_success() { + return Err(AppFailure::from(anyhow!( + "control-plane login returned HTTP {}", + response.status().as_u16() + ))); + } + let authenticated: AuthenticationResponse = response + .json() + .await + .context("control-plane login returned an invalid authentication response") + .map_err(AppFailure::from)?; + if authenticated.access_token.is_empty() { + return Err(AppFailure::from(anyhow!( + "control-plane login returned an empty access token" + ))); + } + Ok(authenticated.access_token) } - fn admin_token(&self) -> AppResult { - let secret = required_text(&self.config.jwt_secret_key().value, "JWT_SECRET_KEY")?; - let subject = required_text(&self.config.jwt_subject().value, "MCP_JWT_SUBJECT")?; - make_token(secret, subject, TokenKind::Admin).map_err(AppFailure::from) + async fn issue_dataplane_token(&self, server_id: &str) -> AppResult { + let endpoint = url::Url::parse(self.base_url()?) + .context("MCP_CLI_BASE_URL is not a valid URL") + .and_then(|base| { + base.join("/v1/tokens") + .context("failed to construct token catalog URL") + }) + .map_err(AppFailure::from)?; + let admin_token = self.admin_session_token().await?; + let user_email = required_text( + &self.config.platform_admin_email().value, + "PLATFORM_ADMIN_EMAIL", + )?; + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .timeout(Duration::from_secs(30)) + .build() + .context("failed to build token catalog client") + .map_err(AppFailure::from)?; + let response = http + .post(endpoint) + .bearer_auth(&admin_token) + .json(&serde_json::json!({ + "name": format!("cf-integration-{}", uuid::Uuid::new_v4()), + "description": MANAGED_TOKEN_DESCRIPTION, + "expires_in_days": 1, + "user_email": user_email, + "scope": { + "server_id": server_id, + "permissions": ["servers.read", "servers.use", "tools.read", "tools.call"], + }, + })) + .send() + .await + .context("token catalog request failed before receiving a response") + .map_err(AppFailure::from)?; + if !response.status().is_success() { + return Err(AppFailure::from(anyhow!( + "token catalog returned HTTP {} while issuing a dataplane credential", + response.status().as_u16() + ))); + } + let issued: TokenCreateResponse = response + .json() + .await + .context("token catalog returned an invalid credential response") + .map_err(AppFailure::from)?; + if issued.token.id.is_empty() || issued.access_token.is_empty() { + return Err(AppFailure::from(anyhow!( + "token catalog returned an incomplete credential response" + ))); + } + Ok(ManagedBearerToken { + value: issued.access_token, + catalog_id: Some(issued.token.id), + catalog_admin_token: Some(admin_token), + }) + } + + async fn revoke_managed_token(&self, token: &ManagedBearerToken) -> AppResult<()> { + let Some(id) = token.catalog_id.as_deref() else { + return Ok(()); + }; + let admin_token = token.catalog_admin_token.as_deref().ok_or_else(|| { + AppFailure::from(anyhow!( + "managed token is missing its control-plane cleanup credential" + )) + })?; + let endpoint = url::Url::parse(self.base_url()?) + .context("MCP_CLI_BASE_URL is not a valid URL") + .and_then(|base| { + base.join(&format!("/v1/tokens/{id}")) + .context("failed to construct token revocation URL") + }) + .map_err(AppFailure::from)?; + let response = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .timeout(Duration::from_secs(30)) + .build() + .context("failed to build token catalog client") + .map_err(AppFailure::from)? + .delete(endpoint) + .bearer_auth(admin_token) + .send() + .await + .context("token revocation failed before receiving a response") + .map_err(AppFailure::from)?; + if response.status().is_success() || response.status() == reqwest::StatusCode::NOT_FOUND { + Ok(()) + } else { + Err(AppFailure::from(anyhow!( + "token catalog returned HTTP {} while revoking the dataplane credential", + response.status().as_u16() + ))) + } } } @@ -265,3 +432,215 @@ const fn gateway_topology(mode: StackMode) -> GatewayTopology { StackMode::Dataplane => GatewayTopology::Dataplane, } } + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use axum::Router; + use axum::body::Body; + use axum::extract::{Request, State}; + use axum::http::{HeaderMap, Method, Response, StatusCode}; + use axum::routing::any; + use cf_integration_platform::config::Environment; + use cf_integration_platform::process::SystemProcessRunner; + use serde_json::{Value, json}; + use tokio::net::TcpListener; + + use super::*; + + type CapturedRequest = (Method, String, HeaderMap, Value); + + #[derive(Clone, Default)] + struct Capture(Arc>>); + + async fn token_catalog(State(capture): State, request: Request) -> Response { + let (parts, body) = request.into_parts(); + let body = axum::body::to_bytes(body, 64 * 1024) + .await + .expect("token request body should fit"); + let body = if body.is_empty() { + Value::Null + } else { + serde_json::from_slice(&body).expect("token request body should be JSON") + }; + capture + .0 + .lock() + .expect("token capture lock should not be poisoned") + .push(( + parts.method.clone(), + parts.uri.path().to_owned(), + parts.headers, + body, + )); + + if parts.method == Method::POST && parts.uri.path() == "/v1/auth/email/login" { + Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(Body::from( + json!({"access_token": "admin-session-token"}).to_string(), + )) + .expect("login response should build") + } else if parts.method == Method::POST && parts.uri.path() == "/v1/tokens" { + Response::builder() + .status(StatusCode::CREATED) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "token": {"id": "catalog-token-id"}, + "access_token": "issued-dataplane-token" + }) + .to_string(), + )) + .expect("token response should build") + } else if parts.method == Method::DELETE + && parts.uri.path() == "/v1/tokens/catalog-token-id" + { + Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .expect("revocation response should build") + } else { + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::empty()) + .expect("not-found response should build") + } + } + + fn app_config(root: &Path, base_url: &str, extra: &[(&str, &str)]) -> AppConfig { + fs::write( + root.join("Cargo.toml"), + "[package]\nname='test'\nversion='0.0.0'\n", + ) + .expect("temporary manifest should be written"); + fs::create_dir_all(root.join("docker")).expect("temporary docker directory should exist"); + fs::write( + root.join("docker/docker-compose.cf-integration.yaml"), + "services: {}\n", + ) + .expect("temporary Compose marker should be written"); + let mut environment = Environment::from([ + (OsString::from("MCP_CLI_BASE_URL"), OsString::from(base_url)), + ( + OsString::from("JWT_SECRET_KEY"), + OsString::from("test-jwt-secret-that-is-longer-than-32-bytes"), + ), + ( + OsString::from("AUTH_ENCRYPTION_SECRET"), + OsString::from("test-auth-secret-that-is-longer-than-32-bytes"), + ), + ( + OsString::from("PLATFORM_ADMIN_EMAIL"), + OsString::from("operator@example.test"), + ), + ( + OsString::from("PLATFORM_ADMIN_PASSWORD"), + OsString::from("integration-password"), + ), + ]); + environment.extend( + extra + .iter() + .map(|(key, value)| (OsString::from(key), OsString::from(value))), + ); + AppConfig::load( + &environment, + &root.join("target/debug/cf-integration"), + root, + ) + .expect("test application config should load") + .config + } + + #[tokio::test] + async fn dataplane_tokens_are_issued_by_uuid_aware_catalog_and_revoked() { + let capture = Capture::default(); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("token catalog listener should bind"); + let address = listener + .local_addr() + .expect("token catalog listener should have an address"); + let server = tokio::spawn( + axum::serve( + listener, + Router::new() + .fallback(any(token_catalog)) + .with_state(capture.clone()), + ) + .into_future(), + ); + let root = tempfile::tempdir().expect("temporary repository should be created"); + let config = app_config(root.path(), &format!("http://{address}"), &[]); + let runtime = RuntimeExecutor::new(config, SystemProcessRunner); + + let token = runtime + .managed_bearer_token(StackMode::Dataplane, "server-id") + .await + .expect("dataplane token should be issued"); + assert_eq!(token.value, "issued-dataplane-token"); + assert_eq!(token.catalog_id.as_deref(), Some("catalog-token-id")); + runtime + .revoke_managed_token(&token) + .await + .expect("managed token should be revoked"); + server.abort(); + + let requests = capture + .0 + .lock() + .expect("token capture lock should not be poisoned"); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].0, Method::POST); + assert_eq!(requests[0].1, "/v1/auth/email/login"); + assert_eq!(requests[0].3["email"], "operator@example.test"); + assert_eq!(requests[1].0, Method::POST); + assert_eq!(requests[1].1, "/v1/tokens"); + assert_eq!(requests[1].3["user_email"], "operator@example.test"); + assert_eq!(requests[1].3["expires_in_days"], 1); + assert_eq!(requests[1].3["scope"]["server_id"], "server-id"); + assert_eq!( + requests[1].3["scope"]["permissions"], + json!(["servers.read", "servers.use", "tools.read", "tools.call"]) + ); + assert!( + requests[1].3["name"] + .as_str() + .is_some_and(|name| name.starts_with("cf-integration-")) + ); + assert!(requests[1].2.contains_key("authorization")); + assert_eq!(requests[2].0, Method::DELETE); + assert_eq!(requests[2].1, "/v1/tokens/catalog-token-id"); + assert!(requests[2].2.contains_key("authorization")); + assert_eq!( + requests[1].2["authorization"], + requests[2].2["authorization"] + ); + } + + #[tokio::test] + async fn caller_managed_tokens_bypass_catalog_and_are_not_revoked() { + let root = tempfile::tempdir().expect("temporary repository should be created"); + let config = app_config( + root.path(), + "http://127.0.0.1:9", + &[("MCPGATEWAY_BEARER_TOKEN", "caller-token")], + ); + let runtime = RuntimeExecutor::new(config, SystemProcessRunner); + + let token = runtime + .managed_bearer_token(StackMode::Dataplane, "server-id") + .await + .expect("caller token should not contact the catalog"); + assert_eq!(token.value, "caller-token"); + assert_eq!(token.catalog_id, None); + assert_eq!(token.catalog_admin_token, None); + runtime + .revoke_managed_token(&token) + .await + .expect("caller token cleanup should be a no-op"); + } +} diff --git a/src/runtime/stack.rs b/src/runtime/stack.rs index bace159..6bb993b 100644 --- a/src/runtime/stack.rs +++ b/src/runtime/stack.rs @@ -167,7 +167,7 @@ impl RuntimeExecutor { } pub(super) fn compose_project(&self, mode: StackMode) -> ComposeProject { - match mode { + let project = match mode { StackMode::Dataplane => ComposeProject::dataplane( self.config.root(), self.config.controlplane_dir(), @@ -180,7 +180,8 @@ impl RuntimeExecutor { self.config.controlplane_project().value.clone(), self.environment_flag("CONTROLPLANE_ENABLE_SSO", false), ), - } + }; + project.with_conformance_overlay(self.config.root()) } pub(super) fn conformance_compose_project(&self, mode: StackMode) -> ComposeProject { @@ -239,6 +240,10 @@ impl RuntimeExecutor { "PLATFORM_ADMIN_EMAIL", self.config.platform_admin_email().value.clone(), ) + .env( + "PLATFORM_ADMIN_PASSWORD", + self.config.platform_admin_password().value.clone(), + ) .env( "KEY_FILE_PASSWORD", self.config.key_file_password().value.clone(), diff --git a/src/runtime/workloads.rs b/src/runtime/workloads.rs index 7ccc01b..9ac0078 100644 --- a/src/runtime/workloads.rs +++ b/src/runtime/workloads.rs @@ -25,8 +25,7 @@ impl RuntimeExecutor { protocol_version: &ProtocolVersion, ) -> AppResult<()> { let server_id = self.default_server_id().to_owned(); - self.with_managed_test_target(topology, &server_id, || async { - let token = self.bearer_token(topology, &server_id)?; + self.with_managed_authenticated_target(topology, &server_id, |token| async { let config = ProbeConfig { mode: gateway_topology(topology), base_url: self.base_url()?.to_owned(), @@ -53,8 +52,8 @@ impl RuntimeExecutor { pub(super) async fn run_load(&self, args: ResolvedLoadArgs) -> AppResult<()> { let server_id = self.default_server_id().to_owned(); - self.with_managed_test_target(args.topology, &server_id, || async { - let token = self.bearer_token(args.topology, &server_id)?; + let operation_server_id = server_id.clone(); + self.with_managed_authenticated_target(args.topology, &server_id, |token| async move { let settings = LoadSettings::resolve(&self.config, &args.request).map_err(AppFailure::from)?; match args.request.engine { @@ -64,7 +63,8 @@ impl RuntimeExecutor { args.topology, &settings, &token, - (args.topology == StackMode::Dataplane).then_some(server_id.as_str()), + (args.topology == StackMode::Dataplane) + .then_some(operation_server_id.as_str()), args.protocol_version.as_str(), ) .map_err(AppFailure::from)?; @@ -83,7 +83,7 @@ impl RuntimeExecutor { args.topology, &settings, &token, - &server_id, + &operation_server_id, &args.protocol_version, ) .await @@ -116,6 +116,24 @@ impl RuntimeExecutor { ) } + pub(super) async fn with_managed_authenticated_target( + &self, + topology: StackMode, + server_id: &str, + operation: F, + ) -> AppResult<()> + where + F: FnOnce(String) -> Fut, + Fut: Future>, + { + self.with_managed_test_target(topology, server_id, || async { + let token = self.managed_bearer_token(topology, server_id).await?; + let primary = operation(token.value.clone()).await; + finish_with_cleanup(primary.err(), self.revoke_managed_token(&token).await) + }) + .await + } + pub(super) async fn prepare_test_target( &self, topology: StackMode, diff --git a/tests/cli.rs b/tests/cli.rs index 560e4e6..75b0cd2 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,9 +1,11 @@ use std::ffi::OsString; +use std::process::Command as ProcessCommand; use cf_integration::cli::{ Cli, CliConformanceServerEra, CliLane, CliLoadEngine, CliTopology, Command, ConformanceArgs, - ConformanceCommand, DebugArgs, DebugCommand, LiveGroup, LoadArgs, ProtocolVersion, StackArgs, - StackCommand, TokenKind, TopologySelection, WorkflowTargetArgs, + ConformanceCommand, DebugArgs, DebugCommand, LiveGroup, LoadArgs, ProtocolVersion, + RoutedWorkflowTargetArgs, StackArgs, StackCommand, TokenKind, TopologySelection, + WorkflowTargetArgs, }; use clap::{CommandFactory, Parser, error::ErrorKind}; @@ -53,6 +55,43 @@ fn command_tree_contains_only_distinct_public_workflows() { assert_eq!(subcommands(&["debug"]), ["inspect", "token"]); } +#[test] +fn every_public_command_renders_help_from_the_binary() { + let paths: &[&[&str]] = &[ + &[], + &["stack"], + &["stack", "up"], + &["stack", "down"], + &["stack", "status"], + &["stack", "logs"], + &["stack", "config"], + &["probe"], + &["load"], + &["live"], + &["conformance"], + &["conformance", "run"], + &["conformance", "report"], + &["debug"], + &["debug", "inspect"], + &["debug", "token"], + ]; + + for path in paths { + let output = ProcessCommand::new(env!("CARGO_BIN_EXE_cf-integration")) + .args(*path) + .arg("--help") + .output() + .expect("help command should start"); + assert!( + output.status.success(), + "help failed for {path:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).expect("help should be UTF-8"); + assert!(stdout.contains("Usage:"), "missing usage for {path:?}"); + } +} + #[test] fn obsolete_root_commands_and_combined_workflows_are_rejected() { for command in REMOVED_COMMANDS { @@ -235,7 +274,19 @@ fn live_accepts_fixture_lane_and_explicit_protocol_version() { #[test] fn operational_workflows_share_canonical_lane_and_protocol_version_flags() { - fn assert_target(target: &WorkflowTargetArgs) { + fn assert_routed_target(target: &RoutedWorkflowTargetArgs) { + assert_eq!(target.lane, Some(CliTopology::Controlplane)); + assert_eq!( + target.protocol_version, + Some( + "2025-06-18" + .parse::() + .expect("valid protocol version") + ) + ); + } + + fn assert_fixture_target(target: &WorkflowTargetArgs) { assert_eq!(target.lane, Some(CliLane::Controlplane)); assert_eq!( target.protocol_version, @@ -258,7 +309,7 @@ fn operational_workflows_share_canonical_lane_and_protocol_version_flags() { else { panic!("expected probe workflow") }; - assert_target(&probe); + assert_routed_target(&probe); let Command::Load(load) = parse( &["cf-integration", "load"] @@ -270,7 +321,7 @@ fn operational_workflows_share_canonical_lane_and_protocol_version_flags() { else { panic!("expected load workflow") }; - assert_target(&load.target); + assert_routed_target(&load.target); let Command::Live(live) = parse( &["cf-integration", "live"] @@ -282,7 +333,7 @@ fn operational_workflows_share_canonical_lane_and_protocol_version_flags() { else { panic!("expected live workflow") }; - assert_target(&live.target); + assert_fixture_target(&live.target); let Command::Debug(DebugArgs { command: DebugCommand::Inspect(inspect), @@ -296,7 +347,25 @@ fn operational_workflows_share_canonical_lane_and_protocol_version_flags() { else { panic!("expected inspect workflow") }; - assert_target(&inspect.target); + assert_routed_target(&inspect.target); +} + +#[test] +fn routed_workflows_reject_the_fixture_lane_during_parsing() { + for arguments in [ + vec!["cf-integration", "probe", "--lane", "fixture-direct"], + vec!["cf-integration", "load", "--lane", "fixture-direct"], + vec![ + "cf-integration", + "debug", + "inspect", + "--lane", + "fixture-direct", + ], + ] { + let error = Cli::try_parse_from(arguments).expect_err("routed lane should be rejected"); + assert_eq!(error.kind(), ErrorKind::InvalidValue); + } } #[test] diff --git a/tests/dispatch.rs b/tests/dispatch.rs index c0c4903..0d4f413 100644 --- a/tests/dispatch.rs +++ b/tests/dispatch.rs @@ -206,30 +206,6 @@ fn live_fixture_lane_rejects_non_protocol_groups() { ); } -#[test] -fn fixture_lane_is_rejected_by_workflows_without_a_direct_fixture() { - for arguments in [ - vec!["cf-integration", "probe", "--lane", "fixture-direct"], - vec!["cf-integration", "load", "--lane", "fixture-direct"], - vec![ - "cf-integration", - "debug", - "inspect", - "--lane", - "fixture-direct", - ], - ] { - let cli = Cli::try_parse_from(arguments).expect("shared lane syntax should parse"); - let error = resolve_action(cli, &Environment::new()) - .expect_err("fixture lane should require a direct-fixture workflow"); - assert!( - error - .to_string() - .contains("only supported by live and conformance run") - ); - } -} - #[test] fn conformance_defaults_to_all_three_ordered_lanes() { assert_eq!(