diff --git a/.env.example b/.env.example index 0ffd9e0..f542d15 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,32 +83,44 @@ 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= -# Shared Locust/Goose load defaults. A smoke run uses 1 user at 1 user/second +# Locust load defaults. A smoke run uses 1 user at 1 user/second # for 10s unless LOCUST_* is exported by the calling process. LOCUST_USERS=100 LOCUST_SPAWN_RATE=10 diff --git a/Cargo.lock b/Cargo.lock index a5bd42f..e30cd6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,30 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -82,24 +58,6 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "async-compression" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" -dependencies = [ - "compression-codecs", - "compression-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "async-trait" version = "0.1.89" @@ -108,7 +66,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -231,15 +189,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -307,16 +256,9 @@ name = "cf-integration-load" version = "0.1.0" dependencies = [ "anyhow", - "axum", "cf-integration-mcp", "cf-integration-platform", - "goose", - "reqwest", - "serde_json", "tempfile", - "thiserror", - "tokio", - "url", ] [[package]] @@ -371,18 +313,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", -] - [[package]] name = "clap" version = "4.6.1" @@ -414,7 +344,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -448,58 +378,12 @@ dependencies = [ "memchr", ] -[[package]] -name = "compression-codecs" -version = "0.4.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" -dependencies = [ - "compression-core", - "flate2", - "memchr", -] - -[[package]] -name = "compression-core" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" - [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "cookie_store" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" -dependencies = [ - "cookie", - "document-features", - "idna", - "log", - "publicsuffix", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -534,15 +418,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "crypto-bigint" version = "0.5.5" @@ -565,17 +440,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "ctrlc" -version = "3.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" -dependencies = [ - "dispatch2", - "nix", - "windows-sys 0.61.2", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -600,15 +464,9 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - [[package]] name = "der" version = "0.7.10" @@ -638,18 +496,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -658,24 +504,9 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", + "syn", ] -[[package]] -name = "downcast-rs" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" - [[package]] name = "dunce" version = "1.0.5" @@ -720,12 +551,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - [[package]] name = "elliptic-curve" version = "0.13.8" @@ -768,9 +593,6 @@ name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -dependencies = [ - "getrandom 0.3.4", -] [[package]] name = "ff" @@ -794,28 +616,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "flume" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" -dependencies = [ - "fastrand", - "futures-core", - "futures-sink", - "spin", -] - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -831,21 +631,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -853,7 +638,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -862,17 +646,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - [[package]] name = "futures-io" version = "0.3.32" @@ -887,7 +660,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -908,7 +681,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -943,20 +715,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -966,41 +724,11 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", + "r-efi", "rand_core 0.10.1", "wasm-bindgen", ] -[[package]] -name = "goose" -version = "0.19.0-dev" -source = "git+https://github.com/tag1consulting/goose?rev=614f75149cab5a963a8bc26d7b62639dedfa7a2b#614f75149cab5a963a8bc26d7b62639dedfa7a2b" -dependencies = [ - "async-trait", - "chrono", - "ctrlc", - "downcast-rs", - "flume", - "futures", - "gumdrop", - "http", - "itertools", - "lazy_static", - "log", - "num-format", - "rand 0.9.4", - "regex", - "reqwest", - "serde", - "serde_json", - "simplelog", - "strum", - "strum_macros", - "tokio", - "tokio-tungstenite", - "url", -] - [[package]] name = "group" version = "0.13.0" @@ -1012,26 +740,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "gumdrop" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bc700f989d2f6f0248546222d9b4258f5b02a171a431f8285a81c08142629e3" -dependencies = [ - "gumdrop_derive", -] - -[[package]] -name = "gumdrop_derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "729f9bd3449d77e7831a18abfb7ba2f99ee813dfd15b8c2167c9a54ba20aa99d" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -1166,30 +874,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -1315,15 +999,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -1357,7 +1032,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn", ] [[package]] @@ -1376,7 +1051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -1457,21 +1132,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.33" @@ -1502,16 +1162,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "1.2.1" @@ -1523,18 +1173,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "num-bigint" version = "0.4.8" @@ -1567,16 +1205,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[package]] -name = "num-format" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" -dependencies = [ - "arrayvec", - "itoa", -] - [[package]] name = "num-integer" version = "0.1.46" @@ -1606,30 +1234,6 @@ dependencies = [ "libm", ] -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - [[package]] name = "once_cell" version = "1.21.4" @@ -1772,22 +1376,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "psl-types" -version = "2.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" - -[[package]] -name = "publicsuffix" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" -dependencies = [ - "idna", - "psl-types", -] - [[package]] name = "quinn" version = "0.11.11" @@ -1854,12 +1442,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1873,20 +1455,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.2" @@ -1908,16 +1480,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -1927,15 +1489,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -1951,35 +1504,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "regex" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - [[package]] name = "reqwest" version = "0.13.4" @@ -1988,8 +1512,6 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", - "cookie", - "cookie_store", "futures-core", "futures-util", "http", @@ -2008,7 +1530,6 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -2200,12 +1721,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "sec1" version = "0.7.3" @@ -2276,7 +1791,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -2328,17 +1843,6 @@ dependencies = [ "unsafe-libyaml", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - [[package]] name = "sha2" version = "0.10.9" @@ -2376,12 +1880,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - [[package]] name = "simd_cesu8" version = "1.1.1" @@ -2410,17 +1908,6 @@ dependencies = [ "time", ] -[[package]] -name = "simplelog" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" -dependencies = [ - "log", - "termcolor", - "time", -] - [[package]] name = "slab" version = "0.4.12" @@ -2448,9 +1935,6 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] [[package]] name = "spki" @@ -2474,41 +1958,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.118" @@ -2537,7 +1992,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -2553,15 +2008,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "thiserror" version = "2.0.18" @@ -2579,7 +2025,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -2589,9 +2035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "libc", "num-conv", - "num_threads", "powerfmt", "serde_core", "time-core", @@ -2663,7 +2107,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -2687,18 +2131,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -2734,17 +2166,12 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "async-compression", "bitflags", "bytes", - "futures-core", "futures-util", "http", "http-body", - "http-body-util", "pin-project-lite", - "tokio", - "tokio-util", "tower", "tower-layer", "tower-service", @@ -2789,23 +2216,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.4", - "sha1", - "thiserror", - "utf-8", -] - [[package]] name = "typenum" version = "1.20.1" @@ -2842,12 +2252,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2903,15 +2307,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -2954,7 +2349,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn", "wasm-bindgen-shared", ] @@ -3018,65 +2413,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -3159,12 +2501,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" version = "0.6.3" @@ -3190,7 +2526,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", "synstructure", ] @@ -3211,7 +2547,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -3231,7 +2567,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", "synstructure", ] @@ -3252,7 +2588,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -3285,7 +2621,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 600ba18..c9d1c79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,6 @@ anyhow = "1" async-trait = "0.1.89" axum = "0.8.9" clap = { version = "4.5.60", features = ["derive"] } -goose = { git = "https://github.com/tag1consulting/goose", rev = "614f75149cab5a963a8bc26d7b62639dedfa7a2b" } jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "stream"] } serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 62b8c26..3e1a508 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,13 @@ 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 +lifecycle, public-route probes, Locust load tests, and official MCP conformance orchestration. Generated checkout, build, and runtime state stays under `.integration/` or `CF_INTEGRATION_DIR`. @@ -48,7 +53,7 @@ The workspace has one application package and four internal libraries: gateway endpoints, and probes - `cf-integration-compliance`: the official conformance fixture, result parser, and three-lane comparison report -- `cf-integration-load`: Locust and Goose load engines +- `cf-integration-load`: Locust load orchestration The official TypeScript fixture is the conformance reference target. An explicit `stack up` starts it for direct MCP access; conformance runs still own @@ -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,40 +176,40 @@ 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 +### Locust -Both load engines exercise the same MCP lifecycle and remain first-class: +The load workflow exercises the MCP lifecycle through the framework-required +Python Locust adapter: ```bash -cf-integration load --lane dataplane --protocol-version 2025-11-25 \ - --engine locust --smoke -cf-integration load --lane dataplane --protocol-version 2025-11-25 \ - --engine goose --smoke +cf-integration load --lane dataplane \ + --smoke -cf-integration load --lane dataplane --engine locust \ - --users 20 --spawn-rate 5 --run-time 2m -cf-integration load --lane dataplane --engine goose \ +cf-integration load --lane dataplane \ --users 20 --spawn-rate 5 --run-time 2m ``` Default full-run settings are 100 users, 10 users/second, and five minutes. CLI settings override `.env`; explicitly exported `LOCUST_USERS`, -`LOCUST_SPAWN_RATE`, and `LOCUST_RUN_TIME` remain authoritative for both -engines. Smoke defaults are one user, one user/second, and ten seconds. +`LOCUST_SPAWN_RATE`, and `LOCUST_RUN_TIME` remain authoritative. 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. +On the modern dataplane lane Locust uses `server/discover`, attaches the +mandatory client `_meta` plus `Mcp-Method`/`Mcp-Name` headers to every request, +and avoids sessions and the removed `ping` method. The legacy control-plane +lane retains initialize, `notifications/initialized`, session cleanup, and +ping. The adapter calls only a finite allowlist of safe fixture tools and audits +generated artifacts for credential leakage. ### Upstream live tests @@ -220,6 +228,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 +246,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 +349,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 +358,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 +379,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 +393,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 +409,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 @@ -413,7 +460,7 @@ src/ CLI and workflow composition crates/platform/ platform orchestration library crates/mcp/ MCP transport and probe library crates/compliance/ official conformance library -crates/load/ Locust and Goose library +crates/load/ Locust orchestration library docker/docker-compose.cf-dataplane.yaml dataplane service and nginx override docker/docker-compose.cf-integration.yaml Fast Time and Locust overlay docker/docker-compose.cf-conformance.yaml official fixture overlay 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/Cargo.toml b/crates/load/Cargo.toml index de242c9..5942108 100644 --- a/crates/load/Cargo.toml +++ b/crates/load/Cargo.toml @@ -10,15 +10,8 @@ publish.workspace = true cf-integration-platform.workspace = true cf-integration-mcp.workspace = true anyhow.workspace = true -goose.workspace = true -reqwest.workspace = true -serde_json.workspace = true -thiserror.workspace = true -tokio.workspace = true -url.workspace = true [dev-dependencies] -axum.workspace = true tempfile.workspace = true [lints] diff --git a/crates/load/src/engine.rs b/crates/load/src/engine.rs deleted file mode 100644 index 54effbc..0000000 --- a/crates/load/src/engine.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Load-test engine selection. - -/// Load-test implementation. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LoadEngine { - /// Python Locust adapter. - Locust, - /// Native Rust Goose runner. - Goose, -} diff --git a/crates/load/src/goose.rs b/crates/load/src/goose.rs deleted file mode 100644 index 6d0aac7..0000000 --- a/crates/load/src/goose.rs +++ /dev/null @@ -1,1165 +0,0 @@ -//! Native Goose load runner for MCP streamable HTTP. - -use std::collections::HashSet; -use std::fmt; -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result, bail}; -use goose::config::GooseConfiguration; -use goose::metrics::{GooseMetrics, GooseRequestMetric}; -use goose::prelude::{ - GooseAttack, GooseMethod, GooseRequest, GooseUser, Scenario, Transaction, TransactionError, - TransactionFunction, TransactionResult, -}; -use reqwest::StatusCode; -use reqwest::header::{CONTENT_TYPE, HeaderMap}; -use reqwest::redirect::Policy; -use serde_json::{Value, json}; -use thiserror::Error; -use url::Url; - -use cf_integration_platform::StackMode; -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, -}; - -use super::LoadSettings; - -const MAX_RESPONSE_BODY_BYTES: usize = 8 * 1024 * 1024; -const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); - -/// Files generated by a Goose load run. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct GooseReportPaths { - html: PathBuf, - json: PathBuf, -} - -impl GooseReportPaths { - /// Returns the HTML report path. - #[must_use] - pub fn html(&self) -> &Path { - &self.html - } - - /// Returns the machine-readable JSON report path. - #[must_use] - pub fn json(&self) -> &Path { - &self.json - } -} - -#[derive(Clone)] -struct BearerToken(String); - -impl BearerToken { - fn expose(&self) -> &str { - &self.0 - } -} - -impl fmt::Debug for BearerToken { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("[REDACTED]") - } -} - -/// Fully resolved, credential-safe configuration for a Goose MCP load run. -#[derive(Clone)] -pub struct GooseLoadConfig { - host: String, - endpoint: String, - users: usize, - hatch_rate: f64, - run_time: String, - reports: GooseReportPaths, - bearer_token: BearerToken, - require_dataplane_backend: bool, - protocol_version: String, -} - -impl fmt::Debug for GooseLoadConfig { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("GooseLoadConfig") - .field("host", &self.host) - .field("endpoint", &self.endpoint) - .field("users", &self.users) - .field("hatch_rate", &self.hatch_rate) - .field("run_time", &self.run_time) - .field("reports", &self.reports) - .field("bearer_token", &self.bearer_token) - .field("require_dataplane_backend", &self.require_dataplane_backend) - .field("protocol_version", &self.protocol_version) - .finish() - } -} - -impl GooseLoadConfig { - /// Resolves a mode-specific Goose run and creates its report directory. - /// - /// # Errors - /// - /// Returns an error for an empty token, missing dataplane server ID, invalid - /// public base URL, unsupported duration, or report-directory failure. - pub fn new( - config: &AppConfig, - mode: StackMode, - settings: &LoadSettings, - bearer_token: &str, - server_id: Option<&str>, - ) -> Result { - Self::new_with_protocol_version( - config, - mode, - settings, - bearer_token, - server_id, - PROTOCOL_VERSION, - ) - } - - /// Resolves a Goose run with an explicit MCP protocol version. - /// - /// # Errors - /// - /// Returns the same errors as [`Self::new`] and rejects an empty protocol - /// version. - pub fn new_with_protocol_version( - config: &AppConfig, - mode: StackMode, - settings: &LoadSettings, - bearer_token: &str, - server_id: Option<&str>, - protocol_version: &str, - ) -> Result { - if bearer_token.trim().is_empty() { - bail!("Goose bearer token must not be empty"); - } - if protocol_version.trim().is_empty() { - bail!("Goose MCP protocol version must not be empty"); - } - - let host = normalized_host( - config - .base_url() - .value - .to_str() - .context("MCP_CLI_BASE_URL must be valid UTF-8")?, - )?; - let (mode_name, endpoint) = match mode { - StackMode::Controlplane => ("controlplane", String::from("/mcp")), - StackMode::Dataplane => { - let server_id = server_id - .filter(|value| !value.trim().is_empty()) - .context("dataplane Goose server ID must not be empty")?; - ("dataplane", dataplane_endpoint(server_id)?) - } - }; - let report_dir = config - .integration_dir() - .join("reports/load") - .join(mode_name) - .join("goose"); - fs::create_dir_all(&report_dir) - .with_context(|| format!("failed to create Goose report directory {report_dir:?}"))?; - - Ok(Self { - host, - endpoint, - users: settings.users().get(), - hatch_rate: settings.spawn_rate(), - run_time: goose_run_time(settings.run_time())?, - reports: GooseReportPaths { - html: report_dir.join("goose-report.html"), - json: report_dir.join("goose-report.json"), - }, - bearer_token: BearerToken(bearer_token.to_owned()), - require_dataplane_backend: mode == StackMode::Dataplane, - protocol_version: protocol_version.to_owned(), - }) - } - - /// Returns the normalized public stack origin. - #[must_use] - pub fn host(&self) -> &str { - &self.host - } - - /// Returns the public MCP request path. - #[must_use] - pub fn endpoint(&self) -> &str { - &self.endpoint - } - - /// Returns the configured concurrent-user count. - #[must_use] - pub fn users(&self) -> usize { - self.users - } - - /// Returns the users started per second. - #[must_use] - pub fn hatch_rate(&self) -> f64 { - self.hatch_rate - } - - /// Returns the Goose-compatible whole-second run time. - #[must_use] - pub fn run_time(&self) -> &str { - &self.run_time - } - - /// Returns the MCP protocol version emitted by each virtual user. - #[must_use] - pub fn protocol_version(&self) -> &str { - &self.protocol_version - } - - /// Returns both report paths. - #[must_use] - pub fn reports(&self) -> &GooseReportPaths { - &self.reports - } - - /// Builds a Goose configuration without consulting process arguments. - #[must_use] - pub fn goose_configuration(&self) -> GooseConfiguration { - let mut configuration = GooseConfiguration::default(); - configuration.host.clone_from(&self.host); - configuration.users = Some(self.users); - configuration.increase_rate = Some(self.hatch_rate.to_string()); - configuration.run_time.clone_from(&self.run_time); - configuration.no_reset_metrics = true; - configuration.no_telnet = true; - configuration.no_websocket = true; - configuration.quiet = 1; - configuration.report_file = vec![ - self.reports.html.to_string_lossy().into_owned(), - self.reports.json.to_string_lossy().into_owned(), - ]; - configuration - } - - /// Executes the native Goose MCP scenario. - /// - /// # Errors - /// - /// Returns an error when Goose cannot execute, reports expose a credential, - /// or any request or transaction metric records a failure. - pub async fn execute(self) -> std::result::Result { - let reports = self.reports.clone(); - let configuration = self.goose_configuration(); - let bearer_token = self.bearer_token.clone(); - let shared = Arc::new(SharedRunConfig { - endpoint: self.endpoint, - bearer_token: bearer_token.clone(), - require_dataplane_backend: self.require_dataplane_backend, - protocol_version: self.protocol_version, - }); - let scenario = build_scenario(shared).map_err(|source| GooseRunError::Execution { - source, - reports: reports.clone(), - })?; - let metrics = GooseAttack::initialize_with_config(configuration) - .map_err(|source| GooseRunError::Execution { - source, - reports: reports.clone(), - })? - .register_scenario(scenario) - .execute() - .await - .map_err(|source| GooseRunError::Execution { - source, - reports: reports.clone(), - })?; - - inspect_reports(&reports, bearer_token.expose(), &metrics).await?; - - let failed_requests = failed_request_count(&metrics); - let failed_transactions = failed_transaction_count(&metrics); - if failed_requests > 0 || failed_transactions > 0 { - return Err(GooseRunError::FailedMetrics { - failed_requests, - failed_transactions, - metrics: Box::new(metrics), - reports, - }); - } - - Ok(GooseRunOutcome { metrics, reports }) - } -} - -/// Successful Goose result with metrics and diagnostic report locations. -#[derive(Debug)] -pub struct GooseRunOutcome { - metrics: GooseMetrics, - reports: GooseReportPaths, -} - -impl GooseRunOutcome { - /// Returns the complete Goose metrics. - #[must_use] - pub fn metrics(&self) -> &GooseMetrics { - &self.metrics - } - - /// Returns generated report locations. - #[must_use] - pub fn reports(&self) -> &GooseReportPaths { - &self.reports - } - - /// Returns failed request count. - #[must_use] - pub fn failed_requests(&self) -> usize { - failed_request_count(&self.metrics) - } - - /// Returns failed transaction count. - #[must_use] - pub fn failed_transactions(&self) -> usize { - failed_transaction_count(&self.metrics) - } -} - -/// Fail-closed Goose execution errors retaining available diagnostics. -#[derive(Debug, Error)] -pub enum GooseRunError { - /// Goose could not construct or execute the load attack. - #[error("Goose execution failed; reports: {reports:?}")] - Execution { - /// Underlying Goose error. - #[source] - source: goose::GooseError, - /// Intended or generated report locations. - reports: GooseReportPaths, - }, - /// Goose completed but recorded failed operations. - #[error( - "Goose recorded {failed_requests} failed requests and {failed_transactions} failed transactions; reports: {reports:?}" - )] - FailedMetrics { - /// Number of failed requests. - failed_requests: usize, - /// Number of failed transactions. - failed_transactions: usize, - /// Complete metrics retained for diagnostics. - metrics: Box, - /// Generated report paths. - reports: GooseReportPaths, - }, - /// A generated report could not be inspected for credential safety. - #[error("failed to inspect Goose report {path:?}; reports: {reports:?}")] - ReportInspection { - /// Report that could not be read. - path: PathBuf, - /// Underlying read failure. - #[source] - source: io::Error, - /// Complete metrics retained for diagnostics. - metrics: Box, - /// Generated report paths. - reports: GooseReportPaths, - }, - /// A credential-bearing report could not be removed. - #[error("failed to remove tainted Goose report {path:?}; reports: {reports:?}")] - ReportCleanup { - /// Credential-bearing report that could not be deleted. - path: PathBuf, - /// Underlying removal failure. - #[source] - source: io::Error, - /// Complete metrics retained for diagnostics. - metrics: Box, - /// Generated report paths. - reports: GooseReportPaths, - }, - /// A generated report contained the bearer credential and was removed. - #[error("removed Goose report {path:?} because it contained a bearer credential")] - CredentialLeak { - /// Removed report path. - path: PathBuf, - /// Complete metrics retained for diagnostics. - metrics: Box, - /// Generated report paths. - reports: GooseReportPaths, - }, -} - -#[derive(Clone)] -struct SharedRunConfig { - endpoint: String, - bearer_token: BearerToken, - require_dataplane_backend: bool, - protocol_version: String, -} - -#[derive(Clone)] -struct UserSession { - endpoint: String, - bearer_token: BearerToken, - session_id: Option, - callable_tools: Vec, - next_tool: usize, - next_request_id: u64, - require_dataplane_backend: bool, - protocol_version: String, -} - -impl fmt::Debug for UserSession { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("UserSession") - .field("endpoint", &self.endpoint) - .field("bearer_token", &self.bearer_token) - .field("session_id", &self.session_id.as_ref().map(|_| "[PRESENT]")) - .field("callable_tools", &self.callable_tools) - .field("next_tool", &self.next_tool) - .field("next_request_id", &self.next_request_id) - .field("require_dataplane_backend", &self.require_dataplane_backend) - .field("protocol_version", &self.protocol_version) - .finish() - } -} - -struct McpResponse { - request: GooseRequestMetric, - headers: HeaderMap, - body: String, -} - -#[derive(Clone, Copy)] -enum ExpectedStatus { - Ok, - Accepted, - SessionDeleted, -} - -fn build_scenario( - shared: Arc, -) -> std::result::Result { - let initialize_config = Arc::clone(&shared); - let initialize: TransactionFunction = Arc::new(move |user| { - let config = Arc::clone(&initialize_config); - Box::pin(async move { - match initialize_user(user, &config).await { - Ok(()) => Ok(()), - Err(error) - if matches!( - error.as_ref(), - TransactionError::RequestFailed { .. } | TransactionError::Reqwest(_) - ) => - { - // Goose has already recorded the failed request. Keep the user - // dormant so the timed run can finish and the outer metrics gate - // can return a diagnostic-rich failure. - if let Some(state) = user.get_session_data_mut::() { - state.session_id = None; - state.callable_tools.clear(); - } - Ok(()) - } - Err(error) => Err(error), - } - }) - }); - - let mut scenario = Scenario::new("MCP streamable HTTP").register_transaction( - Transaction::new(initialize) - .set_name("initialize MCP session") - .set_on_start(), - ); - scenario = scenario.register_transaction( - transaction(list_tools) - .set_name("list MCP tools") - .set_weight(3)?, - ); - scenario = scenario.register_transaction( - transaction(call_tool) - .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(), - ); - Ok(scenario) -} - -fn transaction( - function: for<'a> fn( - &'a mut GooseUser, - ) -> std::pin::Pin< - Box + Send + 'a>, - >, -) -> Transaction { - let function: TransactionFunction = Arc::new(move |user| function(user)); - Transaction::new(function) -} - -async fn initialize_user(user: &mut GooseUser, config: &SharedRunConfig) -> TransactionResult { - user.set_client_builder( - reqwest::Client::builder() - .user_agent("cf-integration-goose/0.1") - .redirect(Policy::none()) - .no_proxy() - .timeout(REQUEST_TIMEOUT), - ) - .await?; - user.set_session_data(UserSession { - endpoint: config.endpoint.clone(), - bearer_token: config.bearer_token.clone(), - session_id: None, - callable_tools: Vec::new(), - next_tool: 0, - next_request_id: 2, - require_dataplane_backend: config.require_dataplane_backend, - protocol_version: config.protocol_version.clone(), - }); - - 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 mut response = send_mcp_request( - user, - &state, - GooseMethod::Post, - Some(&payload), - false, - "initialize", - ExpectedStatus::Ok, - ) - .await?; - let result = match decode_result(&response, &request_id) { - Ok(result) => result, - Err(tag) => return fail_request(user, &mut response.request, tag), - }; - if result.get("protocolVersion").and_then(Value::as_str) - != Some(config.protocol_version.as_str()) - { - return fail_request( - user, - &mut response.request, - "initialize returned an unexpected protocol version", - ); - } - if result - .get("capabilities") - .and_then(Value::as_object) - .is_none() - { - return fail_request( - user, - &mut response.request, - "initialize response is missing a capabilities object", - ); - } - let valid_server_info = result - .get("serverInfo") - .and_then(Value::as_object) - .is_some_and(|server_info| { - ["name", "version"].into_iter().all(|field| { - server_info - .get(field) - .and_then(Value::as_str) - .is_some_and(|value| !value.is_empty()) - }) - }); - if !valid_server_info { - return fail_request( - user, - &mut response.request, - "initialize response is missing valid serverInfo name and version", - ); - } - let session_id = match response - .headers - .get("mcp-session-id") - .and_then(|value| value.to_str().ok()) - .filter(|value| !value.trim().is_empty()) - { - Some(value) => value.to_owned(), - None => { - return fail_request( - user, - &mut response.request, - "initialize response is missing a valid MCP session ID", - ); - } - }; - session_mut(user)?.session_id = Some(session_id); - - let state = session(user)?.clone(); - let notification = json!({ - "jsonrpc": "2.0", - "method": "notifications/initialized" - }); - let _response = send_mcp_request( - user, - &state, - GooseMethod::Post, - Some(¬ification), - true, - "initialized notification", - ExpectedStatus::Accepted, - ) - .await?; - - list_tools(user).await -} - -fn list_tools(user: &mut GooseUser) -> transaction_future::TransactionFuture<'_> { - Box::pin(async move { - if session(user)?.session_id.is_none() { - return dormant_user().await; - } - let (state, request_id) = next_request(user)?; - let payload = jsonrpc_with_id("tools/list", None, request_id.clone()); - let mut response = send_mcp_request( - user, - &state, - GooseMethod::Post, - Some(&payload), - true, - "tools/list", - ExpectedStatus::Ok, - ) - .await?; - let result = match decode_result(&response, &request_id) { - Ok(result) => result, - Err(tag) => return fail_request(user, &mut response.request, tag), - }; - let tools = match result.get("tools").and_then(Value::as_array) { - Some(tools) => tools, - None => { - return fail_request( - user, - &mut response.request, - "tools/list result is missing its tools array", - ); - } - }; - let mut seen = HashSet::new(); - let mut callable_tools = Vec::new(); - for tool in tools { - let Some(name) = tool - .as_object() - .and_then(|object| object.get("name")) - .and_then(Value::as_str) - .filter(|name| !name.is_empty()) - else { - return fail_request( - user, - &mut response.request, - "tools/list contains a malformed tool entry", - ); - }; - if tool_call_args(name).is_some() && seen.insert(name.to_owned()) { - callable_tools.push(name.to_owned()); - } - } - if callable_tools.is_empty() { - return fail_request( - user, - &mut response.request, - "tools/list returned no explicitly safe callable tool", - ); - } - let state = session_mut(user)?; - state.callable_tools = callable_tools; - state.next_tool %= state.callable_tools.len(); - Ok(()) - }) -} - -fn call_tool(user: &mut GooseUser) -> transaction_future::TransactionFuture<'_> { - Box::pin(async move { - if session(user)?.session_id.is_none() { - return dormant_user().await; - } - let (state, request_id, name) = next_tool_call(user)?; - let Some(arguments) = tool_call_args(&name) else { - return Err(custom_error( - "selected MCP tool is not on the safe allowlist", - )); - }; - let payload = jsonrpc_with_id( - "tools/call", - Some(json!({"name": name, "arguments": arguments})), - request_id.clone(), - ); - let mut response = send_mcp_request( - user, - &state, - GooseMethod::Post, - Some(&payload), - true, - "tools/call", - ExpectedStatus::Ok, - ) - .await?; - let result = match decode_result(&response, &request_id) { - Ok(result) => result, - Err(tag) => return fail_request(user, &mut response.request, tag), - }; - if result.get("content").and_then(Value::as_array).is_none() - || result - .get("isError") - .is_some_and(|value| value != &Value::Bool(false)) - { - return fail_request( - user, - &mut response.request, - "tools/call returned a malformed or failed result", - ); - } - Ok(()) - }) -} - -fn ping_server(user: &mut GooseUser) -> transaction_future::TransactionFuture<'_> { - Box::pin(async move { - if session(user)?.session_id.is_none() { - return dormant_user().await; - } - let (state, request_id) = next_request(user)?; - let payload = jsonrpc_with_id("ping", None, request_id.clone()); - let mut response = send_mcp_request( - user, - &state, - GooseMethod::Post, - Some(&payload), - true, - "ping", - ExpectedStatus::Ok, - ) - .await?; - if let Err(tag) = decode_result(&response, &request_id) { - return fail_request(user, &mut response.request, tag); - } - Ok(()) - }) -} - -fn delete_session(user: &mut GooseUser) -> transaction_future::TransactionFuture<'_> { - Box::pin(async move { - let state = session(user)?.clone(); - if state.session_id.is_none() { - return dormant_user().await; - } - let _response = send_mcp_request( - user, - &state, - GooseMethod::Delete, - None, - true, - "delete session", - ExpectedStatus::SessionDeleted, - ) - .await?; - let state = session_mut(user)?; - state.session_id = None; - state.callable_tools.clear(); - Ok(()) - }) -} - -mod transaction_future { - use std::future::Future; - use std::pin::Pin; - - use goose::prelude::TransactionResult; - - pub type TransactionFuture<'a> = Pin + Send + 'a>>; -} - -async fn dormant_user() -> TransactionResult { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - Ok(()) -} - -fn session(user: &GooseUser) -> std::result::Result<&UserSession, Box> { - user.get_session_data::() - .ok_or_else(|| custom_error("Goose user is missing MCP session state")) -} - -fn session_mut( - user: &mut GooseUser, -) -> std::result::Result<&mut UserSession, Box> { - user.get_session_data_mut::() - .ok_or_else(|| custom_error("Goose user is missing MCP session state")) -} - -fn next_request( - user: &mut GooseUser, -) -> std::result::Result<(UserSession, Value), Box> { - let state = session_mut(user)?; - let request_id = state.next_request_id; - state.next_request_id = state - .next_request_id - .checked_add(1) - .ok_or_else(|| custom_error("MCP request ID overflow"))?; - Ok((state.clone(), json!(request_id))) -} - -fn next_tool_call( - user: &mut GooseUser, -) -> std::result::Result<(UserSession, Value, String), Box> { - let state = session_mut(user)?; - if state.callable_tools.is_empty() { - return Err(custom_error("Goose user has no safe callable MCP tools")); - } - let tool_index = state.next_tool % state.callable_tools.len(); - state.next_tool = (tool_index + 1) % state.callable_tools.len(); - let name = state.callable_tools[tool_index].clone(); - let request_id = state.next_request_id; - state.next_request_id = state - .next_request_id - .checked_add(1) - .ok_or_else(|| custom_error("MCP request ID overflow"))?; - Ok((state.clone(), json!(request_id), name)) -} - -async fn send_mcp_request( - user: &mut GooseUser, - state: &UserSession, - method: GooseMethod, - payload: Option<&Value>, - include_session: bool, - request_name: &'static str, - expected_status: ExpectedStatus, -) -> std::result::Result> { - let mut request_builder = user - .get_request_builder(&method, &state.endpoint)? - .bearer_auth(state.bearer_token.expose()) - .header("accept", ACCEPT); - if let Some(payload) = payload { - request_builder = request_builder - .header("content-type", "application/json") - .body(payload.to_string()); - } - if include_session { - let session_id = state - .session_id - .as_deref() - .filter(|value| !value.is_empty()) - .ok_or_else(|| custom_error("MCP request requires an initialized session"))?; - request_builder = request_builder - .header("mcp-session-id", session_id) - .header("mcp-protocol-version", state.protocol_version.as_str()); - } - - let request = GooseRequest::builder() - .method(method) - .path(state.endpoint.as_str()) - .name(request_name) - .set_request_builder(request_builder) - .build(); - let response = user.request(request).await?; - let mut request_metric = response.request; - let response = match response.response { - Ok(response) => response, - Err(source) => return Err(Box::new(TransactionError::Reqwest(source))), - }; - let status = response.status(); - let headers = response.headers().clone(); - if state.require_dataplane_backend - && let Some(tag) = BackendIdentity::from_headers(&headers).dataplane_error() - { - return fail_request(user, &mut request_metric, tag); - } - let body = match bounded_response_body(response).await { - Ok(body) => body, - Err(_) => { - return fail_request( - user, - &mut request_metric, - "MCP response body was invalid or exceeded the safe size limit", - ); - } - }; - let accepted = match expected_status { - ExpectedStatus::Ok => status == StatusCode::OK, - ExpectedStatus::Accepted => status == StatusCode::ACCEPTED, - ExpectedStatus::SessionDeleted => { - status.is_success() - || matches!( - status, - StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED - ) - } - }; - if !accepted { - return fail_request( - user, - &mut request_metric, - "MCP response returned an unexpected HTTP status", - ); - } - if matches!(expected_status, ExpectedStatus::Accepted) && !body.is_empty() { - return fail_request( - user, - &mut request_metric, - "MCP notification response must not contain a body", - ); - } - if matches!(expected_status, ExpectedStatus::SessionDeleted) && !status.is_success() { - user.set_success(&mut request_metric)?; - } - - Ok(McpResponse { - request: request_metric, - headers, - body, - }) -} - -fn decode_result( - response: &McpResponse, - expected_id: &Value, -) -> std::result::Result { - let content_type = response - .headers - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .ok_or("MCP response is missing a valid Content-Type")?; - let message = parse_mcp_body(&response.body, content_type) - .map_err(|_| "MCP response body is not valid JSON or SSE")? - .ok_or("MCP response does not contain a JSON-RPC message")?; - if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { - return Err("MCP response has an invalid JSON-RPC version"); - } - if message.get("id") != Some(expected_id) { - return Err("MCP response JSON-RPC ID does not match the request"); - } - if message.get("error").is_some() { - return Err("MCP response contains a JSON-RPC error"); - } - message - .get("result") - .cloned() - .ok_or("MCP response is missing its JSON-RPC result") -} - -fn fail_request( - user: &mut GooseUser, - request: &mut GooseRequestMetric, - tag: &'static str, -) -> std::result::Result> { - match user.set_failure(tag, request, None, None) { - Err(error) => Err(error), - Ok(()) => Err(custom_error(tag)), - } -} - -fn custom_error(message: &str) -> Box { - Box::new(TransactionError::Custom(message.to_owned())) -} - -async fn inspect_reports( - reports: &GooseReportPaths, - bearer_token: &str, - metrics: &GooseMetrics, -) -> std::result::Result<(), GooseRunError> { - let mut first_inspection_error = None; - let mut tainted = Vec::new(); - for path in [&reports.html, &reports.json] { - match tokio::fs::read(path).await { - Ok(contents) if contains_bytes(&contents, bearer_token.as_bytes()) => { - tainted.push(path.clone()); - } - Ok(_) => {} - Err(source) if first_inspection_error.is_none() => { - first_inspection_error = Some((path.clone(), source)); - } - Err(_) => {} - } - } - let first_tainted = tainted.first().cloned(); - let mut cleanup_error = None; - for path in tainted { - if let Err(source) = tokio::fs::remove_file(&path).await - && cleanup_error.is_none() - { - cleanup_error = Some((path, source)); - } - } - if let Some((path, source)) = cleanup_error { - return Err(GooseRunError::ReportCleanup { - path, - source, - metrics: Box::new(metrics.clone()), - reports: reports.clone(), - }); - } - if let Some(path) = first_tainted { - return Err(GooseRunError::CredentialLeak { - path, - metrics: Box::new(metrics.clone()), - reports: reports.clone(), - }); - } - if let Some((path, source)) = first_inspection_error { - return Err(GooseRunError::ReportInspection { - path, - source, - metrics: Box::new(metrics.clone()), - reports: reports.clone(), - }); - } - Ok(()) -} - -async fn bounded_response_body(mut response: reqwest::Response) -> Result { - let mut body = Vec::new(); - while let Some(chunk) = response - .chunk() - .await - .context("failed to read MCP response body")? - { - if chunk.len() > MAX_RESPONSE_BODY_BYTES.saturating_sub(body.len()) { - bail!("MCP response body exceeded {MAX_RESPONSE_BODY_BYTES} bytes"); - } - body.extend_from_slice(&chunk); - } - String::from_utf8(body).context("MCP response body is not valid UTF-8") -} - -fn contains_bytes(contents: &[u8], needle: &[u8]) -> bool { - !needle.is_empty() - && contents - .windows(needle.len()) - .any(|window| window == needle) -} - -fn normalized_host(value: &str) -> Result { - let mut url = Url::parse(value).context("invalid MCP_CLI_BASE_URL for Goose")?; - if !matches!(url.scheme(), "http" | "https") || url.host().is_none() { - bail!("MCP_CLI_BASE_URL must be an HTTP(S) URL with a host"); - } - if !url.username().is_empty() || url.password().is_some() { - bail!("MCP_CLI_BASE_URL must not contain credentials"); - } - if url.query().is_some() || url.fragment().is_some() { - bail!("MCP_CLI_BASE_URL must not contain a query or fragment"); - } - url.set_path("/"); - Ok(url.into()) -} - -fn dataplane_endpoint(server_id: &str) -> Result { - let mut url = Url::parse("http://integration.invalid/") - .context("failed to initialize dataplane endpoint URL")?; - url.path_segments_mut() - .map_err(|()| anyhow::anyhow!("failed to build dataplane endpoint"))? - .extend(["servers", server_id, "mcp"]); - Ok(url.path().to_owned()) -} - -fn goose_run_time(value: &str) -> Result { - let bytes = value.as_bytes(); - let mut position = 0; - let mut total_millis = 0_u128; - while position < bytes.len() { - let number_start = position; - while position < bytes.len() && bytes[position].is_ascii_digit() { - position += 1; - } - let amount = value[number_start..position] - .parse::() - .context("invalid Goose run time")?; - let multiplier = if bytes[position..].starts_with(b"ms") { - position += 2; - 1 - } else { - let unit = bytes - .get(position) - .copied() - .context("invalid Goose run-time unit")?; - position += 1; - match unit { - b's' => 1_000, - b'm' => 60_000, - b'h' => 3_600_000, - b'd' => 86_400_000, - _ => bail!("invalid Goose run-time unit"), - } - }; - total_millis = total_millis - .checked_add( - amount - .checked_mul(multiplier) - .context("Goose run time is too large")?, - ) - .context("Goose run time is too large")?; - } - let seconds = total_millis.div_ceil(1_000); - if seconds == 0 { - bail!("Goose run time must be greater than zero"); - } - let seconds = usize::try_from(seconds).context("Goose run time is too large")?; - Ok(format!("{seconds}s")) -} - -fn failed_request_count(metrics: &GooseMetrics) -> usize { - metrics - .requests - .values() - .map(|request| request.fail_count) - .sum() -} - -fn failed_transaction_count(metrics: &GooseMetrics) -> usize { - metrics - .transactions - .iter() - .flatten() - .map(|transaction| transaction.fail_count) - .sum() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn report_audit_removes_every_tainted_output_before_failing() { - let directory = tempfile::tempdir().expect("temporary report directory"); - let reports = GooseReportPaths { - html: directory.path().join("goose.html"), - json: directory.path().join("goose.json"), - }; - let token = "credential-present-in-both-reports"; - tokio::fs::write(&reports.html, format!("html {token}")) - .await - .expect("HTML report should be written"); - tokio::fs::write(&reports.json, format!("json {token}")) - .await - .expect("JSON report should be written"); - - let error = inspect_reports(&reports, token, &GooseMetrics::default()) - .await - .expect_err("credential-bearing reports must fail closed"); - - assert!(matches!(error, GooseRunError::CredentialLeak { .. })); - assert!(!reports.html.exists()); - assert!(!reports.json.exists()); - } -} diff --git a/crates/load/src/lib.rs b/crates/load/src/lib.rs index 0bb513f..5ff2526 100644 --- a/crates/load/src/lib.rs +++ b/crates/load/src/lib.rs @@ -1,11 +1,7 @@ -//! Goose and Locust load-testing primitives. +//! Locust load-testing primitives. -mod engine; -mod goose; mod locust; mod settings; -pub use engine::LoadEngine; -pub use goose::{GooseLoadConfig, GooseReportPaths, GooseRunError, GooseRunOutcome}; pub use locust::{LocustCommand, audit_reports as audit_locust_reports}; pub use settings::{LoadRequest, LoadSettings}; diff --git a/crates/load/src/settings.rs b/crates/load/src/settings.rs index 2ab382b..87a79f9 100644 --- a/crates/load/src/settings.rs +++ b/crates/load/src/settings.rs @@ -6,8 +6,6 @@ use std::num::NonZeroUsize; use anyhow::{Context, Result, bail}; use cf_integration_platform::config::{AppConfig, SourcedValue, ValueOrigin}; -use crate::LoadEngine; - const SMOKE_USERS: &str = "1"; const SMOKE_SPAWN_RATE: &str = "1"; const SMOKE_RUN_TIME: &str = "10s"; @@ -16,8 +14,6 @@ const RUN_TIME_ERROR: &str = "LOCUST_RUN_TIME must be a positive Locust duration /// User-selected settings before configuration precedence is applied. #[derive(Debug, Clone, PartialEq)] pub struct LoadRequest { - /// Selected load generator. - pub engine: LoadEngine, /// Whether dotenv/default values should use smoke-test replacements. pub smoke: bool, /// Explicit concurrent-user override. @@ -46,7 +42,7 @@ impl LoadSettings { /// # Errors /// /// Returns an error for a zero or malformed user count, a non-finite or - /// non-positive spawn rate, or an invalid engine-specific run-time expression. + /// non-positive spawn rate, or an invalid Locust run-time expression. pub fn resolve(config: &AppConfig, request: &LoadRequest) -> Result { let users = match request.users { Some(users) => users, @@ -78,10 +74,7 @@ impl LoadSettings { }, |run_time| Ok(run_time.to_owned()), )?; - match request.engine { - LoadEngine::Locust => validate_locust_run_time(&run_time)?, - LoadEngine::Goose => validate_grouped_run_time(&run_time)?, - } + validate_locust_run_time(&run_time)?; Ok(Self { users, @@ -179,33 +172,3 @@ fn validate_locust_run_time(value: &str) -> Result<()> { } Ok(()) } - -fn validate_grouped_run_time(value: &str) -> Result<()> { - let bytes = value.as_bytes(); - let mut position = 0; - if bytes.is_empty() { - bail!("LOCUST_RUN_TIME must be one or more positive integer+unit groups"); - } - while position < bytes.len() { - let number_start = position; - while position < bytes.len() && bytes[position].is_ascii_digit() { - position += 1; - } - if number_start == position - || value[number_start..position] - .parse::() - .ok() - .is_none_or(|amount| amount == 0) - { - bail!("LOCUST_RUN_TIME must be one or more positive integer+unit groups"); - } - if bytes[position..].starts_with(b"ms") { - position += 2; - } else if matches!(bytes.get(position), Some(b's' | b'm' | b'h' | b'd')) { - position += 1; - } else { - bail!("LOCUST_RUN_TIME must be one or more positive integer+unit groups"); - } - } - Ok(()) -} diff --git a/crates/load/tests/load_goose.rs b/crates/load/tests/load_goose.rs deleted file mode 100644 index 04cf412..0000000 --- a/crates/load/tests/load_goose.rs +++ /dev/null @@ -1,933 +0,0 @@ -use std::ffi::OsString; -use std::fs; -use std::path::Path; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; - -use axum::Router; -use axum::body::Body; -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_platform::StackMode; -use cf_integration_platform::config::{AppConfig, Environment}; -use serde_json::{Value, json}; -use tempfile::TempDir; - -const TOKEN: &str = "secret.goose.jwt"; -const SESSION_ID: &str = "mock-session-id"; - -fn environment(values: &[(&str, &str)]) -> Environment { - values - .iter() - .map(|(key, value)| (OsString::from(key), OsString::from(value))) - .collect() -} - -fn repository_root() -> TempDir { - let root = tempfile::tempdir().expect("temporary repository root should be created"); - fs::write(root.path().join("Cargo.toml"), "[package]\n") - .expect("temporary Cargo manifest should be written"); - fs::create_dir_all(root.path().join("docker")) - .expect("temporary docker directory should be created"); - fs::write( - root.path() - .join("docker/docker-compose.cf-integration.yaml"), - "services: {}\n", - ) - .expect("temporary Compose file should be written"); - root -} - -fn app_config(root: &Path, process: &Environment) -> AppConfig { - AppConfig::load(process, &root.join("target/debug/cf-integration"), root) - .expect("application config should load") - .config -} - -fn load_settings(config: &AppConfig, run_time: &str) -> LoadSettings { - LoadSettings::resolve( - config, - &LoadRequest { - engine: LoadEngine::Goose, - smoke: false, - users: Some(1), - spawn_rate: Some(2.5), - run_time: Some(run_time.to_owned()), - }, - ) - .expect("load settings should resolve") -} - -#[test] -fn dataplane_configuration_maps_settings_and_encodes_the_public_server_path() { - let root = repository_root(); - let config = app_config( - root.path(), - &environment(&[("MCP_CLI_BASE_URL", "http://127.0.0.1:9321")]), - ); - let settings = load_settings(&config, "1m250ms"); - - let goose = GooseLoadConfig::new( - &config, - StackMode::Dataplane, - &settings, - TOKEN, - Some("server/with space"), - ) - .expect("Goose configuration should build"); - - assert_eq!(goose.host(), "http://127.0.0.1:9321/"); - assert_eq!(goose.endpoint(), "/servers/server%2Fwith%20space/mcp"); - assert_eq!(goose.users(), 1); - assert_eq!(goose.hatch_rate(), 2.5); - assert_eq!(goose.run_time(), "61s"); - assert_eq!( - goose.reports().html(), - root.path() - .join(".integration/reports/load/dataplane/goose/goose-report.html") - ); - assert_eq!( - goose.reports().json(), - root.path() - .join(".integration/reports/load/dataplane/goose/goose-report.json") - ); - let debug = format!("{goose:?}"); - assert!(!debug.contains(TOKEN)); - assert!(debug.contains("[REDACTED]")); -} - -#[test] -fn controlplane_configuration_targets_raw_mcp_and_needs_no_server_id() { - let root = repository_root(); - let config = app_config(root.path(), &Environment::new()); - let settings = load_settings(&config, "1s"); - - let goose = GooseLoadConfig::new(&config, StackMode::Controlplane, &settings, TOKEN, None) - .expect("control-plane Goose configuration should build"); - - assert_eq!(goose.endpoint(), "/mcp"); - assert!( - goose.reports().html().is_file() - || goose.reports().html().parent().is_some_and(Path::is_dir) - ); -} - -#[test] -fn explicit_protocol_version_is_retained_by_goose_configuration() { - let root = repository_root(); - let config = app_config(root.path(), &Environment::new()); - let settings = load_settings(&config, "1s"); - - let goose = GooseLoadConfig::new_with_protocol_version( - &config, - StackMode::Controlplane, - &settings, - TOKEN, - None, - "2025-06-18", - ) - .expect("control-plane Goose configuration should build"); - - assert_eq!(goose.protocol_version(), "2025-06-18"); -} - -#[test] -fn configuration_rejects_missing_credentials_and_dataplane_server_id() { - let root = repository_root(); - let config = app_config(root.path(), &Environment::new()); - let settings = load_settings(&config, "1s"); - - let token_error = GooseLoadConfig::new(&config, StackMode::Controlplane, &settings, " ", None) - .expect_err("empty bearer token should fail"); - assert!(token_error.to_string().contains("bearer token")); - - let server_error = GooseLoadConfig::new(&config, StackMode::Dataplane, &settings, TOKEN, None) - .expect_err("missing dataplane server ID should fail"); - assert!(server_error.to_string().contains("server ID")); -} - -#[test] -fn configuration_rejects_a_duration_goose_cannot_represent() { - let root = repository_root(); - let config = app_config(root.path(), &Environment::new()); - let settings = load_settings(&config, "18446744073709551615d"); - - let error = GooseLoadConfig::new(&config, StackMode::Controlplane, &settings, TOKEN, None) - .expect_err("duration larger than Goose usize seconds should fail"); - - assert!(error.to_string().contains("too large")); -} - -#[derive(Clone, Debug)] -struct MockState { - observations: Arc>>, - omit_session: bool, - reject_notification: bool, - nonempty_notification: bool, - backend_marker: Option<&'static str>, - delete_status: StatusCode, - initialize_result: Option, - protocol_version: &'static str, -} - -#[derive(Clone, Debug)] -struct Observation { - http_method: Method, - rpc_method: Option, - path: String, - accept: Option, - authenticated: bool, - session: Option, - protocol_version: Option, - initialize_protocol_version: Option, - called_tool: Option, -} - -async fn mcp_handler(State(state): State, request: Request) -> Response { - let (parts, body) = request.into_parts(); - let bytes = axum::body::to_bytes(body, usize::MAX) - .await - .expect("mock request body should be readable"); - let payload = if bytes.is_empty() { - None - } else { - serde_json::from_slice::(&bytes).ok() - }; - let rpc_method = payload - .as_ref() - .and_then(|value| value.get("method")) - .and_then(Value::as_str) - .map(str::to_owned); - let called_tool = payload - .as_ref() - .and_then(|value| value.pointer("/params/name")) - .and_then(Value::as_str) - .map(str::to_owned); - let observation = Observation { - http_method: parts.method.clone(), - rpc_method: rpc_method.clone(), - path: parts.uri.path().to_owned(), - accept: header(&parts.headers, "accept"), - authenticated: header(&parts.headers, "authorization").as_deref() - == Some("Bearer secret.goose.jwt"), - session: header(&parts.headers, "mcp-session-id"), - protocol_version: header(&parts.headers, "mcp-protocol-version"), - initialize_protocol_version: payload - .as_ref() - .and_then(|value| value.pointer("/params/protocolVersion")) - .and_then(Value::as_str) - .map(str::to_owned), - called_tool, - }; - state - .observations - .lock() - .expect("mock observation lock should not be poisoned") - .push(observation); - - if header(&parts.headers, "authorization").as_deref() != Some("Bearer secret.goose.jwt") - || header(&parts.headers, "accept").as_deref() != Some(ACCEPT) - { - return response(StatusCode::BAD_REQUEST, "text/plain", "bad common headers"); - } - - if parts.method == Method::DELETE { - return if has_session_headers(&parts.headers, state.protocol_version) { - response(state.delete_status, "text/plain", "") - } else { - response(StatusCode::BAD_REQUEST, "text/plain", "bad delete headers") - }; - } - - let Some(payload) = payload else { - return response(StatusCode::BAD_REQUEST, "text/plain", "missing JSON body"); - }; - let id = payload.get("id").cloned().unwrap_or(Value::Null); - match rpc_method.as_deref() { - Some("initialize") => { - if header(&parts.headers, "mcp-session-id").is_some() - || header(&parts.headers, "mcp-protocol-version").is_some() - { - return response( - StatusCode::BAD_REQUEST, - "text/plain", - "early session headers", - ); - } - let result = state.initialize_result.clone().unwrap_or_else(|| { - json!({ - "protocolVersion": state.protocol_version, - "capabilities": {}, - "serverInfo": {"name": "mock", "version": "1"} - }) - }); - let mut builder = Response::builder() - .status(StatusCode::OK) - .header("content-type", "text/event-stream; charset=utf-8"); - if !state.omit_session { - builder = builder.header("mcp-session-id", SESSION_ID); - } - builder - .body(Body::from(format!( - "data: {}\n\n", - json!({ - "jsonrpc": "2.0", - "id": id, - "result": result - }) - ))) - .expect("mock initialize response should build") - } - Some("notifications/initialized") => { - if state.reject_notification { - return response(StatusCode::INTERNAL_SERVER_ERROR, "application/json", ""); - } - if has_session_headers(&parts.headers, state.protocol_version) - && payload.get("id").is_none() - { - response( - StatusCode::ACCEPTED, - "application/json", - if state.nonempty_notification { - "{}" - } else { - "" - }, - ) - } else { - response(StatusCode::BAD_REQUEST, "text/plain", "bad notification") - } - } - Some("tools/list") => { - if !has_session_headers(&parts.headers, state.protocol_version) { - return response(StatusCode::BAD_REQUEST, "text/plain", "bad list headers"); - } - json_response(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { - "tools": [ - {"name": "delete_everything_echo", "inputSchema": {"type": "object"}}, - {"name": "echo", "inputSchema": {"type": "object"}} - ] - } - })) - } - Some("tools/call") => { - if !has_session_headers(&parts.headers, state.protocol_version) - || payload.pointer("/params/name").and_then(Value::as_str) != Some("echo") - || payload - .pointer("/params/arguments/message") - .and_then(Value::as_str) - != Some("cf-integration") - { - return response(StatusCode::BAD_REQUEST, "text/plain", "unsafe call"); - } - response( - StatusCode::OK, - "text/event-stream", - &format!( - "data: {}\n\n", - json!({ - "jsonrpc": "2.0", - "id": id, - "result": {"content": [{"type": "text", "text": "ok"}], "isError": false} - }) - ), - ) - } - Some("ping") => { - if !has_session_headers(&parts.headers, state.protocol_version) { - return response(StatusCode::BAD_REQUEST, "text/plain", "bad ping headers"); - } - json_response(json!({"jsonrpc": "2.0", "id": id, "result": {}})) - } - _ => response(StatusCode::BAD_REQUEST, "text/plain", "unknown method"), - } -} - -async fn marked_mcp_handler( - State(state): State, - request: Request, -) -> Response { - let marker = state.backend_marker; - let mut response = mcp_handler(State(state), request).await; - if let Some(marker) = marker { - response.headers_mut().insert( - "x-cf-integration-backend", - marker.parse().expect("test backend marker should be valid"), - ); - } - response -} - -fn header(headers: &HeaderMap, name: &str) -> Option { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned) -} - -fn has_session_headers(headers: &HeaderMap, protocol_version: &str) -> bool { - header(headers, "mcp-session-id").as_deref() == Some(SESSION_ID) - && header(headers, "mcp-protocol-version").as_deref() == Some(protocol_version) -} - -fn response(status: StatusCode, content_type: &str, body: &str) -> Response { - Response::builder() - .status(status) - .header("content-type", content_type) - .body(Body::from(body.to_owned())) - .expect("mock response should build") -} - -fn json_response(value: Value) -> Response { - response( - StatusCode::OK, - "application/json; charset=utf-8", - &value.to_string(), - ) -} - -async fn spawn_mock( - omit_session: bool, - reject_notification: bool, - nonempty_notification: bool, -) -> (String, MockState, tokio::task::JoinHandle<()>) { - spawn_mock_with_marker( - omit_session, - reject_notification, - nonempty_notification, - Some("dataplane"), - ) - .await -} - -async fn spawn_mock_with_marker( - omit_session: bool, - reject_notification: bool, - nonempty_notification: bool, - backend_marker: Option<&'static str>, -) -> (String, MockState, tokio::task::JoinHandle<()>) { - spawn_mock_with_options( - omit_session, - reject_notification, - nonempty_notification, - backend_marker, - StatusCode::NO_CONTENT, - None, - PROTOCOL_VERSION, - ) - .await -} - -async fn spawn_mock_with_options( - omit_session: bool, - reject_notification: bool, - nonempty_notification: bool, - backend_marker: Option<&'static str>, - delete_status: StatusCode, - initialize_result: Option, - protocol_version: &'static str, -) -> (String, MockState, tokio::task::JoinHandle<()>) { - let state = MockState { - observations: Arc::new(Mutex::new(Vec::new())), - omit_session, - reject_notification, - nonempty_notification, - backend_marker, - delete_status, - initialize_result, - protocol_version, - }; - let app = Router::new() - .route("/{*path}", any(marked_mcp_handler)) - .with_state(state.clone()); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("mock listener should bind"); - let address = listener - .local_addr() - .expect("mock listener should have a local address"); - let handle = tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("mock server should run"); - }); - (format!("http://{address}"), state, handle) -} - -async fn spawn_mock_with_initialize_result( - initialize_result: Value, -) -> (String, MockState, tokio::task::JoinHandle<()>) { - spawn_mock_with_options( - false, - false, - false, - Some("dataplane"), - StatusCode::NO_CONTENT, - Some(initialize_result), - PROTOCOL_VERSION, - ) - .await -} - -#[derive(Clone)] -struct RedirectState { - location: String, -} - -async fn redirect_handler(State(state): State) -> Response { - Response::builder() - .status(StatusCode::TEMPORARY_REDIRECT) - .header("location", state.location) - .body(Body::empty()) - .expect("redirect response should build") -} - -async fn redirect_target_handler(State(hits): State>) -> Response { - hits.fetch_add(1, Ordering::SeqCst); - response( - StatusCode::INTERNAL_SERVER_ERROR, - "text/plain", - "redirected", - ) -} - -#[tokio::test] -async fn goose_never_follows_redirects_with_a_bearer_credential() { - let hits = Arc::new(AtomicUsize::new(0)); - let target_listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("redirect target should bind"); - let target_address = target_listener.local_addr().expect("target address"); - let target_app = Router::new() - .fallback(any(redirect_target_handler)) - .with_state(Arc::clone(&hits)); - let target = tokio::spawn(async move { - axum::serve(target_listener, target_app) - .await - .expect("redirect target should run"); - }); - - let origin_listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("redirect origin should bind"); - let origin_address = origin_listener.local_addr().expect("origin address"); - let origin_app = Router::new() - .fallback(any(redirect_handler)) - .with_state(RedirectState { - location: format!("http://{target_address}/capture"), - }); - let origin = tokio::spawn(async move { - axum::serve(origin_listener, origin_app) - .await - .expect("redirect origin should run"); - }); - - let host = format!("http://{origin_address}"); - let root = repository_root(); - let config = app_config(root.path(), &environment(&[("MCP_CLI_BASE_URL", &host)])); - let settings = load_settings(&config, "1s"); - let error = GooseLoadConfig::new(&config, StackMode::Controlplane, &settings, TOKEN, None) - .expect("Goose configuration should build") - .execute() - .await - .expect_err("redirect response must fail closed"); - - origin.abort(); - target.abort(); - assert!(matches!(error, GooseRunError::FailedMetrics { .. })); - assert_eq!( - hits.load(Ordering::SeqCst), - 0, - "redirect target was contacted" - ); -} - -#[tokio::test] -async fn execute_runs_strict_session_flow_with_dynamic_safe_tools_and_reports() { - let (host, state, server) = spawn_mock(false, false, false).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( - &config, - StackMode::Dataplane, - &settings, - TOKEN, - Some("server/with space"), - ) - .expect("Goose configuration should build"); - - let outcome = goose.execute().await.expect("strict MCP flow should pass"); - server.abort(); - - assert_eq!(outcome.failed_requests(), 0); - assert_eq!(outcome.failed_transactions(), 0); - for report in [outcome.reports().html(), outcome.reports().json()] { - let contents = fs::read_to_string(report).expect("Goose report should be readable"); - assert!(!contents.contains(TOKEN), "report leaked bearer token"); - } - - let observations = state - .observations - .lock() - .expect("mock observation lock should not be poisoned") - .clone(); - assert!(observations.len() >= 6, "observations: {observations:#?}"); - assert_eq!(observations[0].rpc_method.as_deref(), Some("initialize")); - assert_eq!( - observations[1].rpc_method.as_deref(), - Some("notifications/initialized") - ); - assert_eq!( - observations.last().map(|value| &value.http_method), - Some(&Method::DELETE) - ); - assert!(observations.iter().all(|value| value.authenticated)); - assert!( - observations - .iter() - .all(|value| value.accept.as_deref() == Some(ACCEPT)) - ); - assert!( - observations - .iter() - .all(|value| value.path == "/servers/server%2Fwith%20space/mcp") - ); - assert!( - observations - .iter() - .any(|value| value.rpc_method.as_deref() == Some("tools/list")) - ); - assert!( - observations - .iter() - .any(|value| value.rpc_method.as_deref() == Some("tools/call")) - ); - assert!( - observations - .iter() - .any(|value| value.rpc_method.as_deref() == Some("ping")) - ); - assert!( - observations - .iter() - .filter_map(|value| value.called_tool.as_deref()) - .all(|name| name == "echo") - ); - assert!(observations[0].session.is_none()); - assert!(observations[0].protocol_version.is_none()); - assert_eq!( - observations[0].initialize_protocol_version.as_deref(), - Some(PROTOCOL_VERSION) - ); - assert!( - observations[1..] - .iter() - .all(|value| value.session.as_deref() == Some(SESSION_ID)) - ); - assert!( - observations[1..] - .iter() - .all(|value| value.protocol_version.as_deref() == Some(PROTOCOL_VERSION)) - ); -} - -#[tokio::test] -async fn execute_emits_the_selected_protocol_version_in_body_and_headers() { - const SELECTED_VERSION: &str = "2025-06-18"; - let (host, state, server) = spawn_mock_with_options( - false, - false, - false, - Some("dataplane"), - StatusCode::NO_CONTENT, - None, - SELECTED_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::Controlplane, - &settings, - TOKEN, - None, - SELECTED_VERSION, - ) - .expect("Goose configuration should build"); - - let outcome = goose - .execute() - .await - .expect("selected protocol flow should pass"); - server.abort(); - assert_eq!(outcome.failed_requests(), 0); - - let observations = state - .observations - .lock() - .expect("mock observation lock should not be poisoned"); - assert_eq!( - observations[0].initialize_protocol_version.as_deref(), - Some(SELECTED_VERSION) - ); - assert!( - observations[1..] - .iter() - .all(|value| value.protocol_version.as_deref() == Some(SELECTED_VERSION)) - ); -} - -#[tokio::test] -async fn dataplane_goose_rejects_absent_fallback_forged_and_duplicate_backend_markers() { - for marker in [ - None, - Some("controlplane-fallback"), - Some("private-forged-marker"), - Some("dataplane, dataplane"), - ] { - let (host, _state, server) = spawn_mock_with_marker(false, false, false, marker).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( - &config, - StackMode::Dataplane, - &settings, - TOKEN, - Some("server"), - ) - .expect("Goose configuration should build"); - - let error = goose - .execute() - .await - .expect_err("invalid dataplane identity must fail the load run"); - server.abort(); - - let diagnostic = format!("{error}\n{error:?}"); - assert!(matches!(error, GooseRunError::FailedMetrics { .. })); - assert!(!diagnostic.contains("private-forged-marker")); - } -} - -#[tokio::test] -async fn goose_accepts_absent_or_unsupported_session_delete_responses() { - for delete_status in [StatusCode::NOT_FOUND, StatusCode::METHOD_NOT_ALLOWED] { - let (host, _state, server) = spawn_mock_with_options( - false, - false, - false, - Some("dataplane"), - delete_status, - None, - 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( - &config, - StackMode::Dataplane, - &settings, - TOKEN, - Some("server"), - ) - .expect("Goose configuration should build"); - - let outcome = goose - .execute() - .await - .expect("404 and 405 are valid session-delete outcomes"); - server.abort(); - - assert_eq!(outcome.failed_requests(), 0); - assert_eq!(outcome.failed_transactions(), 0); - } -} - -#[tokio::test] -async fn goose_rejects_malformed_initialize_capabilities_and_server_info() { - let malformed_results = [ - json!({ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": [], - "serverInfo": {"name": "mock", "version": "1"} - }), - json!({ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": {}, - }), - json!({ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": {}, - "serverInfo": {"name": "", "version": "1"} - }), - json!({ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": {}, - "serverInfo": {"name": "mock", "version": 1} - }), - ]; - - for result in malformed_results { - let (host, _state, server) = spawn_mock_with_initialize_result(result).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( - &config, - StackMode::Dataplane, - &settings, - TOKEN, - Some("server"), - ) - .expect("Goose configuration should build"); - - let error = goose - .execute() - .await - .expect_err("malformed initialize metadata must fail the load run"); - server.abort(); - - assert!(matches!(error, GooseRunError::FailedMetrics { .. })); - } -} - -#[tokio::test] -async fn execute_fails_closed_when_initialize_has_no_real_session_id() { - let (host, _state, server) = spawn_mock(true, false, false).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(&config, StackMode::Controlplane, &settings, TOKEN, None) - .expect("Goose configuration should build"); - - let error = goose - .execute() - .await - .expect_err("missing MCP session ID should fail the load run"); - server.abort(); - - match error { - GooseRunError::FailedMetrics { - failed_requests, - failed_transactions, - metrics, - reports, - .. - } => { - assert!(failed_requests > 0); - assert_eq!(failed_transactions, 0); - assert!(!metrics.requests.is_empty()); - assert!(!metrics.transactions.is_empty()); - assert!(reports.html().is_file()); - assert!(reports.json().is_file()); - } - other => panic!("unexpected error: {other:#}"), - } -} - -#[tokio::test] -async fn failed_initialized_notification_disables_the_user_without_invalid_followups() { - let (host, state, server) = spawn_mock(false, true, false).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(&config, StackMode::Controlplane, &settings, TOKEN, None) - .expect("Goose configuration should build"); - - let error = goose - .execute() - .await - .expect_err("rejected initialized notification should fail the load run"); - server.abort(); - - assert!(matches!(error, GooseRunError::FailedMetrics { .. })); - let observations = state - .observations - .lock() - .expect("mock observation lock should not be poisoned"); - assert_eq!(observations.len(), 2, "observations: {observations:#?}"); - assert_eq!(observations[0].rpc_method.as_deref(), Some("initialize")); - assert_eq!( - observations[1].rpc_method.as_deref(), - Some("notifications/initialized") - ); -} - -#[tokio::test] -async fn initialized_notification_rejects_a_nonempty_202_response() { - let (host, _state, server) = spawn_mock(false, false, true).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(&config, StackMode::Controlplane, &settings, TOKEN, None) - .expect("Goose configuration should build"); - - let error = goose - .execute() - .await - .expect_err("a notification response body violates Streamable HTTP"); - server.abort(); - - assert!(matches!(error, GooseRunError::FailedMetrics { .. })); -} - -#[test] -fn goose_configuration_uses_programmatic_values_and_both_report_files() { - let root = repository_root(); - let config = app_config(root.path(), &Environment::new()); - let settings = load_settings(&config, "2m3s"); - let goose = GooseLoadConfig::new(&config, StackMode::Controlplane, &settings, TOKEN, None) - .expect("Goose configuration should build"); - - let mapped = goose.goose_configuration(); - assert_eq!(mapped.host, goose.host()); - assert_eq!(mapped.users, Some(1)); - assert_eq!(mapped.increase_rate.as_deref(), Some("2.5")); - assert_eq!(mapped.run_time, "123s"); - assert!(mapped.no_reset_metrics); - assert!(mapped.no_telnet); - assert!(mapped.no_websocket); - assert_eq!( - mapped.report_file, - vec![ - goose.reports().html().to_string_lossy().into_owned(), - goose.reports().json().to_string_lossy().into_owned(), - ] - ); -} - -#[test] -fn report_directory_is_mode_and_engine_specific() { - let root = repository_root(); - let config = app_config(root.path(), &Environment::new()); - let settings = load_settings(&config, "1s"); - let expected = [ - (StackMode::Controlplane, "controlplane"), - (StackMode::Dataplane, "dataplane"), - ]; - - for (mode, mode_name) in expected { - let server_id = (mode == StackMode::Dataplane).then_some("server"); - let goose = GooseLoadConfig::new(&config, mode, &settings, TOKEN, server_id) - .expect("Goose configuration should build"); - let expected_dir = root - .path() - .join(format!(".integration/reports/load/{mode_name}/goose")); - assert_eq!( - goose.reports().html().parent(), - Some(expected_dir.as_path()) - ); - } -} diff --git a/crates/load/tests/load_locust.rs b/crates/load/tests/load_locust.rs index 86280e4..728ef9b 100644 --- a/crates/load/tests/load_locust.rs +++ b/crates/load/tests/load_locust.rs @@ -3,7 +3,7 @@ use std::ffi::{OsStr, OsString}; use std::fs; use std::path::Path; -use cf_integration_load::{LoadEngine, LoadRequest, LoadSettings, LocustCommand}; +use cf_integration_load::{LoadRequest, LoadSettings, LocustCommand}; use cf_integration_platform::StackMode; use cf_integration_platform::config::{AppConfig, Environment}; use tempfile::TempDir; @@ -41,7 +41,6 @@ fn config(root: &Path, process: &Environment) -> AppConfig { fn args(smoke: bool) -> LoadRequest { LoadRequest { - engine: LoadEngine::Locust, smoke, users: None, spawn_rate: None, 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..a84dfe8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -110,21 +110,34 @@ 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 { + 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 +149,7 @@ pub fn resolve_action(cli: Cli, environment: &Environment) -> Result { protocol_version: resolve_protocol_version( args.target.protocol_version, environment, + ProtocolVersion::default(), )?, }) } @@ -152,15 +166,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 +204,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..7596387 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -10,7 +10,7 @@ use cf_integration_mcp::mcp::PROTOCOL_VERSION; use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum}; const RUN_TIME_ERROR: &str = - "must be one or more positive integer+unit groups using ms, s, m, h, or d"; + "must be a positive Locust duration using h, m, and s at most once in that order"; const PROTOCOL_VERSION_ERROR: &str = "must use the MCP YYYY-MM-DD version format"; fn parse_positive_usize(value: &str) -> Result { @@ -38,6 +38,7 @@ fn parse_positive_f64(value: &str) -> Result { fn parse_run_time(value: &str) -> Result { let bytes = value.as_bytes(); let mut position = 0; + let mut previous_unit = None; if bytes.is_empty() { return Err(String::from(RUN_TIME_ERROR)); @@ -59,13 +60,17 @@ fn parse_run_time(value: &str) -> Result { return Err(String::from(RUN_TIME_ERROR)); } - if bytes[position..].starts_with(b"ms") { - position += 2; - } else if matches!(bytes.get(position), Some(b's' | b'm' | b'h' | b'd')) { - position += 1; - } else { + let unit = match bytes.get(position) { + Some(b'h') => 0, + Some(b'm') => 1, + Some(b's') => 2, + _ => return Err(String::from(RUN_TIME_ERROR)), + }; + if previous_unit.is_some_and(|previous| unit <= previous) { return Err(String::from(RUN_TIME_ERROR)); } + previous_unit = Some(unit); + position += 1; } Ok(value.to_owned()) @@ -86,7 +91,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 +157,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,13 +225,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, - - /// Load-test engine. - #[arg(long, value_enum, default_value = "locust")] - pub engine: CliLoadEngine, + pub target: RoutedWorkflowTargetArgs, /// Use smoke-test settings. #[arg(long)] @@ -228,29 +241,11 @@ pub struct LoadArgs { #[arg(long, value_parser = parse_positive_f64)] pub spawn_rate: Option, - /// Duration using positive ms, s, m, h, or d groups, such as 1h30m. + /// Locust duration using positive h, m, and s groups, such as 1h30m. #[arg(long, value_parser = parse_run_time)] pub run_time: Option, } -/// Load-test implementation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum CliLoadEngine { - /// Python Locust adapter. - Locust, - /// Native Rust Goose runner. - Goose, -} - -impl From for cf_integration_load::LoadEngine { - fn from(engine: CliLoadEngine) -> Self { - match engine { - CliLoadEngine::Locust => Self::Locust, - CliLoadEngine::Goose => Self::Goose, - } - } -} - /// Upstream live-test options. #[derive(Debug, Clone, PartialEq, Eq, Args)] pub struct LiveArgs { @@ -284,7 +279,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 +426,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 +461,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..ac305bc 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -16,12 +16,11 @@ 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, -}; -use cf_integration_load::{ - GooseLoadConfig, LoadEngine, LoadSettings, LocustCommand, audit_locust_reports, + 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::{LoadSettings, LocustCommand, audit_locust_reports}; use cf_integration_mcp::GatewayTopology; use cf_integration_mcp::auth_proxy::AuthProxy; use cf_integration_mcp::gateway::GatewayClient; @@ -37,6 +36,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 +45,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 +70,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 +123,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 +139,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 +166,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, + }); + } + if mode == StackMode::Controlplane { + return Ok(ManagedBearerToken { + value: self.admin_session_token().await?, + catalog_id: None, + catalog_admin_token: None, + }); } - self.generated_bearer_token(mode, server_id) + + 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) + } + + 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), + }) } - 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 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 +430,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..5950439 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,42 +52,24 @@ 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 { - LoadEngine::Locust => { - let command = LocustCommand::new_with_protocol_version( - &self.config, - args.topology, - &settings, - &token, - (args.topology == StackMode::Dataplane).then_some(server_id.as_str()), - args.protocol_version.as_str(), - ) - .map_err(AppFailure::from)?; - let process_result = self - .runner - .run(&self.compose_environment( - command.command().clone(), - args.topology, - true, - )?) - .map_err(AppFailure::from); - finalize_locust_run(process_result, command.report_dir(), &token) - } - LoadEngine::Goose => { - self.run_goose( - args.topology, - &settings, - &token, - &server_id, - &args.protocol_version, - ) - .await - } - } + let command = LocustCommand::new_with_protocol_version( + &self.config, + args.topology, + &settings, + &token, + (args.topology == StackMode::Dataplane).then_some(operation_server_id.as_str()), + args.protocol_version.as_str(), + ) + .map_err(AppFailure::from)?; + let process_result = self + .runner + .run(&self.compose_environment(command.command().clone(), args.topology, true)?) + .map_err(AppFailure::from); + finalize_locust_run(process_result, command.report_dir(), &token) }) .await } @@ -116,6 +97,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, @@ -181,36 +180,6 @@ impl RuntimeExecutor { .map_err(|_| AppFailure::from(anyhow!("{key} must be a non-negative integer"))) }) } - - async fn run_goose( - &self, - topology: StackMode, - settings: &LoadSettings, - token: &str, - server_id: &str, - protocol_version: &ProtocolVersion, - ) -> AppResult<()> { - let run = GooseLoadConfig::new_with_protocol_version( - &self.config, - topology, - settings, - token, - (topology == StackMode::Dataplane).then_some(server_id), - protocol_version.as_str(), - ) - .map_err(AppFailure::from)?; - let outcome = run - .execute() - .await - .map_err(|error| AppFailure::from(anyhow!(error)))?; - println!( - "{} {} and {}", - OutputStyle::stdout().info("Goose reports:"), - outcome.reports().html().display(), - outcome.reports().json().display() - ); - Ok(()) - } } fn finalize_locust_run( diff --git a/tests/cli.rs b/tests/cli.rs index 560e4e6..62a3e40 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, + Cli, CliConformanceServerEra, CliLane, CliTopology, Command, ConformanceArgs, + 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 { @@ -125,43 +164,37 @@ fn stack_logs_preserve_service_arguments() { } #[test] -fn load_keeps_locust_and_goose_with_validated_settings() { - for (engine, expected) in [ - ("locust", CliLoadEngine::Locust), - ("goose", CliLoadEngine::Goose), - ] { - let Command::Load(LoadArgs { - target, - engine: actual, - users, - spawn_rate, - run_time, - .. - }) = parse(&[ - "cf-integration", - "load", - "--engine", - engine, - "--users", - "2", - "--spawn-rate", - "0.5", - "--run-time", - "1m30s", - ]) - .command - else { - panic!("expected load") - }; - assert_eq!(actual, expected); - assert_eq!(target.lane, None); - assert_eq!(target.protocol_version, None); - assert_eq!(users, Some(2)); - assert_eq!(spawn_rate, Some(0.5)); - assert_eq!(run_time.as_deref(), Some("1m30s")); - } +fn load_keeps_validated_locust_settings() { + let Command::Load(LoadArgs { + target, + users, + spawn_rate, + run_time, + .. + }) = parse(&[ + "cf-integration", + "load", + "--users", + "2", + "--spawn-rate", + "0.5", + "--run-time", + "1m30s", + ]) + .command + else { + panic!("expected load") + }; + assert_eq!(target.lane, None); + assert_eq!(target.protocol_version, None); + assert_eq!(users, Some(2)); + assert_eq!(spawn_rate, Some(0.5)); + assert_eq!(run_time.as_deref(), Some("1m30s")); + rejected(&["cf-integration", "load", "--users", "0"]); + rejected(&["cf-integration", "load", "--run-time", "1ms"]); rejected(&["cf-integration", "load", "--run-time", "zero"]); + rejected(&["cf-integration", "load", "--engine", "locust"]); } #[test] @@ -235,7 +268,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 +303,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 +315,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 +327,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 +341,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..67b504d 100644 --- a/tests/dispatch.rs +++ b/tests/dispatch.rs @@ -6,7 +6,7 @@ use cf_integration::app::{ }; use cf_integration::cli::{Cli, LiveGroup, ProtocolVersion, TokenKind, TopologySelection}; use cf_integration_compliance::conformance::{ConformanceServerEra, ConformanceTarget}; -use cf_integration_load::{LoadEngine, LoadRequest}; +use cf_integration_load::LoadRequest; use cf_integration_platform::StackMode; use cf_integration_platform::config::Environment; use clap::Parser; @@ -99,7 +99,7 @@ fn stack_actions_resolve_freshness_and_volume_cleanup() { } #[test] -fn load_preserves_both_engines_and_explicit_settings() { +fn load_preserves_explicit_locust_settings() { assert_eq!( action( &[ @@ -109,8 +109,6 @@ fn load_preserves_both_engines_and_explicit_settings() { "controlplane", "--protocol-version", "2025-06-18", - "--engine", - "locust", "--smoke", "--users", "2", @@ -127,7 +125,6 @@ fn load_preserves_both_engines_and_explicit_settings() { .parse::() .expect("valid protocol version"), request: LoadRequest { - engine: LoadEngine::Locust, smoke: true, users: Some(2), spawn_rate: Some(0.5), @@ -206,30 +203,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!(