diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0833966 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +docker/mcp-conformance.patch text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffffbbb..d0faa26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,54 +19,7 @@ env: jobs: quality: - name: quality - runs-on: ubuntu-24.04 - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.97.0 - with: - components: clippy,rustfmt - - - name: Restore Rust cache - uses: Swatinem/rust-cache@v2.9.1 - - - name: Check formatting - run: cargo fmt --all --check - - - name: Run Clippy - run: cargo clippy --all-targets --locked -- -D warnings - - - name: Run full test suite - run: cargo test --all-targets --locked - - - name: Verify standalone lazy runtime state - shell: bash - run: | - cargo build --locked --bin cf-integration - sandbox=$(mktemp -d) - binary="$GITHUB_WORKSPACE/target/debug/cf-integration" - if (cd "$sandbox" && CF_INTEGRATION_DIR="$sandbox/state" "$binary" conformance report); then - echo "report unexpectedly succeeded without prior results" >&2 - exit 1 - fi - test ! -e "$sandbox/state" - if (cd "$sandbox" && CF_INTEGRATION_DIR="$sandbox/state" "$binary" stack config --lane external); then - echo "stack config unexpectedly succeeded outside a checkout" >&2 - exit 1 - fi - test -f "$sandbox/state/secrets.env" - find "$sandbox/state/assets" \ - -path '*/docker/docker-compose.cf-integration.yaml' \ - -type f -print -quit | grep -q . - - - name: Verify published package - run: cargo package --locked - - - name: Validate GitHub Actions workflows - run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + uses: ./.github/workflows/quality.yml native: name: native (${{ matrix.target }}) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..f06cd27 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,64 @@ +name: Quality checks + +on: + workflow_call: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_TARGET_DIR: target + RUSTFLAGS: -D warnings + +jobs: + quality: + name: quality + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.97.0 + with: + components: clippy,rustfmt + + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2.9.1 + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run Clippy + run: cargo clippy --all-targets --locked -- -D warnings + + - name: Run full test suite + run: cargo test --all-targets --locked + + - name: Verify standalone lazy runtime state + shell: bash + run: | + cargo build --locked --bin cf-integration + sandbox=$(mktemp -d) + binary="$GITHUB_WORKSPACE/target/debug/cf-integration" + if (cd "$sandbox" && CF_INTEGRATION_DIR="$sandbox/state" "$binary" conformance report); then + echo "report unexpectedly succeeded without prior results" >&2 + exit 1 + fi + test ! -e "$sandbox/state" + if (cd "$sandbox" && CF_INTEGRATION_DIR="$sandbox/state" "$binary" stack config --lane external); then + echo "stack config unexpectedly succeeded outside a checkout" >&2 + exit 1 + fi + test -f "$sandbox/state/secrets.env" + find "$sandbox/state/assets" \ + -path '*/docker/docker-compose.cf-integration.yaml' \ + -type f -print -quit | grep -q . + + - name: Verify published package + run: cargo package --locked + + - name: Validate GitHub Actions workflows + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6afc3bc..11c7faa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,56 +14,9 @@ env: jobs: quality: - name: Release quality gate - runs-on: ubuntu-24.04 + uses: ./.github/workflows/quality.yml permissions: contents: read - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.97.0 - with: - components: clippy,rustfmt - - - name: Restore Rust cache - uses: Swatinem/rust-cache@v2.9.1 - - - name: Check formatting - run: cargo fmt --all --check - - - name: Run Clippy - run: cargo clippy --all-targets --locked -- -D warnings - - - name: Run full test suite - run: cargo test --all-targets --locked - - - name: Verify standalone lazy runtime state - shell: bash - run: | - cargo build --locked --bin cf-integration - sandbox=$(mktemp -d) - binary="$GITHUB_WORKSPACE/target/debug/cf-integration" - if (cd "$sandbox" && CF_INTEGRATION_DIR="$sandbox/state" "$binary" conformance report); then - echo "report unexpectedly succeeded without prior results" >&2 - exit 1 - fi - test ! -e "$sandbox/state" - if (cd "$sandbox" && CF_INTEGRATION_DIR="$sandbox/state" "$binary" stack config --lane external); then - echo "stack config unexpectedly succeeded outside a checkout" >&2 - exit 1 - fi - test -f "$sandbox/state/secrets.env" - find "$sandbox/state/assets" \ - -path '*/docker/docker-compose.cf-integration.yaml' \ - -type f -print -quit | grep -q . - - - name: Verify published package - run: cargo package --locked - - - name: Validate GitHub Actions workflows - run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 build-binaries: name: Build ${{ matrix.target }} diff --git a/AGENTS.md b/AGENTS.md index b1ebeeb..29c5c30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,3 +11,5 @@ Scope: - Preserve the public routing contract: `/servers/{virtual_host_id}/mcp` goes to `cf-dataplane`; raw `/mcp` and UI/API traffic go to `cf-controlplane`. - Use published `cf-dataplane` images by default. Local builds should be explicit overrides. - Keep `CHANGELOG.md` current for user-visible behavior, CLI, workflow, and packaging changes. Add normal changes under `Unreleased`; when bumping the package version, move those entries into a dated version section. + +- Write repository-owned code in Rust or Python only. Implement harness helpers in Rust; use Python where required by Python tools such as Locust. diff --git a/CHANGELOG.md b/CHANGELOG.md index 979ae07..312ef6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,32 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Changed +- Run official conformance and Inspector inside a Docker tooling image, removing + the host Node/npm requirement. Client drivers publish directly to Redis, and + runner containers are removed after completion, failure, or interruption. + +- Moved standalone auth and config publishing into the Rust CLI, replacing the Node + helper image and npm dependencies. Fixture customization uses a checked patch. + +- Consolidated server and client conformance artifact validation, baseline gates, + and reporting into one direction-aware path. +- Removed unused MCP transport features and stack command wrappers; tests now + exercise the same MCP POST client used by probes and conformance. +- Shared asynchronous child-process execution and CI/release quality checks. + +### Fixed + +- Preserve configured Docker connection settings when removing conformance and + Inspector containers, including after failure or interruption. + +## [0.3.2] - 2026-09-07 + +### Changed + +- Standalone workflows now run against production dataplane images without + `with_tools`. The harness signs ephemeral JWTs, serves loopback JWKS, and + publishes MessagePack routing snapshots directly to Redis. + - Simplified runtime dispatch and shared authenticated workflow setup, removing forwarding wrappers while preserving token revocation and stack cleanup. @@ -25,6 +51,8 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) setup before a baseline can be blessed. - Made the dataplane config writer available to normal external client conformance and preserved schemas for its scenario tools. +- Embedded the ClickStack collector configuration required by installed-binary + conformance runs. - Accepted empty pagination cursors and legacy SSE keepalives during discovery, and used the fixture's protocol era when configuring backends for clients from a different era. diff --git a/Cargo.lock b/Cargo.lock index 32b710b..14860ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,12 +58,35 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "aws-lc-rs" version = "1.17.1" @@ -71,6 +94,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -177,13 +201,20 @@ dependencies = [ [[package]] name = "cf-integration" -version = "0.3.1" +version = "0.3.2" dependencies = [ "anyhow", + "aws-lc-rs", "axum", + "base64", "clap", + "include_dir", "indicatif", + "jsonwebtoken", + "pem", + "redis", "reqwest", + "rmp-serde", "serde", "serde_json", "tempfile", @@ -215,7 +246,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -280,7 +311,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", + "futures-core", "memchr", + "pin-project-lite", + "tokio", + "tokio-util", ] [[package]] @@ -319,6 +354,12 @@ dependencies = [ "libc", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "displaydoc" version = "0.2.6" @@ -358,6 +399,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -468,7 +529,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -691,6 +752,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -800,6 +880,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + [[package]] name = "libc" version = "0.2.186" @@ -865,6 +963,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -883,6 +1015,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -916,6 +1064,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1005,7 +1159,16 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1020,7 +1183,30 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "redis" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f" +dependencies = [ + "arcstr", + "async-lock", + "bytes", + "cfg-if", + "combine", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "ryu", + "socket2", + "tokio", + "tokio-util", + "url", + "xxhash-rust", ] [[package]] @@ -1073,10 +1259,29 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1177,7 +1382,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -1321,6 +1526,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -1337,6 +1551,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -1441,6 +1667,36 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1611,6 +1867,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -1878,6 +2140,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + [[package]] name = "yaml_serde" version = "0.10.7" @@ -1940,6 +2208,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 35cd8cc..a03cffb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cf-integration" -version = "0.3.1" +version = "0.3.2" edition = "2024" rust-version = "1.97" license = "Apache-2.0" @@ -19,8 +19,6 @@ include = [ "/docker/**", "/scripts/locustfile_mcp.py", "/scripts/live_protocol/sitecustomize.py", - "/scripts/conformance/write_dataplane_config.mjs", - "/scripts/standalone/generate_auth_key.mjs", "/tests/conformance/baselines/**", "/README.md", "/CHANGELOG.md", @@ -33,10 +31,17 @@ path = "src/main.rs" [dependencies] anyhow = "1" +aws-lc-rs = "1" axum = "0.8.9" +base64 = "0.22" clap = { version = "4.5.60", features = ["derive"] } +include_dir = "0.7" indicatif = { version = "0.18.6", default-features = false } +jsonwebtoken = { version = "11", default-features = false, features = ["aws_lc_rs", "use_pem"] } +pem = "3" +redis = { version = "1", default-features = false, features = ["tokio-comp"] } reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "stream"] } +rmp-serde = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2.0.18" diff --git a/README.md b/README.md index b39ab0b..710cb87 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ cargo install cf-integration --locked ``` Use `cargo run --` before a command when running this checkout. Runtime use -requires Docker Compose v2, Git, and Node.js 22.7.5 or newer. Rust 1.97 is +requires Docker Compose v2 and Git. Node/npm are installed and run only inside +Docker images, including conformance and Inspector. Rust 1.97 is needed only to compile the CLI or a local dataplane image. Published images are the default. Set `CF_DATAPLANE_REF` to build and test a @@ -42,15 +43,28 @@ with `CF_MCP_LANE` and `MCP_PROTOCOL_VERSION`. Add the global `--standalone` flag to run the external lane without any control plane. Standalone mode starts Redis, the Rust dataplane, nginx, and the required -test fixture. It generates an ephemeral RSA key, obtains a test token from the -dataplane's local tool endpoint, validates it through the dataplane's loopback -JWKS endpoint, and publishes a fresh config through the dataplane serializer. -Redis therefore always contains the schema understood by the image under test. +test fixture. A harness-owned auth service generates an ephemeral RSA key and +serves public JWKS on the dataplane network namespace's loopback interface. The +config helper signs test tokens and writes named MessagePack routing snapshots +directly to Redis. Production dataplane images work without `with_tools`; that +feature is only for testing the dataplane's optional administrative helpers. +The helper image builds this Rust CLI from its embedded sources on first use, +with Docker caching subsequent builds. JWT/JWKS and Redis configuration run as +private CLI commands. The tooling image also contains pinned upstream conformance +and Inspector packages; Docker caches their installation without using the host npm +cache. Authentication proxies and the Rust client driver run in that container, +which joins the stack network without a Docker socket mount. Reports are written +to the integration directory. Python remains for Locust and upstream live-test +integration. Standalone commands also work from an installed binary without control-plane checkouts or generated control-plane secrets. Routes and tool schemas are discovered from every catalog page of the running fixture, including the selected protocol era's diagnostic tools and prompts. +Control-plane-backed external runs require `CONTEXTFORGE_DATA_PLANE_JWKS_URL` +to point to the HTTPS JWKS provider for the control plane's signing keys. +Standalone runs supply their own loopback JWKS provider. + Use `cf-integration --help` for the complete interface. ## Stack @@ -168,7 +182,7 @@ cf-integration conformance report \ ```bash cf-integration debug inspect --lane external \ - --protocol-version modern --method tools/list + --protocol-version legacy --method tools/list cf-integration debug inspect --lane builtin \ --protocol-version legacy --server-id @@ -180,7 +194,8 @@ cf-integration debug token --kind admin cf-integration debug token --kind scoped --standalone ``` -`inspect` uses the official MCP Inspector. Control-plane tokens are revoked when +`inspect` uses the official MCP Inspector in Docker. The pinned Inspector uses +initialization, so select `--protocol-version legacy`. Control-plane tokens are revoked when the workflow owns them; caller-supplied `MCPGATEWAY_BEARER_TOKEN` values are never revoked. diff --git a/docker/docker-compose.cf-conformance-fixture.yaml b/docker/docker-compose.cf-conformance-fixture.yaml index 9125804..022a9db 100644 --- a/docker/docker-compose.cf-conformance-fixture.yaml +++ b/docker/docker-compose.cf-conformance-fixture.yaml @@ -15,10 +15,8 @@ services: - "127.0.0.1:${CF_CONFORMANCE_PORT:-0}:3000" healthcheck: test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:3000/mcp').then(response => { if (response.status !== 400) process.exit(1); }).catch(() => process.exit(1)) + - CMD-SHELL + - test "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/mcp)" = 400 interval: 2s timeout: 2s retries: 30 diff --git a/docker/docker-compose.cf-dataplane-config.yaml b/docker/docker-compose.cf-dataplane-config.yaml index c557e41..085e3ab 100644 --- a/docker/docker-compose.cf-dataplane-config.yaml +++ b/docker/docker-compose.cf-dataplane-config.yaml @@ -1,14 +1,14 @@ services: config_writer: profiles: ["helpers"] - image: node:22-bookworm-slim + image: cf-integration-helpers:0.3.2-rust + pull_policy: build + build: + context: ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root} + dockerfile: docker/helpers.Dockerfile labels: name: cf-dataplane-config-writer restart: "no" networks: - mcpnet - volumes: - - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/conformance/write_dataplane_config.mjs:/opt/contextforge-integration/write_dataplane_config.mjs:ro - entrypoint: - - node - - /opt/contextforge-integration/write_dataplane_config.mjs + entrypoint: ["cf-integration", "__helper"] diff --git a/docker/docker-compose.cf-dataplane-standalone.yaml b/docker/docker-compose.cf-dataplane-standalone.yaml index c92b154..6260a2e 100644 --- a/docker/docker-compose.cf-dataplane-standalone.yaml +++ b/docker/docker-compose.cf-dataplane-standalone.yaml @@ -1,21 +1,33 @@ # Minimal external-dataplane stack for standalone workflows. The control plane -# is deliberately absent: the dataplane issues a test token from an ephemeral -# RSA key and serializes the mocked per-user routing snapshot into Redis. +# is deliberately absent: harness helpers own ephemeral RSA authentication +# and publish MessagePack routing snapshots directly into Redis. services: - auth_keygen: - image: node:22-bookworm-slim + auth: + image: cf-integration-helpers:0.3.2-rust + pull_policy: build + build: + context: ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root} + dockerfile: docker/helpers.Dockerfile labels: - name: cf-dataplane-auth-keygen + name: cf-dataplane-auth restart: "no" - network_mode: none + # HTTP JWKS is accepted only on loopback. Share the dataplane network + # namespace so the signing key stays in this harness-owned service. + network_mode: service:dataplane volumes: - standalone_auth:/keys - - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/standalone/generate_auth_key.mjs:/opt/contextforge-integration/generate_auth_key.mjs:ro - command: - - node - - /opt/contextforge-integration/generate_auth_key.mjs - - /keys/jwt.key + command: ["__helper", "auth"] + healthcheck: + test: ["CMD", "cf-integration", "__helper", "health"] + interval: 2s + timeout: 2s + retries: 30 + start_period: 2s + + config_writer: + volumes: + - standalone_auth:/keys:ro redis: image: redis:8.2.8-alpine3.22 @@ -50,31 +62,20 @@ services: mcpnet: aliases: - cf-dataplane - extra_hosts: - - host.docker.internal:host-gateway expose: - "4445" - command: - - --token-verification-private-key - - /keys/jwt.key - volumes: - - standalone_auth:/keys:ro environment: 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 - # The current dataplane validates test tokens against its own - # loopback-only JWKS tool endpoint, keeping auth entirely local. - CONTEXTFORGE_DATA_PLANE_JWKS_URL: http://127.0.0.1:4445/contextforge-rs/admin/.well-known/jwks.json + CONTEXTFORGE_DATA_PLANE_JWKS_URL: http://127.0.0.1:4446/.well-known/jwks.json CONTEXTFORGE_DATA_PLANE_UPSTREAM_CONNECTION_MODE: plain-text-or-tls 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}} CONTEXTFORGE_DATA_PLANE_USER_CONFIG_CACHE_EXPIRY_SECONDS: "0" RUST_LOG: ${CF_DATAPLANE_LOG:-info} depends_on: - auth_keygen: - condition: service_completed_successfully redis: condition: service_healthy @@ -92,8 +93,8 @@ services: networks: - mcpnet depends_on: - dataplane: - condition: service_started + auth: + condition: service_healthy healthcheck: test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1/health >/dev/null"] interval: 2s diff --git a/docker/docker-compose.cf-dataplane.yaml b/docker/docker-compose.cf-dataplane.yaml index ef92be8..574e6f6 100644 --- a/docker/docker-compose.cf-dataplane.yaml +++ b/docker/docker-compose.cf-dataplane.yaml @@ -38,8 +38,6 @@ services: mcpnet: aliases: - cf-dataplane - extra_hosts: - - host.docker.internal:host-gateway expose: - "4445" environment: @@ -47,12 +45,7 @@ services: 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. - # The standalone load helper calls only the internal user-config route; - # token creation stays disabled, so satisfy the unused path without a key. - CONTEXTFORGE_DATA_PLANE_TOKEN_VERIFICATION_PRIVATE_KEY: /dev/null + CONTEXTFORGE_DATA_PLANE_JWKS_URL: ${CONTEXTFORGE_DATA_PLANE_JWKS_URL:-} 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. diff --git a/docker/docker-compose.cf-tools.yaml b/docker/docker-compose.cf-tools.yaml new file mode 100644 index 0000000..8286d7c --- /dev/null +++ b/docker/docker-compose.cf-tools.yaml @@ -0,0 +1,20 @@ +services: + mcp_tools: + profiles: ["tools"] + image: cf-integration-tools:0.3.2 + build: + context: ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root} + dockerfile: docker/helpers.Dockerfile + target: tools + labels: + name: cf-mcp-tools + init: true + restart: "no" + user: "${HOST_UID:-1000}:${HOST_GID:-1000}" + environment: + MCP_CONFORMANCE_TOKEN: + CF_CLIENT_CONFORMANCE_BASE_URL: + CF_CLIENT_CONFORMANCE_SERVER_ID: + CF_CONFIG_REDIS_URL: ${CF_CONFIG_REDIS_URL:-redis://redis:6379} + # Keep the upstream runner's localhost checks local to the target service. + network_mode: service:${CF_MCP_TOOLS_NETWORK_SERVICE:-nginx} diff --git a/docker/helpers.Dockerfile b/docker/helpers.Dockerfile new file mode 100644 index 0000000..154f184 --- /dev/null +++ b/docker/helpers.Dockerfile @@ -0,0 +1,31 @@ +FROM rust:1.97-bookworm AS build +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +COPY docker ./docker +COPY scripts ./scripts +COPY tests/conformance/baselines ./tests/conformance/baselines +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/build/target \ + cargo build --locked --release --bin cf-integration \ + && cp target/release/cf-integration /usr/local/bin/cf-integration + +FROM node:22-bookworm-slim AS tools +ENV HOME=/tmp +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +RUN npm install --global --ignore-scripts \ + @modelcontextprotocol/conformance@0.2.0-alpha.11 \ + @modelcontextprotocol/inspector@2.2.0 \ + && npm cache clean --force +COPY --from=build /usr/local/bin/cf-integration /usr/local/bin/cf-integration +ENTRYPOINT ["cf-integration", "__tool"] + +FROM debian:bookworm-slim AS helpers +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /usr/local/bin/cf-integration /usr/local/bin/cf-integration +ENTRYPOINT ["cf-integration"] diff --git a/docker/mcp-conformance-server.Dockerfile b/docker/mcp-conformance-server.Dockerfile index 38e3655..31f9b8e 100644 --- a/docker/mcp-conformance-server.Dockerfile +++ b/docker/mcp-conformance-server.Dockerfile @@ -3,7 +3,7 @@ FROM node:22-bookworm-slim ARG MCP_CONFORMANCE_REVISION=c321dd32035556e6769d3724a8ee97d87c3faaac RUN apt-get update \ - && apt-get install --yes --no-install-recommends ca-certificates git \ + && apt-get install --yes --no-install-recommends ca-certificates curl git \ && rm -rf /var/lib/apt/lists/* WORKDIR /opt @@ -13,8 +13,8 @@ RUN git clone https://github.com/modelcontextprotocol/conformance.git mcp-confor WORKDIR /opt/mcp-conformance/examples/servers/typescript RUN npm ci -COPY docker/patch-mcp-conformance-hosts.mjs /usr/local/bin/patch-mcp-conformance-hosts.mjs -RUN node /usr/local/bin/patch-mcp-conformance-hosts.mjs everything-server.ts +COPY docker/mcp-conformance.patch /tmp/mcp-conformance.patch +RUN git apply --check /tmp/mcp-conformance.patch && git apply /tmp/mcp-conformance.patch WORKDIR /opt/mcp-conformance RUN git diff --exit-code -- . ':(exclude)examples/servers/typescript/everything-server.ts' \ diff --git a/docker/mcp-conformance.patch b/docker/mcp-conformance.patch new file mode 100644 index 0000000..7f7ad8b --- /dev/null +++ b/docker/mcp-conformance.patch @@ -0,0 +1,71 @@ +--- a/examples/servers/typescript/everything-server.ts ++++ b/examples/servers/typescript/everything-server.ts +@@ -1182,7 +1182,7 @@ + // ===== EXPRESS APP ===== + + // Use createMcpExpressApp for DNS rebinding protection on localhost +-const app = createMcpExpressApp(); ++const app = createMcpExpressApp({ allowedHosts: ['mcp_conformance_server', 'localhost', '127.0.0.1', '::1'] }); + + // Open subscriptions/listen streams (SEP-2575). Notifications are delivered + // to whichever streams are open *at the time of the change* and whose filter +@@ -1235,6 +1235,14 @@ + '2025-11-25' + ]; + ++// Harness-only switch for exercising explicit cross-era gateway paths. ++const CONFORMANCE_SERVER_ERA = process.env.MCP_CONFORMANCE_SERVER_ERA; ++if (!['dual', 'legacy', 'modern'].includes(CONFORMANCE_SERVER_ERA)) { ++ throw new Error( ++ `invalid MCP_CONFORMANCE_SERVER_ERA: ${CONFORMANCE_SERVER_ERA}` ++ ); ++} ++ + // Stateless (draft) operations whose results MUST carry the SEP-2549 caching + // hints (`ttlMs`, `cacheScope`). + const STATELESS_CACHEABLE_METHODS: ReadonlySet = new Set([ +@@ -1283,8 +1291,42 @@ + meta === undefined && + reqVersion !== undefined && + LEGACY_SESSION_PROTOCOL_VERSIONS.includes(reqVersion); +- +- if (!sessionId && (reqVersion || meta) && !isLegacySessionEraRequest) { ++ const isModernEraRequest = ++ !sessionId && (reqVersion !== undefined || meta !== undefined) && ++ !isLegacySessionEraRequest; ++ ++ // A legacy-only server must return a non-modern 4xx so a dual-era client ++ // recognizes the server as legacy and retries with initialize. ++ if (CONFORMANCE_SERVER_ERA === 'legacy' && isModernEraRequest) { ++ return res.status(400).json({ ++ jsonrpc: '2.0', ++ id, ++ error: { code: -32601, message: 'Method not found' } ++ }); ++ } ++ ++ // A modern-only server rejects initialization and names the only version it ++ // supports, giving legacy clients the most actionable failure available. ++ if ( ++ CONFORMANCE_SERVER_ERA === 'modern' && ++ isInitializeRequest(body) && ++ !isModernEraRequest ++ ) { ++ return res.status(400).json({ ++ jsonrpc: '2.0', ++ id, ++ error: { ++ code: -32022, ++ message: 'UnsupportedProtocolVersionError', ++ data: { ++ supported: ['2026-07-28'], ++ requested: String(reqVersion ?? params.protocolVersion ?? 'legacy') ++ } ++ } ++ }); ++ } ++ ++ if (isModernEraRequest) { + // Missing Transport Header Validation Check + if (!reqVersion) { + return res.status(400).json({ diff --git a/docker/patch-mcp-conformance-hosts.mjs b/docker/patch-mcp-conformance-hosts.mjs deleted file mode 100644 index fb7007e..0000000 --- a/docker/patch-mcp-conformance-hosts.mjs +++ /dev/null @@ -1,97 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises'; - -const target = process.argv[2]; -if (!target) { - throw new Error('usage: patch-mcp-conformance-hosts.mjs '); -} - -function replaceExactlyOnce(source, oldText, replacement, label) { - const replacementCount = source.split(oldText).length - 1; - if (replacementCount !== 1) { - throw new Error(`expected exactly one ${label} patch target, found ${replacementCount}`); - } - return source.replace(oldText, replacement); -} - -const hostOld = 'const app = createMcpExpressApp();'; -const hostReplacement = - "const app = createMcpExpressApp({ allowedHosts: ['mcp_conformance_server', 'localhost', '127.0.0.1', '::1'] });"; - -const versionListOld = `const LEGACY_SESSION_PROTOCOL_VERSIONS = [ - '2024-11-05', - '2025-03-26', - '2025-06-18', - '2025-11-25' -];`; -const versionListReplacement = `${versionListOld} - -// Harness-only switch for exercising explicit cross-era gateway paths. -const CONFORMANCE_SERVER_ERA = process.env.MCP_CONFORMANCE_SERVER_ERA; -if (!['dual', 'legacy', 'modern'].includes(CONFORMANCE_SERVER_ERA)) { - throw new Error( - \`invalid MCP_CONFORMANCE_SERVER_ERA: \${CONFORMANCE_SERVER_ERA}\` - ); -}`; - -const requestClassificationOld = ` const isLegacySessionEraRequest = - meta === undefined && - reqVersion !== undefined && - LEGACY_SESSION_PROTOCOL_VERSIONS.includes(reqVersion); - - if (!sessionId && (reqVersion || meta) && !isLegacySessionEraRequest) {`; -const requestClassificationReplacement = ` const isLegacySessionEraRequest = - meta === undefined && - reqVersion !== undefined && - LEGACY_SESSION_PROTOCOL_VERSIONS.includes(reqVersion); - const isModernEraRequest = - !sessionId && (reqVersion !== undefined || meta !== undefined) && - !isLegacySessionEraRequest; - - // A legacy-only server must return a non-modern 4xx so a dual-era client - // recognizes the server as legacy and retries with initialize. - if (CONFORMANCE_SERVER_ERA === 'legacy' && isModernEraRequest) { - return res.status(400).json({ - jsonrpc: '2.0', - id, - error: { code: -32601, message: 'Method not found' } - }); - } - - // A modern-only server rejects initialization and names the only version it - // supports, giving legacy clients the most actionable failure available. - if ( - CONFORMANCE_SERVER_ERA === 'modern' && - isInitializeRequest(body) && - !isModernEraRequest - ) { - return res.status(400).json({ - jsonrpc: '2.0', - id, - error: { - code: -32022, - message: 'UnsupportedProtocolVersionError', - data: { - supported: ['2026-07-28'], - requested: String(reqVersion ?? params.protocolVersion ?? 'legacy') - } - } - }); - } - - if (isModernEraRequest) {`; - -let source = await readFile(target, 'utf8'); -source = replaceExactlyOnce(source, hostOld, hostReplacement, 'host'); -source = replaceExactlyOnce( - source, - versionListOld, - versionListReplacement, - 'server-era configuration' -); -source = replaceExactlyOnce( - source, - requestClassificationOld, - requestClassificationReplacement, - 'server-era routing' -); -await writeFile(target, source); diff --git a/scripts/conformance/write_dataplane_config.mjs b/scripts/conformance/write_dataplane_config.mjs deleted file mode 100644 index c32fbb5..0000000 --- a/scripts/conformance/write_dataplane_config.mjs +++ /dev/null @@ -1,239 +0,0 @@ -#!/usr/bin/env node -/** Publish one conformance route through the dataplane's current serializer. */ -import { setTimeout } from 'node:timers/promises'; -import { realpathSync } from 'node:fs'; -import { pathToFileURL } from 'node:url'; - -const DATAPLANE_CONFIG_URL = - 'http://dataplane:4445/contextforge-rs/admin/userconfigs'; -const DATAPLANE_TOKEN_URL = - 'http://dataplane:4445/contextforge-rs/admin/tokens'; -/** Discover the pinned fixture instead of maintaining a second, incomplete catalog. */ -export async function fixtureCatalog(backendUrl, protocolVersion) { - let requestId = 0; - let sessionId; - const modern = protocolVersion >= '2026-07-28'; - const clientInfo = { name: 'cf-integration-config', version: '1.0' }; - async function rpc(method, params = {}, notification = false) { - if (modern) params = { ...params, _meta: { - 'io.modelcontextprotocol/protocolVersion': protocolVersion, - 'io.modelcontextprotocol/clientInfo': clientInfo, - 'io.modelcontextprotocol/clientCapabilities': {}, - } }; - const id = notification ? undefined : ++requestId; - const response = await fetch(backendUrl, { - method: 'POST', - headers: { - 'content-type': 'application/json', - accept: 'application/json, text/event-stream', - ...(method === 'initialize' ? {} : { 'mcp-protocol-version': protocolVersion }), - ...(modern ? { 'mcp-method': method } : {}), - ...(sessionId ? { 'mcp-session-id': sessionId } : {}), - }, - body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), - signal: AbortSignal.timeout(10000), - }); - if (!response.ok) throw new Error(`fixture ${method} failed: HTTP ${response.status}`); - sessionId = response.headers.get('mcp-session-id') ?? sessionId; - if (notification) { await response.body?.cancel(); return; } - const body = await response.text(); - const messages = response.headers.get('content-type')?.startsWith('text/event-stream') - ? body.split(/\r?\n\r?\n/).map((event) => - event.split(/\r?\n/).filter((line) => line.startsWith('data:')) - .map((line) => line.slice(5).trimStart()).join('\n')) - .filter((data) => data.trim()).map((data) => JSON.parse(data)) - : [JSON.parse(body)]; - const message = messages.find((message) => message.id === id); - if (!message || message.error || !message.result) { - throw new Error(`fixture ${method} did not return a successful result`); - } - return message.result; - } - async function list(method, key) { - const items = []; - const cursors = new Set(); - let cursor; - do { - const page = await rpc(method, cursor === undefined ? {} : { cursor }); - if (!Array.isArray(page[key])) throw new Error(`fixture ${method} has no ${key} array`); - items.push(...page[key]); - cursor = page.nextCursor ?? undefined; - if (cursor !== undefined && (typeof cursor !== 'string' || cursors.has(cursor))) { - throw new Error(`fixture ${method} returned an invalid or repeated cursor`); - } - cursors.add(cursor); - } while (cursor !== undefined); - return items; - } - try { - if (modern) await rpc('server/discover'); - else { - await rpc('initialize', { protocolVersion, capabilities: {}, clientInfo }); - await rpc('notifications/initialized', {}, true); - } - const tools = await list('tools/list', 'tools'); - if (!tools.length || tools.some((tool) => !tool.name || !tool.inputSchema - || typeof tool.inputSchema !== 'object' || Array.isArray(tool.inputSchema))) { - throw new Error('fixture tools must include names and input schemas'); - } - return { - tools: tools.map((tool) => tool.name), - toolSchemas: Object.fromEntries(tools.map((tool) => [tool.name, tool.inputSchema])), - resources: (await list('resources/list', 'resources')).map((resource) => resource.uri), - resourceTemplates: (await list('resources/templates/list', 'resourceTemplates')).map((resource) => resource.uriTemplate), - prompts: (await list('prompts/list', 'prompts')).map((prompt) => prompt.name), - }; - } finally { - if (sessionId) await fetch(backendUrl, { - method: 'DELETE', - headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': protocolVersion }, - signal: AbortSignal.timeout(10000), - }).then((response) => response.body?.cancel()); - } -} - -function fail(message) { - process.stderr.write(`${message}\n`); - process.exit(1); -} - -function tokenSubject(token) { - const parts = token.split('.'); - if (parts.length !== 3) fail('MCP_CONFORMANCE_TOKEN is not a JWT'); - let claims; - try { - claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); - } catch { - fail('MCP_CONFORMANCE_TOKEN has invalid claims'); - } - if (typeof claims.sub !== 'string' || claims.sub.length === 0) { - fail('MCP_CONFORMANCE_TOKEN has no string subject'); - } - return claims.sub; -} - -function stringArray(value, label) { - let parsed; - try { - parsed = JSON.parse(value); - } catch { - fail(`${label} must be valid JSON`); - } - if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== 'string' || !item)) { - fail(`${label} must be a JSON string array`); - } - return parsed; -} - -function routes(names, backendName) { - return Object.fromEntries( - names.map((name) => [name, { backend_name: backendName, upstream_name: name }]), - ); -} - -function config(serverId, backendUrl, protocolVersion, catalogs) { - const backendName = 'conformance-backend'; - return { - virtual_hosts: { - [serverId]: { - backends: { - [backendName]: { - name: backendName, - url: backendUrl, - mcp_protocol_version: protocolVersion, - passthrough_headers: [], - add_headers: {}, - remove_headers: [], - completion: {}, - tool_schemas: catalogs.toolSchemas, - }, - }, - tools: routes(catalogs.tools, backendName), - resources: routes(catalogs.resources, backendName), - resource_templates: routes(catalogs.resourceTemplates, backendName), - prompts: routes(catalogs.prompts, backendName), - }, - }, - }; -} - -async function publish(subject, body) { - const endpoint = `${DATAPLANE_CONFIG_URL}/${encodeURIComponent(subject)}`; - let lastError = 'dataplane did not respond'; - for (let attempt = 0; attempt < 60; attempt += 1) { - try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(2000), - }); - if (response.status === 202) return; - lastError = `HTTP ${response.status}: ${(await response.text()).slice(0, 512)}`; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - await setTimeout(500); - } - fail(`dataplane config serializer was unavailable: ${lastError}`); -} - -async function issueToken(tenantId, userId) { - const endpoint = `${DATAPLANE_TOKEN_URL}/${encodeURIComponent(tenantId)}/${encodeURIComponent(userId)}`; - let lastError = 'dataplane did not respond'; - for (let attempt = 0; attempt < 60; attempt += 1) { - try { - const response = await fetch(endpoint, { signal: AbortSignal.timeout(2000) }); - const body = await response.text(); - if (response.ok && body.split('.').length === 3) return body; - lastError = `HTTP ${response.status}: ${body.slice(0, 512)}`; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - await setTimeout(500); - } - fail(`dataplane token helper was unavailable: ${lastError}`); -} - -async function main() { - const [mode, ...args] = process.argv.slice(2); - if (mode === 'token') { - const [tenantId, userId] = args; - if (!tenantId) fail('tenant-id must not be empty'); - if (!userId) fail('user-id must not be empty'); - process.stdout.write(`${await issueToken(tenantId, userId)}\n`); - return; - } - if (!['fixture', 'client'].includes(mode)) { - fail('mode must be token, fixture, or client'); - } - const [serverId, backendUrl, protocolVersion, toolNamesJson] = args; - if (!serverId) fail('virtual-host-id must not be empty'); - try { - const parsed = new URL(backendUrl); - if (!['http:', 'https:'].includes(parsed.protocol)) fail('backend-url must use HTTP(S)'); - } catch { - fail('backend-url must be an absolute HTTP(S) URL'); - } - if (!protocolVersion) fail('protocol-version must not be empty'); - const token = process.env.MCP_CONFORMANCE_TOKEN; - if (!token) fail('MCP_CONFORMANCE_TOKEN is required'); - - const tools = mode === 'client' ? stringArray(toolNamesJson, 'tool-names-json') : undefined; - const catalogs = mode === 'fixture' - ? await fixtureCatalog(backendUrl, protocolVersion) - : { - tools, - toolSchemas: Object.fromEntries(tools.map((name) => [name, {}])), - resources: [], - resourceTemplates: [], - prompts: [], - }; - await publish( - tokenSubject(token), - config(serverId, backendUrl, protocolVersion, catalogs), - ); - process.stdout.write(`${JSON.stringify(catalogs.tools)}\n`); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) await main(); diff --git a/scripts/standalone/generate_auth_key.mjs b/scripts/standalone/generate_auth_key.mjs deleted file mode 100644 index 70e6c86..0000000 --- a/scripts/standalone/generate_auth_key.mjs +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env node -/** Generate the ephemeral RSA key used by the standalone dataplane stack. */ - -import { generateKeyPairSync } from 'node:crypto'; -import { chmodSync, existsSync, writeFileSync } from 'node:fs'; - -const [outputPath] = process.argv.slice(2); -if (!outputPath) { - process.stderr.write('output-path is required\n'); - process.exit(1); -} - -if (existsSync(outputPath)) { - chmodSync(outputPath, 0o600); - process.exit(0); -} - -const { privateKey } = generateKeyPairSync('rsa', { - modulusLength: 2048, - privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, - publicKeyEncoding: { type: 'spki', format: 'pem' }, -}); - -writeFileSync(outputPath, privateKey, { encoding: 'utf8', mode: 0o600 }); -chmodSync(outputPath, 0o600); diff --git a/src/conformance/baseline.rs b/src/conformance/baseline.rs index b9cfab1..d55225c 100644 --- a/src/conformance/baseline.rs +++ b/src/conformance/baseline.rs @@ -92,58 +92,16 @@ pub(crate) struct BaselineEvaluation { pub(crate) updates: Vec, } -/// Evaluates every selected lane against its strict baseline. -/// -/// Routed findings reproduced by the direct fixture are removed before the -/// routed lane is compared. Blessing still parses existing files when present, -/// so malformed baselines cannot be silently replaced. +/// Evaluates selected lanes; server findings reproduced by the direct fixture +/// are subtracted from routed results. Client findings are compared directly. pub(crate) fn evaluate_baselines( + direction: ConformanceDirection, results: &BTreeMap, selected_lanes: &[SemanticLane], baseline_root: &Path, client_version: &str, server_era: ConformanceServerEra, bless: bool, -) -> Result { - evaluate_direction_baselines( - results, - selected_lanes, - baseline_root, - client_version, - server_era, - ConformanceDirection::Server, - bless, - ) -} - -/// Evaluates scoped client-conformance results without fixture subtraction. -pub(crate) fn evaluate_client_baselines( - results: &BTreeMap, - selected_lanes: &[SemanticLane], - baseline_root: &Path, - client_version: &str, - server_era: ConformanceServerEra, - bless: bool, -) -> Result { - evaluate_direction_baselines( - results, - selected_lanes, - baseline_root, - client_version, - server_era, - ConformanceDirection::Client, - bless, - ) -} - -fn evaluate_direction_baselines( - results: &BTreeMap, - selected_lanes: &[SemanticLane], - baseline_root: &Path, - client_version: &str, - server_era: ConformanceServerEra, - direction: ConformanceDirection, - bless: bool, ) -> Result { let selected = selected_lanes.iter().copied().collect::>(); if selected.is_empty() { @@ -221,42 +179,11 @@ fn evaluate_direction_baselines( /// Writes one deterministic machine-readable lane report. pub(crate) fn write_baseline_report( + direction: ConformanceDirection, path: &Path, client_version: &str, server_era: ConformanceServerEra, comparison: &BaselineComparison, -) -> Result<()> { - write_direction_baseline_report( - path, - client_version, - server_era, - ConformanceDirection::Server, - comparison, - ) -} - -/// Writes one deterministic machine-readable client-lane report. -pub(crate) fn write_client_baseline_report( - path: &Path, - client_version: &str, - server_era: ConformanceServerEra, - comparison: &BaselineComparison, -) -> Result<()> { - write_direction_baseline_report( - path, - client_version, - server_era, - ConformanceDirection::Client, - comparison, - ) -} - -fn write_direction_baseline_report( - path: &Path, - client_version: &str, - server_era: ConformanceServerEra, - direction: ConformanceDirection, - comparison: &BaselineComparison, ) -> Result<()> { #[derive(Serialize)] #[serde(deny_unknown_fields)] @@ -690,6 +617,7 @@ mod tests { ]); let evaluation = evaluate_baselines( + ConformanceDirection::Server, &actual, &[SemanticLane::FixtureDirect, SemanticLane::ExternalDataPlane], root, @@ -735,7 +663,8 @@ mod tests { ), )]); - let evaluation = evaluate_client_baselines( + let evaluation = evaluate_baselines( + ConformanceDirection::Client, &actual, &[SemanticLane::ExternalDataPlane], root, @@ -772,6 +701,7 @@ mod tests { )]); let evaluation = evaluate_baselines( + ConformanceDirection::Server, &actual, &[SemanticLane::FixtureDirect], directory.path(), @@ -883,6 +813,7 @@ mod tests { ), )]); let error = evaluate_baselines( + ConformanceDirection::Server, &unknown, &[SemanticLane::FixtureDirect], Path::new("unused"), @@ -899,6 +830,7 @@ mod tests { results("ping", vec![check("ok", CheckStatus::Success)]), )]); let error = evaluate_baselines( + ConformanceDirection::Server, &routed, &[SemanticLane::ExternalDataPlane], Path::new("unused"), @@ -922,6 +854,7 @@ mod tests { results("ping", vec![check("warning", CheckStatus::Warning)]), )]); let evaluation = evaluate_baselines( + ConformanceDirection::Server, &actual, &[SemanticLane::FixtureDirect], &root, diff --git a/src/conformance/client.rs b/src/conformance/client.rs index abf510e..fc1b948 100644 --- a/src/conformance/client.rs +++ b/src/conformance/client.rs @@ -8,12 +8,10 @@ use serde::Deserialize; use serde_json::{Value, json}; use crate::conformance::DEFAULT_MCP_SPEC_VERSION; -use crate::infrastructure::process::{CommandSpec, ProcessRunner, SystemProcessRunner}; use crate::mcp::GatewayTopology; use crate::mcp::gateway::{GatewayClient, GatewayRequest}; pub(crate) const INTERNAL_CLIENT_COMMAND: &str = "__client-conformance"; -pub(crate) const CLIENT_COMPOSE_ARGS_ENV: &str = "CF_CLIENT_CONFORMANCE_COMPOSE_ARGS"; pub(crate) const CLIENT_BASE_URL_ENV: &str = "CF_CLIENT_CONFORMANCE_BASE_URL"; pub(crate) const CLIENT_SERVER_ID_ENV: &str = "CF_CLIENT_CONFORMANCE_SERVER_ID"; pub(crate) const CLIENT_TOKEN_ENV: &str = "MCP_CONFORMANCE_TOKEN"; @@ -55,7 +53,19 @@ pub(crate) async fn run_internal_client(arguments: &[OsString]) -> Result<()> { let base_url = required_environment(CLIENT_BASE_URL_ENV)?; let tool_calls = scenario_tool_calls(&scenario)?; let backend_url = container_backend_url(scenario_server_url)?; - publish_scenario_config(&backend_url, &server_id, &protocol_version, &tool_calls)?; + let tool_names = tool_calls + .iter() + .map(|call| call.name.clone()) + .collect::>(); + crate::helpers::publish_client_config( + &server_id, + &backend_url, + &protocol_version, + &token, + tool_names.into_iter().collect(), + ) + .await + .context("failed to publish the client-conformance dataplane configuration")?; let mut client = GatewayClient::builder(GatewayTopology::Dataplane, &base_url, &server_id, &token) @@ -136,48 +146,12 @@ fn container_backend_url(value: &str) -> Result { if !matches!(url.scheme(), "http" | "https") || !loopback { bail!("scenario-server URL must be an absolute loopback HTTP(S) URL"); } - url.set_host(Some("host.docker.internal")).map_err(|_| { + url.set_host(Some("nginx")).map_err(|_| { anyhow!("failed to address the scenario server from the dataplane container") })?; Ok(url.into()) } -fn publish_scenario_config( - backend_url: &str, - server_id: &str, - protocol_version: &str, - tool_calls: &[ToolCall], -) -> Result<()> { - let serialized_args = required_environment(CLIENT_COMPOSE_ARGS_ENV)?; - let compose_args: Vec = serde_json::from_str(&serialized_args) - .context("CF_CLIENT_CONFORMANCE_COMPOSE_ARGS is not a JSON string array")?; - if compose_args.first().map(String::as_str) != Some("compose") { - bail!("client conformance Compose arguments must begin with compose"); - } - let tool_names = tool_calls - .iter() - .map(|call| call.name.as_str()) - .collect::>(); - let tool_names = serde_json::to_string(&tool_names) - .context("failed to serialize client conformance tool names")?; - let command = CommandSpec::new("docker").args(compose_args).args([ - "run", - "--rm", - "--no-deps", - "-e", - CLIENT_TOKEN_ENV, - "config_writer", - "client", - server_id, - backend_url, - protocol_version, - &tool_names, - ]); - SystemProcessRunner - .run(&command) - .context("failed to publish the client-conformance dataplane configuration") -} - fn required_environment(name: &str) -> Result { std::env::var(name) .with_context(|| format!("{name} is required for internal client conformance")) @@ -198,7 +172,7 @@ mod tests { assert_eq!( container_backend_url("http://127.0.0.1:43123/mcp?scenario=tools") .expect("loopback URL should be accepted"), - "http://host.docker.internal:43123/mcp?scenario=tools" + "http://nginx:43123/mcp?scenario=tools" ); assert!(container_backend_url("https://example.com/mcp").is_err()); } diff --git a/src/conformance/results.rs b/src/conformance/results.rs index a0a535f..f274e5b 100644 --- a/src/conformance/results.rs +++ b/src/conformance/results.rs @@ -290,10 +290,7 @@ pub(crate) fn official_server_command( expected_failures: &Path, output_dir: &Path, ) -> CommandSpec { - CommandSpec::new("npx") - .clear_environment() - .arg("-y") - .arg(OFFICIAL_CONFORMANCE_PACKAGE) + CommandSpec::new("conformance") .arg("server") .arg("--url") .arg(endpoint) @@ -317,10 +314,7 @@ pub(crate) fn official_client_command( expected_failures: &Path, output_dir: &Path, ) -> CommandSpec { - CommandSpec::new("npx") - .clear_environment() - .arg("-y") - .arg(OFFICIAL_CONFORMANCE_PACKAGE) + CommandSpec::new("conformance") .arg("client") .arg("--command") .arg(client_command) diff --git a/src/conformance/results_tests.rs b/src/conformance/results_tests.rs index 2ff926e..6d38111 100644 --- a/src/conformance/results_tests.rs +++ b/src/conformance/results_tests.rs @@ -249,12 +249,10 @@ fn official_command_is_pinned_complete_and_ordered() { "@modelcontextprotocol/conformance@0.2.0-alpha.11" ); assert_eq!(DEFAULT_MCP_SPEC_VERSION, "2026-07-28"); - assert!(!spec.inherits_environment()); + assert_eq!(spec.program(), "conformance"); assert_eq!( spec.arguments(), &[ - OsString::from("-y"), - OsString::from(OFFICIAL_CONFORMANCE_PACKAGE), OsString::from("server"), OsString::from("--url"), OsString::from("http://127.0.0.1:49152/mcp"), @@ -281,12 +279,10 @@ fn official_client_command_is_scoped_complete_and_ordered() { Path::new("results"), ); - assert!(!spec.inherits_environment()); + assert_eq!(spec.program(), "conformance"); assert_eq!( spec.arguments(), &[ - OsString::from("-y"), - OsString::from(OFFICIAL_CONFORMANCE_PACKAGE), OsString::from("client"), OsString::from("--command"), OsString::from("cf-integration __client-conformance"), diff --git a/src/helpers/auth.rs b/src/helpers/auth.rs new file mode 100644 index 0000000..6009397 --- /dev/null +++ b/src/helpers/auth.rs @@ -0,0 +1,83 @@ +//! Ephemeral test signing keys and a loopback public JWKS endpoint. + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result, ensure}; +use aws_lc_rs::encoding::{AsDer, Pkcs8V1Der}; +use aws_lc_rs::rsa::{KeyPair, KeySize}; +use aws_lc_rs::signature::KeyPair as _; +use axum::{Json, Router, routing::get}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use serde_json::{Value, json}; + +const KEY_ID: &str = "cf-integration-standalone"; + +pub(super) fn router(key_path: &Path) -> Result { + if !key_path.exists() { + let key = KeyPair::generate(KeySize::Rsa2048).context("failed to generate test RSA key")?; + let der: Pkcs8V1Der<'_> = key.as_der().context("failed to encode test RSA key")?; + let pem = pem::encode(&pem::Pem::new("PRIVATE KEY", der.as_ref())); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(key_path)?.write_all(pem.as_bytes())?; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(key_path, fs::Permissions::from_mode(0o600))?; + } + let key = pem::parse(fs::read(key_path)?).context("invalid test signing key PEM")?; + let key = KeyPair::from_pkcs8(key.contents()).context("invalid test RSA signing key")?; + let public = key.public_key(); + let jwks = json!({"keys": [{ + "kty": "RSA", "kid": KEY_ID, "alg": "RS256", "use": "sig", + "n": URL_SAFE_NO_PAD.encode(public.modulus().big_endian_without_leading_zero()), + "e": URL_SAFE_NO_PAD.encode(public.exponent().big_endian_without_leading_zero()), + }]}); + Ok(Router::new().route( + "/.well-known/jwks.json", + get(move || { + let jwks = jwks.clone(); + async move { Json(jwks) } + }), + )) +} + +pub(super) fn issue_token(key_path: &Path, tenant_id: &str, user_id: &str) -> Result { + let key = + EncodingKey::from_rsa_pem(&fs::read(key_path)?).context("invalid test signing key")?; + let now = jsonwebtoken::get_current_timestamp(); + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(KEY_ID.to_owned()); + jsonwebtoken::encode( + &header, + &json!({ + "sub": user_id, "tenant_id": tenant_id, "iat": now, "nbf": now, "exp": now + 86400, + }), + &key, + ) + .context("failed to sign test JWT") +} + +pub(super) fn token_subject(token: &str) -> Result { + // Only read the routing identity here. The dataplane verifies the signature. + let claims = jsonwebtoken::dangerous::insecure_decode::(token) + .context("MCP_CONFORMANCE_TOKEN has invalid JWT claims")? + .claims; + let subject = claims["sub"] + .as_str() + .context("MCP_CONFORMANCE_TOKEN has no string subject")?; + ensure!( + !subject.is_empty(), + "MCP_CONFORMANCE_TOKEN has no string subject" + ); + Ok(subject.to_owned()) +} diff --git a/src/helpers/config.rs b/src/helpers/config.rs new file mode 100644 index 0000000..416a2a8 --- /dev/null +++ b/src/helpers/config.rs @@ -0,0 +1,272 @@ +//! Discover fixture catalogs and publish the dataplane's MessagePack contract. + +use std::collections::{BTreeMap, BTreeSet}; +use std::time::Duration; + +use anyhow::{Context, Result, ensure}; +use reqwest::header::{ACCEPT, CONTENT_TYPE}; +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use url::Url; + +use crate::mcp::gateway::{MCP_PROTOCOL_VERSION, MCP_SESSION_ID}; +use crate::mcp::protocol::{self, is_stateless_protocol, jsonrpc_with_id, with_request_metadata}; + +const TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Deserialize)] +struct Tool { + name: String, + #[serde(rename = "inputSchema")] + input_schema: Map, +} + +pub(super) struct Catalog { + tools: Vec, + resources: Vec, + resource_templates: Vec, + prompts: Vec, +} + +impl Catalog { + pub(super) fn for_client(tools: Vec) -> Self { + Self { + tools: tools + .into_iter() + .map(|name| Tool { + name, + input_schema: Map::new(), + }) + .collect(), + resources: Vec::new(), + resource_templates: Vec::new(), + prompts: Vec::new(), + } + } + + pub(super) fn tool_names(&self) -> Vec<&str> { + self.tools.iter().map(|tool| tool.name.as_str()).collect() + } + + pub(super) fn config( + &self, + server_id: &str, + backend_url: &str, + protocol_version: &str, + ) -> Value { + let backend_name = "conformance-backend"; + let routes = |names: Vec<&str>| { + names + .into_iter() + .map(|name| { + ( + name.to_owned(), + json!({"backend_name": backend_name, "upstream_name": name}), + ) + }) + .collect::>() + }; + let schemas: BTreeMap<_, _> = self + .tools + .iter() + .map(|tool| (&tool.name, &tool.input_schema)) + .collect(); + json!({"virtual_hosts": {server_id: { + "backends": {backend_name: { + "name": backend_name, "url": backend_url, "mcp_protocol_version": protocol_version, + "passthrough_headers": [], "add_headers": {}, "remove_headers": [], + "completion": {}, "tool_schemas": schemas, + }}, + "tools": routes(self.tool_names()), + "resources": routes(self.resources.iter().map(String::as_str).collect()), + "resource_templates": routes(self.resource_templates.iter().map(String::as_str).collect()), + "prompts": routes(self.prompts.iter().map(String::as_str).collect()), + }}}) + } +} + +pub(super) async fn publish(redis_url: &str, subject: &str, body: &Value) -> Result<()> { + let client = redis::Client::open(redis_url).context("invalid config Redis URL")?; + let options = redis::AsyncConnectionConfig::new() + .set_connection_timeout(Some(TIMEOUT)) + .set_response_timeout(Some(TIMEOUT)); + let mut connection = client + .get_multiplexed_async_connection_with_config(&options) + .await + .context("failed to connect to config Redis")?; + // User::new(subject) uses this compact key; named maps preserve empty objects. + redis::cmd("SET") + .arg(rmp_serde::to_vec(&("UserConfig", subject))?) + .arg(rmp_serde::to_vec_named(body)?) + .query_async::<()>(&mut connection) + .await + .context("failed to publish dataplane config") +} + +pub(super) async fn fixture_catalog(url: Url, version: &str) -> Result { + let mut client = FixtureClient { + http: reqwest::Client::builder().timeout(TIMEOUT).build()?, + url, + version: version.to_owned(), + session: None, + request_id: 0, + }; + let result = client.catalog().await; + if let Some(session) = client.session.take() { + let cleanup = client + .http + .delete(client.url.clone()) + .header(MCP_SESSION_ID, session) + .header(MCP_PROTOCOL_VERSION, version) + .send() + .await; + // Preserve the discovery error while still attempting session cleanup. + if result.is_ok() { + cleanup.context("failed to close fixture session")?; + } + } + result +} + +struct FixtureClient { + http: reqwest::Client, + url: Url, + version: String, + session: Option, + request_id: u64, +} + +impl FixtureClient { + async fn rpc(&mut self, method: &str, params: Value, notification: bool) -> Result { + self.request_id += 1; + let modern = is_stateless_protocol(&self.version); + let params = if modern { + with_request_metadata(Some(params), &self.version) + } else { + params + }; + let mut body = jsonrpc_with_id(method, Some(params), json!(self.request_id)); + if notification { + body.as_object_mut() + .context("JSON-RPC request must be an object")? + .remove("id"); + } + let mut request = self + .http + .post(self.url.clone()) + .header(ACCEPT, protocol::ACCEPT) + .json(&body); + if method != "initialize" { + request = request.header(MCP_PROTOCOL_VERSION, &self.version); + } + if modern { + request = request.header("mcp-method", method); + } + if let Some(session) = &self.session { + request = request.header(MCP_SESSION_ID, session); + } + let response = request + .send() + .await + .with_context(|| format!("fixture {method} failed"))?; + ensure!( + response.status().is_success(), + "fixture {method} failed: HTTP {}", + response.status() + ); + if let Some(session) = response.headers().get(MCP_SESSION_ID) { + self.session = Some(session.to_str()?.to_owned()); + } + if notification { + return Ok(Value::Null); + } + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned(); + let message = protocol::parse_mcp_body(&response.text().await?, &content_type)? + .with_context(|| format!("fixture {method} returned no message"))?; + ensure!( + message["id"] == self.request_id + && message["error"].is_null() + && message["result"].is_object(), + "fixture {method} did not return a successful result" + ); + Ok(message["result"].clone()) + } + + async fn list(&mut self, method: &str, field: &str) -> Result> { + let mut items = Vec::new(); + let mut cursors = BTreeSet::new(); + let mut params = json!({}); + loop { + let mut page = self.rpc(method, params, false).await?; + items.append( + page[field] + .as_array_mut() + .with_context(|| format!("fixture {method} has no {field} array"))?, + ); + let cursor = page["nextCursor"].take(); + if cursor.is_null() { + return Ok(items); + } + ensure!( + cursor.is_string() + && cursors.insert(cursor.as_str().unwrap_or_default().to_owned()), + "fixture {method} returned an invalid or repeated cursor" + ); + params = json!({"cursor": cursor}); + } + } + + async fn names(&mut self, method: &str, field: &str, name: &str) -> Result> { + self.list(method, field) + .await? + .into_iter() + .map(|item| { + item[name] + .as_str() + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .with_context(|| format!("fixture {method} has an invalid {name}")) + }) + .collect() + } + + async fn catalog(&mut self) -> Result { + if is_stateless_protocol(&self.version) { + self.rpc("server/discover", json!({}), false).await?; + } else { + let initialize = protocol::initialize_with_id_and_version(json!(0), &self.version); + self.rpc("initialize", initialize["params"].clone(), false) + .await?; + self.rpc("notifications/initialized", json!({}), true) + .await?; + } + let tools: Vec = self + .list("tools/list", "tools") + .await? + .into_iter() + .map(serde_json::from_value) + .collect::>() + .context("fixture tools must include names and input schemas")?; + ensure!( + !tools.is_empty() && tools.iter().all(|tool| !tool.name.is_empty()), + "fixture tools must include names and input schemas" + ); + Ok(Catalog { + tools, + resources: self.names("resources/list", "resources", "uri").await?, + resource_templates: self + .names( + "resources/templates/list", + "resourceTemplates", + "uriTemplate", + ) + .await?, + prompts: self.names("prompts/list", "prompts", "name").await?, + }) + } +} diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs new file mode 100644 index 0000000..8dddd9f --- /dev/null +++ b/src/helpers/mod.rs @@ -0,0 +1,124 @@ +//! Private container operations performed by the same integration executable. + +use std::ffi::OsString; + +use anyhow::{Context, Result, ensure}; +use clap::{Args, Parser, Subcommand}; +use url::Url; + +mod auth; +mod config; +#[cfg(test)] +mod tests; +pub(crate) mod tools; + +const KEY_PATH: &str = "/keys/jwt.key"; +const JWKS_ADDRESS: &str = "127.0.0.1:4446"; + +#[derive(Parser)] +struct HelperArgs { + #[command(subcommand)] + command: HelperCommand, +} + +#[derive(Subcommand)] +enum HelperCommand { + Auth, + Health, + Token { tenant_id: String, user_id: String }, + Fixture(ConfigArgs), +} + +#[derive(Args)] +struct ConfigArgs { + server_id: String, + backend_url: Url, + protocol_version: String, +} + +pub(crate) async fn run(arguments: &[OsString]) -> Result<()> { + let args = match HelperArgs::try_parse_from(arguments)?.command { + HelperCommand::Auth => { + let router = auth::router(std::path::Path::new(KEY_PATH))?; + let listener = tokio::net::TcpListener::bind(JWKS_ADDRESS).await?; + axum::serve(listener, router) + .with_graceful_shutdown(shutdown_signal()) + .await?; + return Ok(()); + } + HelperCommand::Health => { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(1)) + .build()? + .get(format!("http://{JWKS_ADDRESS}/.well-known/jwks.json")) + .send() + .await? + .error_for_status()?; + return Ok(()); + } + HelperCommand::Token { tenant_id, user_id } => { + ensure!( + !tenant_id.is_empty() && !user_id.is_empty(), + "tenant-id and user-id must not be empty" + ); + println!( + "{}", + auth::issue_token(std::path::Path::new(KEY_PATH), &tenant_id, &user_id)? + ); + return Ok(()); + } + HelperCommand::Fixture(args) => args, + }; + ensure!( + !args.server_id.is_empty() && !args.protocol_version.is_empty(), + "virtual-host-id and protocol-version must not be empty" + ); + ensure!( + matches!(args.backend_url.scheme(), "http" | "https"), + "backend-url must use HTTP(S)" + ); + let token = + std::env::var("MCP_CONFORMANCE_TOKEN").context("MCP_CONFORMANCE_TOKEN is required")?; + let subject = auth::token_subject(&token)?; + let catalog = config::fixture_catalog(args.backend_url.clone(), &args.protocol_version).await?; + let body = catalog.config( + &args.server_id, + args.backend_url.as_str(), + &args.protocol_version, + ); + let redis_url = + std::env::var("CF_CONFIG_REDIS_URL").unwrap_or_else(|_| "redis://redis:6379".to_owned()); + config::publish(&redis_url, &subject, &body).await?; + println!("{}", serde_json::to_string(&catalog.tool_names())?); + Ok(()) +} + +pub(crate) async fn publish_client_config( + server_id: &str, + backend_url: &str, + protocol_version: &str, + token: &str, + tools: Vec, +) -> Result<()> { + let subject = auth::token_subject(token)?; + let body = config::Catalog::for_client(tools).config(server_id, backend_url, protocol_version); + let redis_url = + std::env::var("CF_CONFIG_REDIS_URL").unwrap_or_else(|_| "redis://redis:6379".to_owned()); + config::publish(&redis_url, &subject, &body).await +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + if let Ok(mut terminate) = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + { + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = terminate.recv() => {}, + } + return; + } + } + let _ = tokio::signal::ctrl_c().await; +} diff --git a/src/helpers/tests.rs b/src/helpers/tests.rs new file mode 100644 index 0000000..a408b56 --- /dev/null +++ b/src/helpers/tests.rs @@ -0,0 +1,339 @@ +use std::sync::{Arc, Mutex}; + +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation}; +use serde_json::{Value, json}; +use tokio::task::JoinHandle; +use url::Url; + +use super::{auth, config}; +use crate::mcp::protocol::{LEGACY_PROTOCOL_VERSION, PROTOCOL_VERSION}; + +async fn serve(router: Router) -> (Url, JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fixture"); + let url = Url::parse(&format!( + "http://{}/mcp", + listener.local_addr().expect("fixture address") + )) + .expect("fixture URL"); + let task = tokio::spawn(async move { + axum::serve(listener, router).await.expect("serve fixture"); + }); + (url, task) +} + +#[derive(Clone)] +struct Fixture { + version: &'static str, + calls: Arc>>, + invalid_tools: Option, +} + +impl Fixture { + fn router(&self) -> Router { + Router::new() + .route("/mcp", post(fixture_rpc).delete(fixture_delete)) + .with_state(self.clone()) + } +} + +async fn fixture_rpc( + State(fixture): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + fixture + .calls + .lock() + .expect("calls lock") + .push((body.clone(), headers)); + let result = match body["method"].as_str().expect("method") { + "initialize" | "server/discover" => json!({"protocolVersion": fixture.version}), + "notifications/initialized" => return StatusCode::ACCEPTED.into_response(), + "tools/list" => fixture.invalid_tools.unwrap_or_else(|| { + if body["params"].get("cursor").is_some() { + json!({"tools": [{"name": "diagnostic", "inputSchema": { + "type": "object", "properties": {"value": {"type": "string", "x-mcp-header": "Value"}} + }}], "nextCursor": null}) + } else { + json!({"tools": [{"name": "first", "inputSchema": {}}], "nextCursor": ""}) + } + }), + "resources/list" => json!({"resources": [{"uri": "test://resource"}]}), + "resources/templates/list" => json!({"resourceTemplates": [{"uriTemplate": "test://resource/{id}"}]}), + "prompts/list" => json!({"prompts": [{"name": "prompt"}]}), + method => panic!("unexpected fixture method {method}"), + }; + let message = json!({"jsonrpc": "2.0", "id": body["id"], "result": result}); + let sse = fixture.version == LEGACY_PROTOCOL_VERSION || body["params"].get("cursor").is_some(); + let mut response = if sse { + ( + [("content-type", "text/event-stream")], + format!("event: message\r\ndata:\r\n\r\ndata: {message}\r\n\r\n"), + ) + .into_response() + } else { + Json(message).into_response() + }; + if fixture.version == LEGACY_PROTOCOL_VERSION { + response.headers_mut().insert( + "mcp-session-id", + "legacy-session".parse().expect("session header"), + ); + } + response +} + +async fn fixture_delete(State(fixture): State, headers: HeaderMap) -> StatusCode { + fixture + .calls + .lock() + .expect("calls lock") + .push((json!({"method": "DELETE"}), headers)); + StatusCode::NO_CONTENT +} + +#[tokio::test] +async fn catalog_discovers_pages_and_preserves_protocol_headers_and_schemas() { + for version in [PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION] { + let fixture = Fixture { + version, + calls: Arc::default(), + invalid_tools: None, + }; + let (url, task) = serve(fixture.router()).await; + let catalog = config::fixture_catalog(url, version) + .await + .expect("fixture catalog"); + task.abort(); + assert_eq!(catalog.tool_names(), ["first", "diagnostic"]); + let config = catalog.config("server", "http://fixture/mcp", version); + let host = &config["virtual_hosts"]["server"]; + assert_eq!( + host["backends"]["conformance-backend"]["tool_schemas"]["diagnostic"]["properties"]["value"] + ["x-mcp-header"], + "Value" + ); + assert_eq!( + host["resources"]["test://resource"]["upstream_name"], + "test://resource" + ); + assert!( + host["resource_templates"] + .get("test://resource/{id}") + .is_some() + ); + assert!(host["prompts"].get("prompt").is_some()); + let calls = fixture.calls.lock().expect("calls lock"); + assert_eq!( + calls + .iter() + .filter(|(body, _)| body["method"] == "tools/list") + .count(), + 2 + ); + for (body, headers) in calls.iter() { + if version == PROTOCOL_VERSION { + assert_eq!( + body["params"]["_meta"]["io.modelcontextprotocol/protocolVersion"], + version + ); + assert_eq!( + headers["mcp-method"], + body["method"].as_str().expect("method") + ); + } else { + assert!(body["params"].get("_meta").is_none()); + assert!(!headers.contains_key("mcp-method")); + if body["method"] == "initialize" { + assert!(!headers.contains_key("mcp-protocol-version")); + assert_eq!(body["params"]["protocolVersion"], version); + } else { + assert_eq!(headers["mcp-session-id"], "legacy-session"); + assert_eq!(headers["mcp-protocol-version"], version); + } + } + } + if version == LEGACY_PROTOCOL_VERSION { + assert_eq!(calls[0].0["method"], "initialize"); + assert_eq!(calls[1].0["method"], "notifications/initialized"); + assert!(calls[1].0.get("id").is_none()); + assert_eq!(calls.last().expect("cleanup").0["method"], "DELETE"); + } else { + assert_eq!(calls[0].0["method"], "server/discover"); + } + } +} + +#[tokio::test] +async fn invalid_catalogs_fail_closed_and_release_legacy_sessions() { + for page in [ + Value::Null, + json!({}), + json!({"tools": []}), + json!({"tools": [{"name": "bad", "inputSchema": []}]}), + json!({"tools": [{"name": "tool", "inputSchema": {}}], "nextCursor": []}), + json!({"tools": [{"name": "tool", "inputSchema": {}}], "nextCursor": "repeated"}), + ] { + let fixture = Fixture { + version: LEGACY_PROTOCOL_VERSION, + calls: Arc::default(), + invalid_tools: Some(page), + }; + let (url, task) = serve(fixture.router()).await; + assert!( + config::fixture_catalog(url, LEGACY_PROTOCOL_VERSION) + .await + .is_err() + ); + task.abort(); + assert_eq!( + fixture + .calls + .lock() + .expect("calls lock") + .last() + .expect("cleanup") + .0["method"], + "DELETE" + ); + } +} + +#[test] +fn client_config_uses_named_messagepack_maps_and_compact_user_key() { + let catalog = config::Catalog::for_client(vec!["metadata_probe".into(), "add_numbers".into()]); + let body = catalog.config("scenario", "http://fixture/mcp", PROTOCOL_VERSION); + let packed = rmp_serde::to_vec_named(&body).expect("encode config"); + let decoded: Value = rmp_serde::from_slice(&packed).expect("decode config"); + let host = &decoded["virtual_hosts"]["scenario"]; + for name in catalog.tool_names() { + assert_eq!( + host["tools"][name], + json!({"backend_name": "conformance-backend", "upstream_name": name}) + ); + assert_eq!( + host["backends"]["conformance-backend"]["tool_schemas"][name], + json!({}) + ); + } + for field in ["resources", "resource_templates", "prompts"] { + assert_eq!(host[field], json!({})); + } + assert_eq!( + rmp_serde::to_vec(&("UserConfig", "subject")).expect("encode key"), + b"\x92\xaaUserConfig\xa7subject" + ); +} + +#[tokio::test] +async fn auth_reuses_private_key_and_serves_only_public_jwks() { + let directory = tempfile::tempdir().expect("key directory"); + let key = directory.path().join("jwt.key"); + let http = reqwest::Client::new(); + let mut original = Value::Null; + for _ in 0..2 { + let router = auth::router(&key).expect("auth router"); + let (base, task) = serve(router).await; + let url = base.join("/.well-known/jwks.json").expect("JWKS URL"); + let jwks: Value = http + .get(url.clone()) + .send() + .await + .expect("JWKS response") + .json() + .await + .expect("JWKS JSON"); + if !original.is_null() { + assert_eq!(original, jwks); + } + original = jwks.clone(); + let jwk = &jwks["keys"][0]; + for field in ["d", "p", "q", "dp", "dq", "qi"] { + assert!(jwk.get(field).is_none()); + } + let token = auth::issue_token(&key, "tenant", "subject").expect("sign JWT"); + let jwk: jsonwebtoken::jwk::Jwk = serde_json::from_value(jwk.clone()).expect("public JWK"); + let decoded = jsonwebtoken::decode::( + &token, + &DecodingKey::from_jwk(&jwk).expect("JWK decoding key"), + &Validation::new(Algorithm::RS256), + ) + .expect("verify signature"); + assert_eq!(decoded.claims["sub"], "subject"); + assert_eq!(decoded.claims["tenant_id"], "tenant"); + assert_eq!( + auth::token_subject(&token).expect("token subject"), + "subject" + ); + assert_eq!( + decoded.header.kid.as_deref(), + Some("cf-integration-standalone") + ); + assert_eq!( + http.head(url.clone()) + .send() + .await + .expect("HEAD") + .bytes() + .await + .expect("HEAD body") + .len(), + 0 + ); + assert_eq!(http.post(url).send().await.expect("POST").status(), 405); + assert_eq!( + http.get(base.join("/jwt.key").expect("key URL")) + .send() + .await + .expect("private path") + .status(), + 404 + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&key) + .expect("key metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + task.abort(); + } +} + +#[test] +fn invalid_existing_key_is_preserved_and_rejected() { + let directory = tempfile::tempdir().expect("key directory"); + let key = directory.path().join("jwt.key"); + std::fs::write(&key, "invalid-key").expect("invalid key fixture"); + assert!(auth::router(&key).is_err()); + assert_eq!( + std::fs::read_to_string(key).expect("preserved key"), + "invalid-key" + ); +} + +#[test] +fn token_subject_rejects_missing_or_invalid_claims() { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + for claims in [json!({}), json!({"sub": ""}), json!({"sub": 123})] { + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256"}"#); + let token = format!( + "{header}.{}.signature", + URL_SAFE_NO_PAD.encode(claims.to_string()) + ); + assert!(auth::token_subject(&token).is_err()); + } + assert!(auth::token_subject("not-a-token").is_err()); +} diff --git a/src/helpers/tools.rs b/src/helpers/tools.rs new file mode 100644 index 0000000..50745d8 --- /dev/null +++ b/src/helpers/tools.rs @@ -0,0 +1,192 @@ +//! Upstream tools run only inside the image containing their pinned packages. + +use std::ffi::OsString; +use std::path::Path; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand, ValueEnum}; +use url::Url; + +use crate::conformance::client::{CLIENT_TOKEN_ENV, INTERNAL_CLIENT_COMMAND}; +use crate::conformance::results::{ + DEFAULT_CONFORMANCE_SUITE, official_client_command, official_server_command, +}; +use crate::error::AppFailure; +use crate::infrastructure::process::{CommandSpec, ProcessRunner, SystemProcessRunner}; +use crate::mcp::auth_proxy::AuthProxy; +use crate::mcp::backend_identity::is_dataplane_endpoint; + +#[derive(Parser)] +struct ToolArgs { + #[command(subcommand)] + command: Tool, +} + +#[derive(Subcommand)] +enum Tool { + Server { + endpoint: Url, + spec_version: String, + #[arg(long)] + proxy: Option, + }, + Client { + scenario: String, + spec_version: String, + }, + Inspect { + endpoint: Url, + spec_version: String, + method: String, + }, +} + +#[derive(Clone, Copy, ValueEnum)] +enum ProxyMode { + Builtin, + External, +} + +/// Uses Compose DNS for local gateways; remote endpoints retain their address. +fn gateway_destination(endpoint: &Url) -> Result { + let loopback = match endpoint.host() { + Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(address)) => address.is_loopback(), + Some(url::Host::Ipv6(address)) => address.is_loopback(), + None => false, + }; + let mut destination = endpoint.clone(); + if loopback { + destination.set_host(Some("nginx"))?; + destination + .set_port(None) + .map_err(|()| anyhow::anyhow!("invalid gateway port"))?; + destination + .set_scheme("http") + .map_err(|()| anyhow::anyhow!("invalid gateway scheme"))?; + } + Ok(destination) +} + +async fn proxy(endpoint: Url, version: Option<&str>, require_dataplane: bool) -> Result { + let token = std::env::var(CLIENT_TOKEN_ENV).context("MCP_CONFORMANCE_TOKEN is required")?; + Ok(AuthProxy::start_routed( + endpoint.clone(), + gateway_destination(&endpoint)?, + &token, + version, + require_dataplane, + ) + .await?) +} + +pub(crate) async fn run(arguments: &[OsString]) -> Result<(), AppFailure> { + let tool = ToolArgs::try_parse_from(arguments) + .map_err(anyhow::Error::from)? + .command; + let expected = Path::new("/artifacts/expected-failures.yml"); + let results = Path::new("/artifacts/official"); + let (command, proxy) = match tool { + Tool::Server { + endpoint, + spec_version, + proxy: mode, + } => { + let proxy = match mode { + Some(mode) => { + Some(proxy(endpoint.clone(), None, matches!(mode, ProxyMode::External)).await?) + } + None => None, + }; + let url = proxy.as_ref().map_or(&endpoint, AuthProxy::url); + ( + official_server_command( + url.as_str(), + DEFAULT_CONFORMANCE_SUITE, + &spec_version, + expected, + results, + ), + proxy, + ) + } + Tool::Client { + scenario, + spec_version, + } => ( + official_client_command( + &format!("cf-integration {INTERNAL_CLIENT_COMMAND}"), + &scenario, + &spec_version, + expected, + results, + ), + None, + ), + Tool::Inspect { + endpoint, + spec_version, + method, + } => { + let require_dataplane = is_dataplane_endpoint(&endpoint); + let proxy = proxy(endpoint, Some(&spec_version), require_dataplane).await?; + let command = CommandSpec::new("mcp-inspector").args([ + "--cli", + proxy.url().as_str(), + "--transport", + "http", + "--method", + &method, + ]); + (command, Some(proxy)) + } + }; + let mut command = command.clear_environment(); + for key in [ + "PATH", + "HOME", + CLIENT_TOKEN_ENV, + "CF_CONFIG_REDIS_URL", + "CF_CLIENT_CONFORMANCE_BASE_URL", + "CF_CLIENT_CONFORMANCE_SERVER_ID", + ] { + if let Some(value) = std::env::var_os(key) { + command = command.env(key, value); + } + } + let result = SystemProcessRunner + .run_async(&command) + .await + .map_err(AppFailure::from); + if let Some(proxy) = proxy { + let cleanup = proxy.shutdown().await.map_err(anyhow::Error::from); + if result.is_ok() { + cleanup?; + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn container_gateway_routing_preserves_paths_and_remote_endpoints() { + for host in ["127.0.0.1", "localhost", "[::1]"] { + let public = + Url::parse(&format!("http://{host}:8080/servers/id/mcp?key=value")).expect("URL"); + assert_eq!( + gateway_destination(&public) + .expect("Docker destination") + .as_str(), + "http://nginx/servers/id/mcp?key=value" + ); + } + let remote = Url::parse("https://gateway.example/servers/id/mcp").expect("URL"); + assert_eq!( + gateway_destination(&remote).expect("remote destination"), + remote + ); + } +} diff --git a/src/infrastructure/assets.rs b/src/infrastructure/assets.rs index 866fa27..27f9fbc 100644 --- a/src/infrastructure/assets.rs +++ b/src/infrastructure/assets.rs @@ -2,6 +2,9 @@ use std::fs; use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use include_dir::{Dir, include_dir}; use anyhow::{Context, Result, bail}; use uuid::Uuid; @@ -9,56 +12,75 @@ use uuid::Uuid; const COMPLETE_MARKER: &str = ".complete"; struct EmbeddedAsset { - path: &'static str, + path: PathBuf, contents: &'static [u8], } macro_rules! asset { ($path:literal) => { EmbeddedAsset { - path: $path, + path: PathBuf::from($path), contents: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/", $path)), } }; } -const ASSETS: &[EmbeddedAsset] = &[ - asset!("docker/docker-compose.cf-conformance-fixture.yaml"), - asset!("docker/docker-compose.cf-conformance-controlplane.yaml"), - asset!("docker/docker-compose.cf-conformance-runtime.yaml"), - asset!("docker/docker-compose.cf-conformance.yaml"), - asset!("docker/docker-compose.cf-controlplane-build-labels.yaml"), - asset!("docker/docker-compose.cf-controlplane-observability.yaml"), - asset!("docker/docker-compose.cf-dataplane-build.yaml"), - asset!("docker/docker-compose.cf-dataplane-config.yaml"), - asset!("docker/docker-compose.cf-dataplane-observability.yaml"), - asset!("docker/docker-compose.cf-dataplane-standalone.yaml"), - asset!("docker/docker-compose.cf-dataplane.yaml"), - asset!("docker/docker-compose.cf-integration.yaml"), - asset!("docker/docker-compose.cf-telemetry.yaml"), - asset!("docker/mcp-conformance-server.Dockerfile"), - asset!("docker/nginx.cf-conformance-proxy.conf"), - asset!("docker/nginx.cf-dataplane.conf"), - asset!("docker/nginx.cf-dataplane-standalone.conf.template"), - asset!("docker/patch-mcp-conformance-hosts.mjs"), - asset!("scripts/live_protocol/sitecustomize.py"), - asset!("scripts/conformance/write_dataplane_config.mjs"), - asset!("scripts/locustfile_mcp.py"), - asset!("scripts/standalone/generate_auth_key.mjs"), - asset!("tests/conformance/baselines/2026-07-28/legacy/built-in-data-plane.yml"), - asset!("tests/conformance/baselines/2026-07-28/legacy/client/external-data-plane.yml"), - asset!("tests/conformance/baselines/2026-07-28/legacy/external-data-plane.yml"), - asset!("tests/conformance/baselines/2026-07-28/legacy/fixture-direct.yml"), - asset!("tests/conformance/baselines/2026-07-28/modern/built-in-data-plane.yml"), - asset!("tests/conformance/baselines/2026-07-28/modern/client/external-data-plane.yml"), - asset!("tests/conformance/baselines/2026-07-28/modern/external-data-plane.yml"), - asset!("tests/conformance/baselines/2026-07-28/modern/fixture-direct.yml"), -]; +static SOURCES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src"); + +static ASSETS: LazyLock> = LazyLock::new(|| { + let mut assets = vec![ + asset!("Cargo.toml"), + asset!("Cargo.lock"), + asset!("docker/clickstack/collector.yaml"), + asset!("docker/docker-compose.cf-conformance-fixture.yaml"), + asset!("docker/docker-compose.cf-conformance-controlplane.yaml"), + asset!("docker/docker-compose.cf-conformance-runtime.yaml"), + asset!("docker/docker-compose.cf-conformance.yaml"), + asset!("docker/docker-compose.cf-controlplane-build-labels.yaml"), + asset!("docker/docker-compose.cf-controlplane-observability.yaml"), + asset!("docker/docker-compose.cf-dataplane-build.yaml"), + asset!("docker/docker-compose.cf-dataplane-config.yaml"), + asset!("docker/docker-compose.cf-dataplane-observability.yaml"), + asset!("docker/docker-compose.cf-dataplane-standalone.yaml"), + asset!("docker/docker-compose.cf-dataplane.yaml"), + asset!("docker/docker-compose.cf-integration.yaml"), + asset!("docker/docker-compose.cf-telemetry.yaml"), + asset!("docker/docker-compose.cf-tools.yaml"), + asset!("docker/mcp-conformance-server.Dockerfile"), + asset!("docker/nginx.cf-conformance-proxy.conf"), + asset!("docker/nginx.cf-dataplane.conf"), + asset!("docker/nginx.cf-dataplane-standalone.conf.template"), + asset!("docker/mcp-conformance.patch"), + asset!("docker/helpers.Dockerfile"), + asset!("scripts/live_protocol/sitecustomize.py"), + asset!("scripts/locustfile_mcp.py"), + asset!("tests/conformance/baselines/2026-07-28/legacy/built-in-data-plane.yml"), + asset!("tests/conformance/baselines/2026-07-28/legacy/client/external-data-plane.yml"), + asset!("tests/conformance/baselines/2026-07-28/legacy/external-data-plane.yml"), + asset!("tests/conformance/baselines/2026-07-28/legacy/fixture-direct.yml"), + asset!("tests/conformance/baselines/2026-07-28/modern/built-in-data-plane.yml"), + asset!("tests/conformance/baselines/2026-07-28/modern/client/external-data-plane.yml"), + asset!("tests/conformance/baselines/2026-07-28/modern/external-data-plane.yml"), + asset!("tests/conformance/baselines/2026-07-28/modern/fixture-direct.yml"), + ]; + add_sources(&SOURCES, &mut assets); + assets +}); + +fn add_sources(directory: &'static Dir<'static>, assets: &mut Vec) { + assets.extend(directory.files().map(|file| EmbeddedAsset { + path: Path::new("src").join(file.path()), + contents: file.contents(), + })); + for child in directory.dirs() { + add_sources(child, assets); + } +} /// Returns whether `root` contains the complete runtime asset set. #[must_use] pub(crate) fn contains_runtime_assets(root: &Path) -> bool { - ASSETS.iter().all(|asset| root.join(asset.path).is_file()) + ASSETS.iter().all(|asset| root.join(&asset.path).is_file()) } /// Materializes the embedded runtime files below the integration directory. @@ -102,8 +124,8 @@ pub(crate) fn materialize_runtime_assets(integration_dir: &Path) -> Result Result<()> { fs::create_dir(root) .with_context(|| format!("failed to create temporary asset tree {}", root.display()))?; - for asset in ASSETS { - let path = root.join(asset.path); + for asset in ASSETS.iter() { + let path = root.join(&asset.path); if let Some(parent) = path.parent() { fs::create_dir_all(parent).with_context(|| { format!("failed to create embedded asset path {}", parent.display()) @@ -128,8 +150,8 @@ fn validate_materialized_assets(root: &Path) -> Result<()> { root.display() ); } - for asset in ASSETS { - let path = root.join(asset.path); + for asset in ASSETS.iter() { + let path = root.join(&asset.path); let contents = fs::read(&path).with_context(|| { format!( "embedded runtime asset {} is missing; remove {} and retry", @@ -190,7 +212,7 @@ mod tests { fn rejects_corrupted_versioned_assets() { let directory = tempfile::tempdir().expect("temporary directory"); let root = materialize_runtime_assets(directory.path()).expect("materialize assets"); - let path = root.join(ASSETS[0].path); + let path = root.join(&ASSETS[0].path); make_writable(&path); fs::write(&path, b"corrupt").expect("corrupt test asset"); diff --git a/src/infrastructure/compose.rs b/src/infrastructure/compose.rs index cadff54..a7b41aa 100644 --- a/src/infrastructure/compose.rs +++ b/src/infrastructure/compose.rs @@ -15,7 +15,7 @@ const LEGACY_FAST_TIME_IMAGE_PREFIXES: &[&str] = &[ /// Compose service keys and their public container display names. pub(crate) const SERVICE_DISPLAY_NAMES: &[(&str, &str)] = &[ - ("auth_keygen", "cf-dataplane-auth-keygen"), + ("auth", "cf-dataplane-auth"), ("gateway", "cf-controlplane"), ("migration", "cf-migration"), ("register_fast_time", "cf-register-fast-time"), @@ -33,6 +33,7 @@ pub(crate) const SERVICE_DISPLAY_NAMES: &[(&str, &str)] = &[ ("a2a_echo_agent_v0_3_0", "cf-a2a-echo-agent-v0-3-0"), ("register_a2a_echo", "cf-register-a2a-echo"), ("mcp_inspector", "cf-mcp-inspector"), + ("mcp_tools", "cf-mcp-tools"), ("keycloak", "cf-keycloak"), ("mcp_conformance_server", "cf-conformance-server"), ("mcp_conformance_proxy", "cf-conformance-proxy"), @@ -281,6 +282,14 @@ impl ComposeProject { project } + /// Adds the private container used for conformance and Inspector. + #[must_use] + pub(crate) fn with_tools(mut self, repository_root: &Path) -> Self { + self.files + .push(repository_root.join("docker/docker-compose.cf-tools.yaml")); + self + } + /// Creates a `docker compose` command with project, files, and profiles. pub(crate) fn command(&self, arguments: I) -> CommandSpec where diff --git a/src/infrastructure/compose_integration_tests.rs b/src/infrastructure/compose_integration_tests.rs index c6d4ed5..2d505a8 100644 --- a/src/infrastructure/compose_integration_tests.rs +++ b/src/infrastructure/compose_integration_tests.rs @@ -37,19 +37,20 @@ fn messages(config: &serde_json::Value) -> Vec { .collect() } -fn run_fixture_patch(source: &str) -> (std::process::ExitStatus, String) { +fn run_fixture_patch(source: &str) -> (std::process::Output, String) { let directory = tempfile::tempdir().expect("create patch test directory"); - let target = directory.path().join("everything-server.ts"); + let relative = Path::new("examples/servers/typescript/everything-server.ts"); + let target = directory.path().join(relative); + fs::create_dir_all(target.parent().expect("fixture parent")).expect("fixture directory"); fs::write(&target, source).expect("write patch test input"); - let script = workspace_root().join("docker/patch-mcp-conformance-hosts.mjs"); - - let output = Command::new("node") - .arg(script) - .arg(&target) + let output = Command::new("git") + .arg("apply") + .arg(workspace_root().join("docker/mcp-conformance.patch")) + .current_dir(directory.path()) .output() - .expect("run conformance host patch"); + .expect("apply conformance fixture patch"); let contents = fs::read_to_string(target).expect("read patch test output"); - (output.status, contents) + (output, contents) } #[test] @@ -258,8 +259,7 @@ fn dataplane_overlays_track_the_current_image_build_and_environment_contract() { "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_JWKS_URL", "CONTEXTFORGE_DATA_PLANE_UPSTREAM_CONNECTION_MODE", "CONTEXTFORGE_DATA_PLANE_USER_CONFIG_CACHE_EXPIRY_SECONDS", "CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS", @@ -270,14 +270,12 @@ fn dataplane_overlays_track_the_current_image_build_and_environment_contract() { "dataplane environment must define {key}" ); } - assert_eq!( - environment[yaml_serde::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" - ); + for key in [ + "CONTEXTFORGE_DATA_PLANE_TOKEN_SECRET", + "CONTEXTFORGE_DATA_PLANE_TOKEN_VERIFICATION_PRIVATE_KEY", + ] { + assert!(!environment.contains_key(yaml_serde::Value::String(key.to_owned()))); + } assert!( environment [yaml_serde::Value::String("CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS".to_owned())] @@ -286,11 +284,6 @@ fn dataplane_overlays_track_the_current_image_build_and_environment_contract() { .contains(",nginx}"), "the default MCP Host allowlist must accept containerized Locust through nginx" ); - assert_eq!( - compose["services"]["dataplane"]["extra_hosts"][0].as_str(), - Some("host.docker.internal:host-gateway"), - "client conformance must let the dataplane reach the official scenario server" - ); assert_eq!( compose["services"]["dataplane"]["pull_policy"].as_str(), Some("${CF_DATAPLANE_PULL_POLICY:-always}") @@ -394,7 +387,7 @@ fn both_external_projects_provide_the_client_conformance_config_writer() { let compose: yaml_serde::Value = yaml_serde::from_str(&source).expect("Compose YAML"); let service = &compose["services"]["config_writer"]; - (!service.is_null()).then(|| service.clone()) + (!service["entrypoint"].is_null()).then(|| service.clone()) }) .collect(); assert_eq!( @@ -404,15 +397,12 @@ fn both_external_projects_provide_the_client_conformance_config_writer() { ); assert_eq!(helpers[0]["profiles"][0].as_str(), Some("helpers")); assert_eq!(helpers[0]["networks"][0].as_str(), Some("mcpnet")); - assert_eq!( - helpers[0]["entrypoint"][1].as_str(), - Some("/opt/contextforge-integration/write_dataplane_config.mjs") - ); + assert_eq!(helpers[0]["entrypoint"][1].as_str(), Some("__helper")); } } #[test] -fn standalone_dataplane_owns_ephemeral_jwks_auth_and_mock_helpers() { +fn standalone_harness_owns_auth_without_dataplane_tools() { let root = workspace_root(); let compose = fs::read_to_string(root.join("docker/docker-compose.cf-dataplane-standalone.yaml")) @@ -423,20 +413,26 @@ fn standalone_dataplane_owns_ephemeral_jwks_auth_and_mock_helpers() { .as_mapping() .expect("standalone services must be a mapping"); - assert_eq!(services.len(), 5); + assert_eq!(services.len(), 6); assert!(compose["services"]["gateway"].is_null()); assert_eq!( - compose["services"]["auth_keygen"]["network_mode"].as_str(), - Some("none") + compose["services"]["auth"]["network_mode"].as_str(), + Some("service:dataplane") ); assert_eq!( compose["services"]["dataplane"]["environment"]["CONTEXTFORGE_DATA_PLANE_JWKS_URL"] .as_str(), - Some("http://127.0.0.1:4445/contextforge-rs/admin/.well-known/jwks.json") + Some("http://127.0.0.1:4446/.well-known/jwks.json") ); + assert!(compose["services"]["dataplane"]["command"].is_null()); + assert!(compose["services"]["dataplane"]["volumes"].is_null()); assert_eq!( - compose["services"]["dataplane"]["command"][1].as_str(), - Some("/keys/jwt.key") + compose["services"]["nginx"]["depends_on"]["auth"]["condition"].as_str(), + Some("service_healthy") + ); + assert_eq!( + compose["services"]["config_writer"]["volumes"][0].as_str(), + Some("standalone_auth:/keys:ro") ); assert!( compose["services"]["dataplane"]["environment"]["CONTEXTFORGE_DATA_PLANE_TOKEN_SECRET"] @@ -694,9 +690,12 @@ fn observability_is_ephemeral_and_exports_both_routed_services() { #[test] fn conformance_container_inputs_pin_the_runner_revision_and_protocol_fixture() { let root = workspace_root(); + let tools = + fs::read_to_string(root.join("docker/helpers.Dockerfile")).expect("tooling Dockerfile"); + assert!(tools.contains(cf_integration::conformance::profile::OFFICIAL_CONFORMANCE_PACKAGE)); let dockerfile = fs::read_to_string(root.join("docker/mcp-conformance-server.Dockerfile")) .expect("read conformance Dockerfile"); - let patch = fs::read_to_string(root.join("docker/patch-mcp-conformance-hosts.mjs")) + let patch = fs::read_to_string(root.join("docker/mcp-conformance.patch")) .expect("read host patch script"); let compose = fs::read_to_string(root.join("docker/docker-compose.cf-conformance-fixture.yaml")) @@ -721,10 +720,9 @@ fn conformance_container_inputs_pin_the_runner_revision_and_protocol_fixture() { ); assert!(dockerfile.contains("WORKDIR /opt/mcp-conformance/examples/servers/typescript")); assert!(dockerfile.contains("npm ci")); - assert!( - dockerfile - .contains("node /usr/local/bin/patch-mcp-conformance-hosts.mjs everything-server.ts") - ); + assert!(dockerfile.contains( + "git apply --check /tmp/mcp-conformance.patch && git apply /tmp/mcp-conformance.patch" + )); assert!(dockerfile.contains( "git diff --exit-code -- . ':(exclude)examples/servers/typescript/everything-server.ts'" )); @@ -742,8 +740,6 @@ fn conformance_container_inputs_pin_the_runner_revision_and_protocol_fixture() { let replacement = "const app = createMcpExpressApp({ allowedHosts: ['mcp_conformance_server', 'localhost', '127.0.0.1', '::1'] });"; assert!(patch.contains(old)); assert!(patch.contains(replacement)); - assert!(patch.contains("replacementCount !== 1")); - assert!(patch.contains("process.argv[2]")); assert!(patch.contains("MCP_CONFORMANCE_SERVER_ERA")); assert!(patch.contains("isModernEraRequest")); assert!(patch.contains("UnsupportedProtocolVersionError")); @@ -769,10 +765,8 @@ services: - "127.0.0.1:${CF_CONFORMANCE_PORT:-0}:3000" healthcheck: test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:3000/mcp').then(response => { if (response.status !== 400) process.exit(1); }).catch(() => process.exit(1)) + - CMD-SHELL + - test "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/mcp)" = 400 interval: 2s timeout: 2s retries: 30 @@ -830,37 +824,50 @@ services: #[test] fn conformance_fixture_patch_is_fail_closed_and_adds_server_era_routing() { - let old = "const app = createMcpExpressApp();"; - let replacement = "const app = createMcpExpressApp({ allowedHosts: ['mcp_conformance_server', 'localhost', '127.0.0.1', '::1'] });"; - let versions = r#"const LEGACY_SESSION_PROTOCOL_VERSIONS = [ - '2024-11-05', - '2025-03-26', - '2025-06-18', - '2025-11-25' -];"#; - let classification = r#" const isLegacySessionEraRequest = - meta === undefined && - reqVersion !== undefined && - LEGACY_SESSION_PROTOCOL_VERSIONS.includes(reqVersion); - - if (!sessionId && (reqVersion || meta) && !isLegacySessionEraRequest) {"#; - let source = format!("before\n{old}\n{versions}\n{classification}\nafter\n"); - - let (status, patched) = run_fixture_patch(&source); - assert!(status.success()); - assert!(patched.contains(replacement)); - assert!(patched.contains("process.env.MCP_CONFORMANCE_SERVER_ERA;")); - assert!(!patched.contains("?? 'dual'")); + // Reconstruct the patch's original hunks with filler between their line ranges. + let patch = fs::read_to_string(workspace_root().join("docker/mcp-conformance.patch")) + .expect("fixture patch"); + let mut original = Vec::new(); + for line in patch.lines() { + if line.starts_with("@@") { + let start = line + .split_whitespace() + .nth(1) + .expect("old range") + .trim_start_matches('-') + .split(',') + .next() + .expect("start") + .parse::() + .expect("line number"); + original.resize(start - 1, "// fixture context"); + } else if !line.starts_with("---") + && let Some(text) = line + .strip_prefix(' ') + .or_else(|| line.strip_prefix('-')) + .or_else(|| line.is_empty().then_some("")) + { + original.push(text); + } + } + let source = format!("{}\n", original.join("\n")); + let (output, patched) = run_fixture_patch(&source); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(patched.contains("createMcpExpressApp({ allowedHosts:")); assert!(patched.contains("CONFORMANCE_SERVER_ERA === 'legacy' && isModernEraRequest")); assert!(patched.contains("CONFORMANCE_SERVER_ERA === 'modern'")); - assert!(!patched.contains(old)); - for missing in [old, versions, classification] { - let unchanged = source.replacen(missing, "missing patch target", 1); - let (status, contents) = run_fixture_patch(&unchanged); - assert!(!status.success()); - assert_eq!(contents, unchanged); - } + let unchanged = source.replace( + "const app = createMcpExpressApp();", + "changed upstream host setup", + ); + let (output, contents) = run_fixture_patch(&unchanged); + assert!(!output.status.success()); + assert_eq!(contents, unchanged); } #[test] diff --git a/src/infrastructure/process.rs b/src/infrastructure/process.rs index 28022f3..86de5b4 100644 --- a/src/infrastructure/process.rs +++ b/src/infrastructure/process.rs @@ -333,18 +333,12 @@ impl ProcessRunner for SystemProcessRunner { &'a self, spec: &'a CommandSpec, ) -> Pin> + 'a>> { - Box::pin(async move { - let mut command = tokio::process::Command::from(command(spec)); - command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); - let mut child = command - .spawn() - .with_context(|| operation_context("spawn", spec))?; - let status = child - .wait() - .await - .with_context(|| operation_context("wait for", spec))?; - require_success(spec, status) - }) + Box::pin(run_async_process( + spec, + Stdio::inherit(), + Stdio::inherit(), + None, + )) } fn run_async_to_log<'a>( @@ -353,57 +347,28 @@ impl ProcessRunner for SystemProcessRunner { log_path: &'a Path, ) -> Pin> + 'a>> { Box::pin(async move { - let (log, stderr_log) = log_handles(log_path, spec)?; - let mut command = tokio::process::Command::from(command(spec)); - command - .stdout(Stdio::from(log)) - .stderr(Stdio::from(stderr_log)); - let mut child = command - .spawn() - .with_context(|| operation_context("spawn", spec))?; - let status = child - .wait() - .await - .with_context(|| operation_context("wait for", spec))?; - require_success(spec, status) + let (stdout, stderr) = log_handles(log_path, spec)?; + run_async_process(spec, stdout.into(), stderr.into(), None).await }) } fn run_async_cancellable<'a>( &'a self, spec: &'a CommandSpec, - mut cancellation: tokio::sync::watch::Receiver, + cancellation: tokio::sync::watch::Receiver, ) -> Pin> + 'a>> { - Box::pin(async move { - let mut command = tokio::process::Command::from(command(spec)); - command - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .kill_on_drop(true); - let mut child = command - .spawn() - .with_context(|| operation_context("spawn", spec))?; - tokio::select! { - status = child.wait() => { - let status = status.with_context(|| operation_context("wait for", spec))?; - require_success(spec, status) - } - () = wait_for_cancellation(&mut cancellation) => { - let _ = child.start_kill(); - child - .wait() - .await - .with_context(|| operation_context("reap cancelled", spec))?; - Err(cancelled_failure(spec)) - } - } - }) + Box::pin(run_async_process( + spec, + Stdio::inherit(), + Stdio::inherit(), + Some(cancellation), + )) } fn run_async_cancellable_to_log<'a>( &'a self, spec: &'a CommandSpec, - mut cancellation: tokio::sync::watch::Receiver, + cancellation: tokio::sync::watch::Receiver, log_path: &'a Path, ) -> Pin> + 'a>> { Box::pin(async move { @@ -412,28 +377,7 @@ impl ProcessRunner for SystemProcessRunner { let stderr_log = log .try_clone() .with_context(|| log_context("clone handle for", log_path, spec))?; - let mut command = tokio::process::Command::from(command(spec)); - command - .stdout(Stdio::from(log)) - .stderr(Stdio::from(stderr_log)) - .kill_on_drop(true); - let mut child = command - .spawn() - .with_context(|| operation_context("spawn", spec))?; - tokio::select! { - status = child.wait() => { - let status = status.with_context(|| operation_context("wait for", spec))?; - require_success(spec, status) - } - () = wait_for_cancellation(&mut cancellation) => { - let _ = child.start_kill(); - child - .wait() - .await - .with_context(|| operation_context("reap cancelled", spec))?; - Err(cancelled_failure(spec)) - } - } + run_async_process(spec, log.into(), stderr_log.into(), Some(cancellation)).await }) } @@ -500,6 +444,34 @@ impl ProcessRunner for SystemProcessRunner { } } +async fn run_async_process( + spec: &CommandSpec, + stdout: Stdio, + stderr: Stdio, + cancellation: Option>, +) -> Result<(), InfrastructureError> { + let mut child = tokio::process::Command::from(command(spec)) + .stdout(stdout) + .stderr(stderr) + .kill_on_drop(cancellation.is_some()) + .spawn() + .with_context(|| operation_context("spawn", spec))?; + let status = if let Some(mut cancellation) = cancellation { + tokio::select! { + status = child.wait() => status, + () = wait_for_cancellation(&mut cancellation) => { + let _ = child.start_kill(); + child.wait().await.with_context(|| operation_context("reap cancelled", spec))?; + return Err(cancelled_failure(spec)); + } + } + } else { + child.wait().await + } + .with_context(|| operation_context("wait for", spec))?; + require_success(spec, status) +} + fn log_handles(log_path: &Path, spec: &CommandSpec) -> Result<(File, File), InfrastructureError> { let log = OpenOptions::new() .create(true) diff --git a/src/infrastructure/process_integration_tests.rs b/src/infrastructure/process_integration_tests.rs index 70f732b..3a1f654 100644 --- a/src/infrastructure/process_integration_tests.rs +++ b/src/infrastructure/process_integration_tests.rs @@ -325,51 +325,56 @@ async fn async_runner_keeps_a_single_thread_executor_responsive() { #[cfg(unix)] #[tokio::test(flavor = "current_thread")] async fn cancellable_async_runner_kills_and_reaps_active_child() { - let directory = tempfile::tempdir().expect("temporary directory should be created"); - let pid_path = directory.path().join("child.pid"); - let script = shell_script( - &directory, - "long-lived.sh", - "printf '%s' \"$$\" > \"$PROCESS_PID_FILE\"\nexec sleep 60", - ); - let spec = CommandSpec::new("/bin/sh") - .arg(script) - .env("PROCESS_PID_FILE", pid_path.as_os_str()); - let (cancellation_sender, cancellation_receiver) = tokio::sync::watch::channel(false); - let cancellation_pid_path = pid_path.clone(); - let cancel = tokio::spawn(async move { - for _ in 0..200 { - if cancellation_pid_path.is_file() { - cancellation_sender.send_replace(true); - return; + for logged in [false, true] { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let pid_path = directory.path().join("child.pid"); + let script = shell_script( + &directory, + "long-lived.sh", + "printf '%s' \"$$\" > \"$PROCESS_PID_FILE\"\nexec sleep 60", + ); + let spec = CommandSpec::new("/bin/sh") + .arg(script) + .env("PROCESS_PID_FILE", pid_path.as_os_str()); + let (cancellation_sender, cancellation_receiver) = tokio::sync::watch::channel(false); + let cancellation_pid_path = pid_path.clone(); + let cancel = tokio::spawn(async move { + for _ in 0..200 { + if cancellation_pid_path.is_file() { + cancellation_sender.send_replace(true); + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; } - tokio::time::sleep(Duration::from_millis(10)).await; - } - panic!("child did not publish its PID before cancellation deadline"); - }); + panic!("child did not publish its PID before cancellation deadline"); + }); - let error = tokio::time::timeout( - Duration::from_secs(5), - SystemProcessRunner.run_async_cancellable(&spec, cancellation_receiver), - ) - .await - .expect("cancellable child should return promptly") - .expect_err("cancellation should be reported"); - cancel.await.expect("cancellation task should join"); - - assert!(error.to_string().contains("cancelled and reaped")); - let pid = fs::read_to_string(&pid_path) - .expect("child PID should be recorded") - .parse::() - .expect("child PID should be numeric"); - let still_running = std::process::Command::new("/bin/kill") - .args(["-0", &pid.to_string()]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .expect("kill probe should execute") - .success(); - assert!(!still_running, "cancelled child {pid} must be gone"); + let log = directory.path().join("child.log"); + let process = if logged { + SystemProcessRunner.run_async_cancellable_to_log(&spec, cancellation_receiver, &log) + } else { + SystemProcessRunner.run_async_cancellable(&spec, cancellation_receiver) + }; + let error = tokio::time::timeout(Duration::from_secs(5), process) + .await + .expect("cancellable child should return promptly") + .expect_err("cancellation should be reported"); + cancel.await.expect("cancellation task should join"); + + assert!(error.to_string().contains("cancelled and reaped")); + let pid = fs::read_to_string(&pid_path) + .expect("child PID should be recorded") + .parse::() + .expect("child PID should be numeric"); + let still_running = std::process::Command::new("/bin/kill") + .args(["-0", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("kill probe should execute") + .success(); + assert!(!still_running, "cancelled child {pid} must be gone"); + } } #[cfg(unix)] diff --git a/src/infrastructure/stack.rs b/src/infrastructure/stack.rs index 298afc6..8248ac9 100644 --- a/src/infrastructure/stack.rs +++ b/src/infrastructure/stack.rs @@ -1,4 +1,4 @@ -//! Pure stack lifecycle decisions and Docker Compose command plans. +//! Stack lifecycle decisions and Docker Compose commands. use std::collections::BTreeMap; use std::ffi::OsString; @@ -150,127 +150,48 @@ pub(crate) enum CleanupKind { Reset, } -/// One immutable stack command. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct StackCommandPlan { - command: CommandSpec, -} - -impl StackCommandPlan { - /// Builds a mode-specific Compose `up` command. - #[must_use] - pub(crate) fn up( - project: ComposeProject, - mode: StackMode, - build: bool, - start_locust_ui: bool, - locust_workers: usize, - ) -> Self { - let mut arguments = vec![ - OsString::from("up"), - OsString::from("-d"), - OsString::from("--remove-orphans"), - ]; - if build { - arguments.push(OsString::from("--build")); - } - if mode == StackMode::Controlplane && start_locust_ui { - arguments.push(OsString::from("--scale")); - arguments.push(OsString::from(format!("locust_worker={locust_workers}"))); - } - Self { - command: project.command(arguments), - } - } - - /// Builds a Compose command that stops one service without removing it. - #[must_use] - #[cfg(test)] - pub(crate) fn stop_service(project: ComposeProject, service: &str) -> Self { - Self { - command: project.command(["stop", "--timeout", "5", service]), - } +/// Builds a mode-specific Compose `up` command. +pub(crate) fn stack_up_command( + project: ComposeProject, + mode: StackMode, + build: bool, + start_locust_ui: bool, + locust_workers: usize, +) -> CommandSpec { + let mut command = project.command(["up", "-d", "--remove-orphans"]); + if build { + command = command.arg("--build"); } - - /// Builds a Compose command that restarts one previously stopped service. - #[must_use] - #[cfg(test)] - pub(crate) fn start_service(project: ComposeProject, service: &str) -> Self { - Self { - command: project.command(["start", service]), - } - } - - /// Builds a Compose command that restarts one service without its dependencies. - #[must_use] - #[cfg(test)] - pub(crate) fn restart_service(project: ComposeProject, service: &str) -> Self { - Self { - command: project.command(["restart", "--timeout", "5", service]), - } - } - - /// Builds a Compose cleanup command. - #[must_use] - pub(crate) fn cleanup(project: ComposeProject, kind: CleanupKind) -> Self { - let mut arguments = vec![OsString::from("down")]; - if kind == CleanupKind::Reset { - arguments.push(OsString::from("--volumes")); - } - arguments.push(OsString::from("--remove-orphans")); - Self { - command: project.command(arguments), - } - } - - /// Builds a Compose service-status command. - #[must_use] - pub(crate) fn status(project: ComposeProject) -> Self { - Self { - command: project.command(["ps"]), - } + if mode == StackMode::Controlplane && start_locust_ui { + command = command.args(["--scale", &format!("locust_worker={locust_workers}")]); } + command +} - /// Builds a Compose log-follow command, translating the public control-plane service name. - #[must_use] - pub(crate) fn logs(project: ComposeProject, services: I) -> Self - where - I: IntoIterator, - { - let mut arguments = vec![OsString::from("logs"), OsString::from("-f")]; - arguments.extend(services.into_iter().map(compose_service_name)); - Self { - command: project.command(arguments), - } +pub(crate) fn stack_cleanup_command(project: ComposeProject, kind: CleanupKind) -> CommandSpec { + let mut command = project.command(["down"]); + if kind == CleanupKind::Reset { + command = command.arg("--volumes"); } + command.arg("--remove-orphans") +} - /// Builds a Compose rendered-config command. - #[must_use] - pub(crate) fn config(project: ComposeProject, mode: StackMode) -> Self { - let arguments = if mode == StackMode::Dataplane { - vec![ - OsString::from("--profile"), - OsString::from("testing"), - OsString::from("config"), - OsString::from("--no-interpolate"), - OsString::from("--no-env-resolution"), - ] - } else { - vec![ - OsString::from("config"), - OsString::from("--no-interpolate"), - OsString::from("--no-env-resolution"), - ] - }; - Self { - command: project.command(arguments), - } - } +/// Translates public display names to Compose service names. +pub(crate) fn stack_logs_command( + project: ComposeProject, + services: impl IntoIterator, +) -> CommandSpec { + project + .command(["logs", "-f"]) + .args(services.into_iter().map(compose_service_name)) +} - /// Returns the executable process specification. - pub(crate) fn command(&self) -> &CommandSpec { - &self.command +pub(crate) fn stack_config_command(project: ComposeProject, mode: StackMode) -> CommandSpec { + let mut command = project.command(std::iter::empty::<&str>()); + if mode == StackMode::Dataplane { + command = command.args(["--profile", "testing"]); } + command.args(["config", "--no-interpolate", "--no-env-resolution"]) } fn compose_service_name(service: OsString) -> OsString { diff --git a/src/infrastructure/stack_integration_tests.rs b/src/infrastructure/stack_integration_tests.rs index 2de678e..ca5048e 100644 --- a/src/infrastructure/stack_integration_tests.rs +++ b/src/infrastructure/stack_integration_tests.rs @@ -5,8 +5,9 @@ use std::path::Path; use cf_integration::infrastructure::StackMode; use cf_integration::infrastructure::compose::ComposeProject; use cf_integration::infrastructure::stack::{ - BuildInputs, BuildMode, CleanupKind, FreshnessSnapshot, ServiceSnapshot, StackCommandPlan, - StackFreshness, resolve_build, + BuildInputs, BuildMode, CleanupKind, FreshnessSnapshot, ServiceSnapshot, StackFreshness, + resolve_build, stack_cleanup_command, stack_config_command, stack_logs_command, + stack_up_command, }; fn project(mode: StackMode) -> ComposeProject { @@ -26,8 +27,8 @@ fn project(mode: StackMode) -> ComposeProject { } } -fn args(plan: StackCommandPlan) -> Vec { - plan.command().arguments().to_vec() +fn args(plan: crate::infrastructure::process::CommandSpec) -> Vec { + plan.arguments().to_vec() } fn ends_with(actual: &[OsString], expected: &[&str]) -> bool { @@ -117,14 +118,14 @@ fn dataplane_freshness_is_considered_only_for_source_mode() { #[test] fn dataplane_up_always_removes_orphans_and_optionally_builds() { - let without_build = args(StackCommandPlan::up( + let without_build = args(stack_up_command( project(StackMode::Dataplane), StackMode::Dataplane, false, false, 1, )); - let with_build = args(StackCommandPlan::up( + let with_build = args(stack_up_command( project(StackMode::Dataplane), StackMode::Dataplane, true, @@ -146,7 +147,7 @@ fn dataplane_up_always_removes_orphans_and_optionally_builds() { #[test] fn controlplane_up_does_not_activate_locust_profile_when_ui_is_disabled() { - let disabled = args(StackCommandPlan::up( + let disabled = args(stack_up_command( project(StackMode::Controlplane), StackMode::Controlplane, false, @@ -160,7 +161,7 @@ fn controlplane_up_does_not_activate_locust_profile_when_ui_is_disabled() { .all(|argument| !argument.to_string_lossy().starts_with("locust")) ); - let enabled = args(StackCommandPlan::up( + let enabled = args(stack_up_command( project(StackMode::Controlplane), StackMode::Controlplane, true, @@ -183,52 +184,27 @@ fn controlplane_up_does_not_activate_locust_profile_when_ui_is_disabled() { #[test] fn cleanup_status_logs_and_config_use_typed_compose_commands() { let dataplane_project = project(StackMode::Dataplane); - assert!(ends_with( - &args(StackCommandPlan::stop_service( - dataplane_project.clone(), - "gateway" - )), - &["stop", "--timeout", "5", "gateway"] - )); - assert!(ends_with( - &args(StackCommandPlan::start_service( - dataplane_project.clone(), - "gateway" - )), - &["start", "gateway"] - )); - assert!(ends_with( - &args(StackCommandPlan::restart_service( - dataplane_project.clone(), - "dataplane" - )), - &["restart", "--timeout", "5", "dataplane"] - )); - let down = StackCommandPlan::cleanup(dataplane_project.clone(), CleanupKind::Down); + let down = stack_cleanup_command(dataplane_project.clone(), CleanupKind::Down); assert!(ends_with( &args(down.clone()), &["down", "--remove-orphans"] )); assert!( !down - .command() .environment() .contains_key(OsStr::new("COMPOSE_PROGRESS")), "Compose must select interactive or plain progress from its actual terminal" ); assert!(ends_with( - &args(StackCommandPlan::cleanup( + &args(stack_cleanup_command( dataplane_project.clone(), CleanupKind::Reset )), &["down", "--volumes", "--remove-orphans"] )); + assert!(ends_with(&args(dataplane_project.command(["ps"])), &["ps"])); assert!(ends_with( - &args(StackCommandPlan::status(dataplane_project.clone())), - &["ps"] - )); - assert!(ends_with( - &args(StackCommandPlan::logs( + &args(stack_logs_command( dataplane_project.clone(), [ OsString::from("cf-controlplane"), @@ -281,7 +257,7 @@ fn cleanup_status_logs_and_config_use_typed_compose_commands() { ] )); assert!(ends_with( - &args(StackCommandPlan::config( + &args(stack_config_command( dataplane_project, StackMode::Dataplane, )), @@ -294,7 +270,7 @@ fn cleanup_status_logs_and_config_use_typed_compose_commands() { ] )); assert!(ends_with( - &args(StackCommandPlan::config( + &args(stack_config_command( project(StackMode::Controlplane), StackMode::Controlplane, )), @@ -419,14 +395,14 @@ fn revision_checks_are_conditional_on_image_source() { #[test] fn stack_up_preserves_compose_auto_progress_without_shell_fragments() { - let plan = StackCommandPlan::up( + let plan = stack_up_command( project(StackMode::Dataplane), StackMode::Dataplane, false, false, 1, ); - let command = plan.command(); + let command = plan; assert_eq!(command.program(), OsStr::new("docker")); assert!(command.environment().is_empty()); assert!( diff --git a/src/lib.rs b/src/lib.rs index f682938..f834f6e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ mod app; mod cli; mod conformance; mod error; +mod helpers; mod infrastructure; mod mcp; mod output; @@ -28,6 +29,18 @@ use runtime::RuntimeContext; /// Runs the CLI using the current process arguments and environment. pub async fn run() -> ExitCode { let arguments = std::env::args_os().collect::>(); + if arguments.get(1).is_some_and(|arg| arg == "__tool") { + return match helpers::tools::run(&arguments[1..]).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => report_failure(error), + }; + } + if arguments.get(1).is_some_and(|arg| arg == "__helper") { + return match helpers::run(&arguments[1..]).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => report_failure(AppFailure::from(error)), + }; + } if conformance::client::is_internal_client_invocation(&arguments) { return match conformance::client::run_internal_client(&arguments).await { Ok(()) => ExitCode::SUCCESS, diff --git a/src/mcp/auth_proxy.rs b/src/mcp/auth_proxy.rs index a16086c..311e154 100644 --- a/src/mcp/auth_proxy.rs +++ b/src/mcp/auth_proxy.rs @@ -19,7 +19,7 @@ use tokio::task::JoinHandle; use url::Url; use uuid::Uuid; -use crate::mcp::backend_identity::{BackendIdentity, is_dataplane_endpoint}; +use crate::mcp::backend_identity::BackendIdentity; const REDACTED: &str = ""; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); @@ -57,6 +57,7 @@ pub(crate) enum AuthProxyError { struct ProxyState { upstream: Url, + destination: Url, authorization: HeaderValue, proxy_path: String, loopback_authority: String, @@ -77,65 +78,16 @@ pub(crate) struct AuthProxy { } impl AuthProxy { - /// Starts a proxy for one fixed upstream and bearer token. - /// - /// The listener is always bound to `127.0.0.1` on an operating-system - /// selected port. Redirect following and environment HTTP proxies are - /// disabled, and HTTPS uses normal certificate validation. - /// - /// # Errors - /// - /// Returns an error if the upstream or token is invalid, the HTTP client - /// cannot be configured, or the loopback listener cannot be bound. - pub(crate) async fn start( - upstream: Url, - bearer_token: impl AsRef, - ) -> Result { - Self::start_with_protocol_version(upstream, bearer_token, None).await - } - - /// Starts a proxy for a routed endpoint backed by the built-in dataplane. - /// - /// Unlike [`Self::start`], this does not require the Rust data-plane - /// response marker merely because the endpoint uses `/servers/{id}/mcp`. - /// - /// # Errors - /// - /// Returns the same errors as [`Self::start`]. - pub(crate) async fn start_builtin_data_plane( - upstream: Url, - bearer_token: impl AsRef, - ) -> Result { - Self::start_configured(upstream, bearer_token, None, false).await - } - - /// Starts a proxy that also rewrites MCP initialize requests to one version. - /// - /// # Errors - /// - /// Returns the same errors as [`Self::start`]. - pub(crate) async fn start_with_protocol_version( - upstream: Url, - bearer_token: impl AsRef, - protocol_version: Option<&str>, - ) -> Result { - let require_dataplane_backend = is_dataplane_endpoint(&upstream); - Self::start_configured( - upstream, - bearer_token, - protocol_version, - require_dataplane_backend, - ) - .await - } - - async fn start_configured( + /// Routes over the container network while preserving the public authority. + pub(crate) async fn start_routed( upstream: Url, + destination: Url, bearer_token: impl AsRef, protocol_version: Option<&str>, require_dataplane_backend: bool, ) -> Result { validate_upstream(&upstream)?; + validate_upstream(&destination)?; let mut authorization = HeaderValue::from_str(&format!("Bearer {}", bearer_token.as_ref())) .map_err(|_| AuthProxyError::InvalidBearerToken)?; authorization.set_sensitive(true); @@ -158,6 +110,7 @@ impl AuthProxy { let state = Arc::new(ProxyState { require_dataplane_backend, upstream, + destination, authorization, proxy_path, loopback_authority, @@ -251,17 +204,20 @@ async fn forward(State(state): State>, request: Request) -> Resp .and_then(|value| value.to_str().ok()) .is_some_and(|authority| authority == state.loopback_authority) { - // A normal client addresses the loopback shim. Removing that Host lets - // reqwest synthesize the fixed upstream authority. Deliberately - // mutated Host values remain untouched for the rebinding scenario. - headers.remove(HOST); + // Preserve the public authority even when connecting over Docker DNS. + // Mutated Host values still reach the gateway's rebinding checks. + if let Ok(authority) = HeaderValue::from_str( + &state.upstream[url::Position::BeforeHost..url::Position::AfterPort], + ) { + headers.insert(HOST, authority); + } } rewrite_loopback_origin(&mut headers, &state.loopback_authority, &state.upstream); headers.insert(AUTHORIZATION, state.authorization.clone()); let upstream_response = match state .client - .request(parts.method, state.upstream.clone()) + .request(parts.method, state.destination.clone()) .headers(headers) .body(body) .send() diff --git a/src/mcp/auth_proxy_integration_tests.rs b/src/mcp/auth_proxy_integration_tests.rs index c7ecb59..e388df7 100644 --- a/src/mcp/auth_proxy_integration_tests.rs +++ b/src/mcp/auth_proxy_integration_tests.rs @@ -20,6 +20,14 @@ const INBOUND_TOKEN: &str = "must-not-reach-upstream"; const INJECTED_TOKEN: &str = "injected-secret-token"; const SENSITIVE_UPSTREAM_PATH: &str = "private/session-sensitive/mcp"; +async fn start_proxy( + upstream: Url, + token: &str, +) -> Result { + let require_dataplane = cf_integration::mcp::backend_identity::is_dataplane_endpoint(&upstream); + AuthProxy::start_routed(upstream.clone(), upstream, token, None, require_dataplane).await +} + #[derive(Clone, Debug)] struct CapturedRequest { method: Method, @@ -126,10 +134,55 @@ fn client() -> Client { .expect("test client should build") } +#[tokio::test] +async fn container_routing_preserves_public_authority_and_rebinding_probes() { + let (upstream, capture) = start_capture_server().await; + let public = Url::parse(&format!( + "https://gateway.example:8443/{SENSITIVE_UPSTREAM_PATH}" + )) + .expect("public URL"); + let proxy = AuthProxy::start_routed(public, upstream.url.clone(), INJECTED_TOKEN, None, false) + .await + .expect("routed proxy"); + for mutated in [false, true] { + let mut request = client().post(proxy.url().clone()).header( + "origin", + if mutated { + "https://attacker.example".to_owned() + } else { + proxy.url().origin().ascii_serialization() + }, + ); + if mutated { + request = request.header(HOST, "attacker.example"); + } + assert_eq!( + request.send().await.expect("forwarded request").status(), + StatusCode::CREATED + ); + } + let requests = capture.take(); + assert_eq!(requests[0].headers[HOST], "gateway.example:8443"); + assert_eq!( + requests[0].headers["origin"], + "https://gateway.example:8443" + ); + assert_eq!(requests[1].headers[HOST], "attacker.example"); + assert_eq!(requests[1].headers["origin"], "https://attacker.example"); + for request in requests { + assert_eq!( + request.headers[AUTHORIZATION], + format!("Bearer {INJECTED_TOKEN}") + ); + } + proxy.shutdown().await.expect("stop proxy"); + upstream.shutdown().await; +} + #[tokio::test] async fn injects_auth_and_preserves_mcp_request_and_response_contract() { let (upstream, capture) = start_capture_server().await; - let proxy = AuthProxy::start(upstream.url.clone(), INJECTED_TOKEN) + let proxy = start_proxy(upstream.url.clone(), INJECTED_TOKEN) .await .expect("proxy should start"); @@ -232,10 +285,12 @@ async fn injects_auth_and_preserves_mcp_request_and_response_contract() { #[tokio::test] async fn selected_protocol_version_rewrites_only_the_initialize_payload() { let (upstream, capture) = start_capture_server().await; - let proxy = AuthProxy::start_with_protocol_version( + let proxy = AuthProxy::start_routed( + upstream.url.clone(), upstream.url.clone(), INJECTED_TOKEN, Some("2025-06-18"), + false, ) .await .expect("proxy should start"); @@ -264,7 +319,7 @@ async fn selected_protocol_version_rewrites_only_the_initialize_payload() { #[tokio::test] async fn forwards_host_and_origin_unchanged_for_gateway_dns_rebinding_checks() { let (upstream, capture) = start_capture_server().await; - let proxy = AuthProxy::start(upstream.url.clone(), INJECTED_TOKEN) + let proxy = start_proxy(upstream.url.clone(), INJECTED_TOKEN) .await .expect("proxy should start"); @@ -296,7 +351,7 @@ async fn forwards_host_and_origin_unchanged_for_gateway_dns_rebinding_checks() { #[tokio::test] async fn rejects_wrong_path_query_and_unsupported_methods() { let (upstream, capture) = start_capture_server().await; - let proxy = AuthProxy::start(upstream.url.clone(), INJECTED_TOKEN) + let proxy = start_proxy(upstream.url.clone(), INJECTED_TOKEN) .await .expect("proxy should start"); let client = client(); @@ -355,7 +410,7 @@ async fn supports_delete_and_does_not_follow_upstream_redirects() { let upstream = TestServer::start(Router::new().route("/redirect", any(redirect)), "redirect").await; - let proxy = AuthProxy::start(upstream.url.clone(), INJECTED_TOKEN) + let proxy = start_proxy(upstream.url.clone(), INJECTED_TOKEN) .await .expect("proxy should start"); @@ -406,7 +461,7 @@ async fn streams_sse_response_chunks_without_buffering() { } let upstream = TestServer::start(Router::new().route("/sse", any(sse)), "sse").await; - let proxy = AuthProxy::start(upstream.url.clone(), INJECTED_TOKEN) + let proxy = start_proxy(upstream.url.clone(), INJECTED_TOKEN) .await .expect("proxy should start"); @@ -439,7 +494,7 @@ async fn streams_sse_response_chunks_without_buffering() { #[tokio::test] async fn caps_buffered_request_bodies_before_forwarding() { let (upstream, capture) = start_capture_server().await; - let proxy = AuthProxy::start(upstream.url.clone(), INJECTED_TOKEN) + let proxy = start_proxy(upstream.url.clone(), INJECTED_TOKEN) .await .expect("proxy should start"); @@ -459,10 +514,10 @@ async fn caps_buffered_request_bodies_before_forwarding() { #[tokio::test] async fn endpoint_is_unguessable_and_debug_and_errors_do_not_leak_secrets() { let (upstream, _capture) = start_capture_server().await; - let proxy_a = AuthProxy::start(upstream.url.clone(), INJECTED_TOKEN) + let proxy_a = start_proxy(upstream.url.clone(), INJECTED_TOKEN) .await .expect("first proxy should start"); - let proxy_b = AuthProxy::start(upstream.url.clone(), INJECTED_TOKEN) + let proxy_b = start_proxy(upstream.url.clone(), INJECTED_TOKEN) .await .expect("second proxy should start"); @@ -481,7 +536,7 @@ async fn endpoint_is_unguessable_and_debug_and_errors_do_not_leak_secrets() { assert!(debug.contains("")); let invalid_token = "sensitive-token\ninvalid"; - let error = AuthProxy::start(upstream.url.clone(), invalid_token) + let error = start_proxy(upstream.url.clone(), invalid_token) .await .expect_err("invalid Authorization header should be rejected"); let display = error.to_string(); @@ -497,7 +552,7 @@ async fn endpoint_is_unguessable_and_debug_and_errors_do_not_leak_secrets() { #[tokio::test] async fn shutdown_stops_accepting_connections() { let (upstream, _capture) = start_capture_server().await; - let proxy = AuthProxy::start(upstream.url.clone(), INJECTED_TOKEN) + let proxy = start_proxy(upstream.url.clone(), INJECTED_TOKEN) .await .expect("proxy should start"); let endpoint = proxy.url().clone(); @@ -538,7 +593,7 @@ async fn dataplane_proxy_requires_one_exact_backend_marker_before_forwarding() { "servers/test/mcp", ) .await; - let proxy = AuthProxy::start(accepted.url.clone(), INJECTED_TOKEN) + let proxy = start_proxy(accepted.url.clone(), INJECTED_TOKEN) .await .expect("dataplane proxy should start"); let response = client() @@ -567,7 +622,7 @@ async fn dataplane_proxy_requires_one_exact_backend_marker_before_forwarding() { "servers/test/mcp", ) .await; - let proxy = AuthProxy::start(upstream.url.clone(), INJECTED_TOKEN) + let proxy = start_proxy(upstream.url.clone(), INJECTED_TOKEN) .await .expect("dataplane proxy should start"); @@ -603,9 +658,15 @@ async fn builtin_data_plane_proxy_allows_a_routed_controlplane_response() { "servers/test/mcp", ) .await; - let proxy = AuthProxy::start_builtin_data_plane(upstream.url.clone(), INJECTED_TOKEN) - .await - .expect("built-in dataplane proxy should start"); + let proxy = AuthProxy::start_routed( + upstream.url.clone(), + upstream.url.clone(), + INJECTED_TOKEN, + None, + false, + ) + .await + .expect("built-in dataplane proxy should start"); let response = client() .get(proxy.url().clone()) diff --git a/src/mcp/gateway.rs b/src/mcp/gateway.rs index bca0ef1..2874579 100644 --- a/src/mcp/gateway.rs +++ b/src/mcp/gateway.rs @@ -4,9 +4,6 @@ use std::collections::BTreeMap; use std::fmt; use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; -use reqwest::{Method, StatusCode}; -#[cfg(test)] -use serde_json::Map; use serde_json::Value; use thiserror::Error; use url::Url; @@ -17,8 +14,6 @@ use crate::mcp::backend_identity::{BACKEND_HEADER, BackendIdentity, sanitized_ba use crate::mcp::protocol::{ ACCEPT as MCP_ACCEPT, PROTOCOL_VERSION, is_stateless_protocol, parse_mcp_body, routing_name, }; -#[cfg(test)] -use crate::mcp::protocol::{initialize_with_id_and_version, jsonrpc_with_id}; /// Default MCP protocol version used in request bodies and HTTP headers. pub(crate) const DEFAULT_PROTOCOL_VERSION: &str = PROTOCOL_VERSION; @@ -28,7 +23,7 @@ pub(crate) const MCP_PROTOCOL_VERSION: &str = "mcp-protocol-version"; pub(crate) const MCP_SESSION_ID: &str = "mcp-session-id"; const JSON_CONTENT_TYPE: &str = "application/json"; -const SSE_ACCEPT: &str = "text/event-stream"; +const SSE_CONTENT_TYPE: &str = "text/event-stream"; const REDACTED: &str = ""; /// Maximum response body buffered by the MCP client. pub(crate) const MAX_RESPONSE_BODY_BYTES: usize = 8 * 1024 * 1024; @@ -59,166 +54,38 @@ impl fmt::Debug for HeaderOverride { } } -#[derive(Clone, Debug, PartialEq)] -enum Payload { - #[cfg(test)] - Initialize { - id: Value, - }, - Json(Value), - #[cfg(test)] - Raw(Vec), - #[cfg(test)] - None, -} - -#[derive(Clone, PartialEq)] -enum ResponseExpectation { - #[cfg(test)] - JsonRpc { - id: Value, - }, - #[cfg(test)] - NotificationAccepted, - Unchecked, -} - -impl fmt::Debug for ResponseExpectation { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - #[cfg(test)] - Self::JsonRpc { .. } => formatter.write_str("JsonRpc { id: }"), - #[cfg(test)] - Self::NotificationAccepted => formatter.write_str("NotificationAccepted"), - Self::Unchecked => formatter.write_str("Unchecked"), - } - } -} - /// One protected HTTP exchange to issue against the public MCP endpoint. #[derive(Clone, PartialEq)] pub(crate) struct GatewayRequest { - method: Method, - payload: Payload, + payload: Value, authorization: HeaderOverride, protocol_version: HeaderOverride, session: HeaderOverride, - expectation: ResponseExpectation, } impl fmt::Debug for GatewayRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - let payload = match &self.payload { - #[cfg(test)] - Payload::Initialize { .. } => "initialize", - Payload::Json(_) => "json:", - #[cfg(test)] - Payload::Raw(_) => "raw:", - #[cfg(test)] - Payload::None => "none", - }; formatter .debug_struct("GatewayRequest") - .field("method", &self.method) - .field("payload", &payload) + .field("payload", &REDACTED) .field("authorization", &self.authorization) .field("protocol_version", &self.protocol_version) .field("session", &self.session) - .field("expectation", &self.expectation) .finish() } } impl GatewayRequest { - /// Builds an MCP initialize request. - #[must_use] - #[cfg(test)] - pub(crate) fn initialize(id: Value) -> Self { - let mut request = Self::post( - Payload::Initialize { id: id.clone() }, - ResponseExpectation::JsonRpc { id }, - ); - // The negotiated version header is required on subsequent HTTP - // requests, not on the initialize request that establishes it. - request.protocol_version = HeaderOverride::Omit; - request - } - - /// Builds the required `notifications/initialized` notification. - #[must_use] - #[cfg(test)] - pub(crate) fn initialized() -> Self { - Self::notification("notifications/initialized", None) - } - - /// Builds a generic JSON-RPC request whose response must match `id`. - #[must_use] - #[cfg(test)] - pub(crate) fn request(method: &str, params: Option, id: Value) -> Self { - Self::post( - Payload::Json(jsonrpc_with_id(method, params, id.clone())), - ResponseExpectation::JsonRpc { id }, - ) - } - - /// Builds a generic JSON-RPC notification. - #[must_use] - #[cfg(test)] - pub(crate) fn notification(method: &str, params: Option) -> Self { - Self::post( - Payload::Json(notification_message(method, params)), - ResponseExpectation::NotificationAccepted, - ) - } - - /// Builds an unchecked streamable-HTTP GET request. - /// - /// The response body is intentionally not consumed because a successful - /// Streamable HTTP GET can remain open indefinitely. The returned exchange - /// contains the status and headers with an empty body. - #[must_use] - #[cfg(test)] - pub(crate) fn get() -> Self { - Self { - method: Method::GET, - payload: Payload::None, - authorization: HeaderOverride::Automatic, - protocol_version: HeaderOverride::Automatic, - session: HeaderOverride::Automatic, - expectation: ResponseExpectation::Unchecked, - } - } - - /// Builds an unchecked streamable-HTTP DELETE request. - #[must_use] - #[cfg(test)] - pub(crate) fn delete() -> Self { + /// Builds a JSON POST whose protocol outcome is checked by the workflow. + pub(crate) fn probe(payload: Value) -> Self { Self { - method: Method::DELETE, - payload: Payload::None, + payload, authorization: HeaderOverride::Automatic, protocol_version: HeaderOverride::Automatic, session: HeaderOverride::Automatic, - expectation: ResponseExpectation::Unchecked, } } - /// Builds an unchecked JSON POST with an arbitrary, potentially malformed body. - #[must_use] - #[cfg(test)] - pub(crate) fn raw_post(body: impl AsRef<[u8]>) -> Self { - Self::post( - Payload::Raw(body.as_ref().to_vec()), - ResponseExpectation::Unchecked, - ) - } - - /// Builds an unchecked JSON POST for workflow-level validation. - #[must_use] - pub(crate) fn probe(payload: Value) -> Self { - Self::post(Payload::Json(payload), ResponseExpectation::Unchecked) - } - /// Overrides or omits the configured authorization header. #[must_use] pub(crate) fn authorization(mut self, authorization: HeaderOverride) -> Self { @@ -239,30 +106,6 @@ impl GatewayRequest { self.session = session; self } - - fn post(payload: Payload, expectation: ResponseExpectation) -> Self { - Self { - method: Method::POST, - payload, - authorization: HeaderOverride::Automatic, - protocol_version: HeaderOverride::Automatic, - session: HeaderOverride::Automatic, - expectation, - } - } -} - -/// Builds a JSON-RPC 2.0 notification without an `id` member. -#[must_use] -#[cfg(test)] -pub(crate) fn notification_message(method: &str, params: Option) -> Value { - let mut payload = Map::new(); - payload.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned())); - payload.insert("method".to_owned(), Value::String(method.to_owned())); - if let Some(params) = params { - payload.insert("params".to_owned(), params); - } - Value::Object(payload) } /// Safe diagnostic snapshot of an outbound HTTP request. @@ -352,8 +195,7 @@ impl Exchange { &self.headers } - /// Sanitized response body. This is empty for GET requests because an SSE - /// stream can remain open indefinitely and is not consumed by this client. + /// Sanitized response body. #[must_use] pub(crate) fn body(&self) -> &str { &self.body @@ -524,20 +366,10 @@ impl GatewayClient { self.session_id.as_deref() } - /// Sends and validates one protected gateway request. - /// - /// JSON-RPC request builders require a successful HTTP response and validate - /// the version, ID, and result/error shape. Notification builders require an - /// empty `202 Accepted` response. GET, DELETE, raw, and explicitly unchecked - /// requests return any HTTP status for scenario-level assertions. - /// - /// # Errors - /// - /// Returns a mode-aware error with a safe request or full exchange capture - /// for header, transport, body parsing, status, or JSON-RPC failures. + /// Sends an MCP POST, checking backend identity and parsing JSON/SSE responses. + /// HTTP status and JSON-RPC semantics are evaluated by the calling workflow. pub(crate) async fn send(&mut self, request: GatewayRequest) -> Result { - let body = materialize_payload(self.mode, &request.payload, &self.protocol_version)?; - let outbound = self.build_request(&request, body.as_deref())?; + let outbound = self.build_request(&request)?; let outbound_session = outbound .headers() .get(MCP_SESSION_ID) @@ -578,28 +410,6 @@ impl GatewayClient { }; return Err(GatewayError::with_exchange(self.mode, message, exchange)); } - if request.method == Method::GET { - let session_id = session_result.as_ref().ok().cloned().flatten(); - let exchange = Exchange { - mode: self.mode, - request: request_capture, - status: status.as_u16(), - headers: capture_headers(&raw_headers, &response_secrets), - body: String::new(), - message: None, - session_id, - }; - if let Err(message) = session_result { - return Err(GatewayError::with_exchange(self.mode, message, exchange)); - } - self.validate_exchange(&request.expectation, status, Ok(None), &exchange)?; - if status.is_success() - && let Some(session_id) = exchange.session_id.as_ref() - { - self.session_id = Some(session_id.clone()); - } - return Ok(exchange); - } let raw_body = match bounded_response_body(&mut response).await { Ok(body) => body, Err(error) => { @@ -639,7 +449,11 @@ impl GatewayClient { return Err(GatewayError::with_exchange(self.mode, message, exchange)); } - self.validate_exchange(&request.expectation, status, parsed, &exchange)?; + if status.is_success() + && let Err(message) = parsed + { + return Err(GatewayError::with_exchange(self.mode, message, exchange)); + } if status.is_success() && let Some(session_id) = exchange.session_id.as_ref() { @@ -648,22 +462,13 @@ impl GatewayClient { Ok(exchange) } - fn build_request( - &self, - request: &GatewayRequest, - body: Option<&[u8]>, - ) -> Result { + fn build_request(&self, request: &GatewayRequest) -> Result { let mut builder = self .http - .request(request.method.clone(), self.endpoint.clone()) - .header( - ACCEPT, - if request.method == Method::GET { - SSE_ACCEPT - } else { - MCP_ACCEPT - }, - ); + .post(self.endpoint.clone()) + .header(ACCEPT, MCP_ACCEPT) + .header(CONTENT_TYPE, JSON_CONTENT_TYPE) + .json(&request.payload); let automatic_authorization = format!("Bearer {}", self.bearer_token); builder = apply_header( self.mode, @@ -672,9 +477,6 @@ impl GatewayClient { &request.authorization, Some(&automatic_authorization), )?; - if request.method == Method::POST { - builder = builder.header(CONTENT_TYPE, JSON_CONTENT_TYPE); - } builder = apply_header( self.mode, builder, @@ -682,20 +484,17 @@ impl GatewayClient { &request.protocol_version, Some(&self.protocol_version), )?; - if request.method == Method::POST { - let protocol_version = match &request.protocol_version { - HeaderOverride::Automatic => Some(self.protocol_version.as_str()), - HeaderOverride::Omit => None, - HeaderOverride::Value(value) => Some(value.as_str()), - }; - if protocol_version.is_some_and(is_stateless_protocol) - && let Payload::Json(payload) = &request.payload - && let Some(method) = payload.get("method").and_then(Value::as_str) - { - builder = apply_literal_header(self.mode, builder, "mcp-method", method)?; - if let Some(name) = routing_name(method, payload.get("params")) { - builder = apply_literal_header(self.mode, builder, "mcp-name", name)?; - } + let protocol_version = match &request.protocol_version { + HeaderOverride::Automatic => Some(self.protocol_version.as_str()), + HeaderOverride::Omit => None, + HeaderOverride::Value(value) => Some(value.as_str()), + }; + if protocol_version.is_some_and(is_stateless_protocol) + && let Some(method) = request.payload.get("method").and_then(Value::as_str) + { + builder = apply_literal_header(self.mode, builder, "mcp-method", method)?; + if let Some(name) = routing_name(method, request.payload.get("params")) { + builder = apply_literal_header(self.mode, builder, "mcp-name", name)?; } } builder = apply_header( @@ -705,9 +504,6 @@ impl GatewayClient { &request.session, self.session_id.as_deref(), )?; - if let Some(body) = body { - builder = builder.body(body.to_vec()); - } builder.build().map_err(|error| { GatewayError::configuration( self.mode, @@ -715,76 +511,6 @@ impl GatewayClient { ) }) } - - fn validate_exchange( - &self, - expectation: &ResponseExpectation, - status: StatusCode, - parsed: Result, String>, - exchange: &Exchange, - ) -> Result<(), GatewayError> { - match expectation { - #[cfg(test)] - ResponseExpectation::JsonRpc { id } => { - if status != StatusCode::OK { - return Err(GatewayError::with_exchange( - self.mode, - format!( - "JSON-RPC response expected HTTP 200, got status {}", - status.as_u16() - ), - exchange.clone(), - )); - } - let message = parsed - .map_err(|message| { - GatewayError::with_exchange(self.mode, message, exchange.clone()) - })? - .ok_or_else(|| { - GatewayError::with_exchange( - self.mode, - "response did not contain a JSON or SSE message", - exchange.clone(), - ) - })?; - validate_jsonrpc_response(&message, id).map_err(|message| { - GatewayError::with_exchange(self.mode, message, exchange.clone()) - })?; - } - #[cfg(test)] - ResponseExpectation::NotificationAccepted => { - if status != StatusCode::ACCEPTED { - return Err(GatewayError::with_exchange( - self.mode, - format!( - "notification expected status 202, got status {}", - status.as_u16() - ), - exchange.clone(), - )); - } - if !exchange.body.is_empty() { - return Err(GatewayError::with_exchange( - self.mode, - "notification response body must be empty", - exchange.clone(), - )); - } - } - ResponseExpectation::Unchecked => { - if status.is_success() - && let Err(message) = parsed - { - return Err(GatewayError::with_exchange( - self.mode, - message, - exchange.clone(), - )); - } - } - } - Ok(()) - } } fn apply_literal_header( @@ -846,27 +572,6 @@ pub(crate) enum GatewayError { } impl GatewayError { - /// Stack mode in which the failure occurred. - #[must_use] - #[cfg(test)] - pub(crate) fn mode(&self) -> GatewayTopology { - match self { - Self::Configuration { mode, .. } - | Self::Request { mode, .. } - | Self::Exchange { mode, .. } => *mode, - } - } - - /// Full exchange for response-time failures. - #[must_use] - #[cfg(test)] - pub(crate) fn exchange(&self) -> Option<&Exchange> { - match self { - Self::Exchange { exchange, .. } => Some(exchange), - Self::Configuration { .. } | Self::Request { .. } => None, - } - } - fn configuration(mode: GatewayTopology, message: impl Into) -> Self { Self::Configuration { mode, @@ -941,31 +646,6 @@ fn gateway_endpoint( Ok(endpoint) } -fn materialize_payload( - mode: GatewayTopology, - payload: &Payload, - _protocol_version: &str, -) -> Result>, GatewayError> { - let value = match payload { - #[cfg(test)] - Payload::Initialize { id } => Some(initialize_with_id_and_version( - id.clone(), - _protocol_version, - )), - Payload::Json(value) => Some(value.clone()), - #[cfg(test)] - Payload::Raw(body) => return Ok(Some(body.clone())), - #[cfg(test)] - Payload::None => None, - }; - value - .map(|value| { - serde_json::to_vec(&value) - .map_err(|_| GatewayError::configuration(mode, "failed to serialize JSON request")) - }) - .transpose() -} - fn apply_header( mode: GatewayTopology, mut builder: reqwest::RequestBuilder, @@ -1086,7 +766,7 @@ fn parse_response_body(body: &[u8], headers: &HeaderMap) -> Result .map_or(content_type, |(media_type, _)| media_type) .trim(); if !media_type.eq_ignore_ascii_case(JSON_CONTENT_TYPE) - && !media_type.eq_ignore_ascii_case(SSE_ACCEPT) + && !media_type.eq_ignore_ascii_case(SSE_CONTENT_TYPE) { return Ok(None); } @@ -1096,37 +776,6 @@ fn parse_response_body(body: &[u8], headers: &HeaderMap) -> Result .map_err(|_| "response body is not valid JSON or SSE".to_owned()) } -#[cfg(test)] -fn validate_jsonrpc_response(message: &Value, expected_id: &Value) -> Result<(), String> { - let object = message - .as_object() - .ok_or_else(|| "JSON-RPC response must be an object".to_owned())?; - if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { - return Err("invalid JSON-RPC version".to_owned()); - } - if object.get("id") != Some(expected_id) { - return Err("JSON-RPC response id does not match request id".to_owned()); - } - let has_result = object.contains_key("result"); - let has_error = object.contains_key("error"); - if has_result == has_error { - return Err("JSON-RPC response must contain exactly one of result or error".to_owned()); - } - if let Some(error) = object.get("error") { - let error = error - .as_object() - .ok_or_else(|| "JSON-RPC error object must be an object".to_owned())?; - if error.get("code").and_then(Value::as_i64).is_none() - || error.get("message").and_then(Value::as_str).is_none() - { - return Err( - "JSON-RPC error object must contain an integer code and string message".to_owned(), - ); - } - } - Ok(()) -} - fn redact_and_sanitize(value: &str, token: &str) -> String { redact_and_sanitize_secrets(value, &[token]) } diff --git a/src/mcp/gateway_integration_tests.rs b/src/mcp/gateway_integration_tests.rs index aea0f9e..8bcad81 100644 --- a/src/mcp/gateway_integration_tests.rs +++ b/src/mcp/gateway_integration_tests.rs @@ -1,10 +1,9 @@ use std::collections::{BTreeMap, VecDeque}; -use std::convert::Infallible; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use crate::mcp::protocol::{initialize_with_id_and_version, jsonrpc_with_id}; use axum::Router; -use axum::body::{Body, Bytes, to_bytes}; +use axum::body::{Body, to_bytes}; use axum::extract::State; use axum::http::{HeaderMap, HeaderName, HeaderValue, Request, Response, StatusCode}; use axum::routing::any; @@ -30,7 +29,6 @@ struct MockResponse { status: StatusCode, headers: Vec<(String, String)>, body: String, - open_stream: bool, } impl MockResponse { @@ -39,7 +37,6 @@ impl MockResponse { status, headers: vec![("content-type".to_owned(), "application/json".to_owned())], body: body.to_string(), - open_stream: false, } } @@ -51,16 +48,6 @@ impl MockResponse { "text/event-stream; charset=utf-8".to_owned(), )], body: format!("event: message\ndata: {body}\n\n"), - open_stream: false, - } - } - - fn open_sse() -> Self { - Self { - status: StatusCode::OK, - headers: vec![("content-type".to_owned(), "text/event-stream".to_owned())], - body: String::new(), - open_stream: true, } } @@ -69,7 +56,6 @@ impl MockResponse { status, headers: Vec::new(), body: String::new(), - open_stream: false, } } @@ -164,12 +150,7 @@ async fn mock_handler(State(state): State, request: Request) -> } fn response_from(spec: MockResponse) -> Response { - let body = if spec.open_stream { - Body::from_stream(tokio_stream::pending::>()) - } else { - Body::from(spec.body) - }; - let mut response = Response::new(body); + let mut response = Response::new(Body::from(spec.body)); *response.status_mut() = spec.status; for (name, value) in spec.headers { response.headers_mut().append( @@ -199,6 +180,15 @@ fn response(id: u64, result: Value) -> Value { json!({"jsonrpc": "2.0", "id": id, "result": result}) } +fn initialize(id: Value) -> GatewayRequest { + GatewayRequest::probe(initialize_with_id_and_version(id, DEFAULT_PROTOCOL_VERSION)) + .protocol_version(HeaderOverride::Omit) +} + +fn rpc(method: &str, params: Option, id: Value) -> GatewayRequest { + GatewayRequest::probe(jsonrpc_with_id(method, params, id)) +} + #[tokio::test] async fn initialize_uses_fixed_encoded_route_and_exact_required_headers() { let server = MockServer::start([MockResponse::json( @@ -217,7 +207,7 @@ async fn initialize_uses_fixed_encoded_route_and_exact_required_headers() { .expect("valid gateway client should build"); let exchange = client - .send(GatewayRequest::initialize(json!(1))) + .send(initialize(json!(1))) .await .expect("initialize should succeed"); @@ -261,7 +251,7 @@ async fn initialize_uses_fixed_encoded_route_and_exact_required_headers() { } #[tokio::test] -async fn response_session_is_propagated_and_notification_requires_202() { +async fn response_session_is_propagated_to_notifications() { let server = MockServer::start([ MockResponse::json(StatusCode::OK, response(7, json!({}))) .with_header(MCP_SESSION_ID, "from-response"), @@ -277,11 +267,13 @@ async fn response_session_is_propagated_and_notification_requires_202() { .expect("valid gateway client should build"); client - .send(GatewayRequest::initialize(json!(7))) + .send(initialize(json!(7))) .await .expect("initialize should succeed"); let notification = client - .send(GatewayRequest::initialized()) + .send(GatewayRequest::probe( + json!({"jsonrpc":"2.0", "method":"notifications/initialized"}), + )) .await .expect("initialized notification should receive 202"); @@ -297,30 +289,7 @@ async fn response_session_is_propagated_and_notification_requires_202() { } #[tokio::test] -async fn jsonrpc_response_requires_exact_http_200() { - let server = MockServer::start([MockResponse::json( - StatusCode::CREATED, - response(2, json!({"tools": []})), - )]) - .await; - let mut client = GatewayClient::new( - GatewayTopology::Direct, - &server.base_url, - "server", - "secret-token", - ) - .expect("valid gateway client should build"); - - let error = client - .send(GatewayRequest::request("tools/list", None, json!(2))) - .await - .expect_err("JSON-RPC over Streamable HTTP requires status 200"); - - assert!(error.to_string().contains("expected HTTP 200")); -} - -#[tokio::test] -async fn generic_requests_parse_json_and_sse_and_validate_ids() { +async fn post_requests_parse_json_and_sse() { let server = MockServer::start([ MockResponse::json(StatusCode::OK, response(2, json!({"tools": []}))).dataplane(), MockResponse::sse(response(3, json!({"resources": []}))).dataplane(), @@ -335,19 +304,11 @@ async fn generic_requests_parse_json_and_sse_and_validate_ids() { .expect("valid gateway client should build"); let json_exchange = client - .send(GatewayRequest::request( - "tools/list", - Some(json!({})), - json!(2), - )) + .send(rpc("tools/list", Some(json!({})), json!(2))) .await .expect("JSON response should validate"); let sse_exchange = client - .send(GatewayRequest::request( - "resources/list", - Some(json!({})), - json!(3), - )) + .send(rpc("resources/list", Some(json!({})), json!(3))) .await .expect("SSE response should validate"); @@ -370,7 +331,6 @@ async fn structured_json_suffix_is_not_accepted_as_mcp_json() { "application/problem+json".to_owned(), )], body: response(2, json!({"tools": []})).to_string(), - open_stream: false, }]) .await; let mut client = GatewayClient::new( @@ -381,20 +341,16 @@ async fn structured_json_suffix_is_not_accepted_as_mcp_json() { ) .expect("valid gateway client should build"); - let error = client - .send(GatewayRequest::request("tools/list", None, json!(2))) + let exchange = client + .send(rpc("tools/list", None, json!(2))) .await - .expect_err("MCP only permits exact JSON and event-stream media types"); - - assert!( - error - .to_string() - .contains("did not contain a JSON or SSE message") - ); + .expect("non-MCP media types remain available for workflow diagnostics"); + assert!(exchange.message().is_none()); + assert!(!exchange.body().is_empty()); } #[tokio::test] -async fn custom_protocol_version_changes_header_and_initialize_body() { +async fn custom_protocol_version_sets_the_default_request_header() { let server = MockServer::start([MockResponse::json( StatusCode::OK, response(1, json!({"protocolVersion": "2099-01-01"})), @@ -411,89 +367,12 @@ async fn custom_protocol_version_changes_header_and_initialize_body() { .expect("custom-version client should build"); client - .send(GatewayRequest::initialize(json!(1))) + .send(rpc("ping", None, json!(1))) .await .expect("custom-version initialize should succeed"); let request = &server.requests()[0]; - assert!(!request.headers.contains_key(MCP_PROTOCOL_VERSION)); - let payload: Value = serde_json::from_slice(&request.body).expect("body should be JSON"); - assert_eq!(payload["params"]["protocolVersion"], "2099-01-01"); -} - -#[tokio::test] -async fn get_delete_and_malformed_post_support_explicit_invalid_header_overrides() { - let server = MockServer::start([ - MockResponse::empty(StatusCode::METHOD_NOT_ALLOWED).dataplane(), - MockResponse::empty(StatusCode::OK).dataplane(), - MockResponse::json( - StatusCode::BAD_REQUEST, - json!({"error": "malformed request"}), - ) - .dataplane(), - ]) - .await; - let mut client = GatewayClient::new( - GatewayTopology::Dataplane, - &server.base_url, - "server", - "secret-token", - ) - .expect("valid gateway client should build"); - - let get = client - .send( - GatewayRequest::get() - .protocol_version(HeaderOverride::Value("invalid-version".to_owned())) - .session(HeaderOverride::Value("invalid-session".to_owned())), - ) - .await - .expect("unchecked GET should capture 405"); - let delete = client - .send(GatewayRequest::delete().session(HeaderOverride::Omit)) - .await - .expect("DELETE should be captured"); - let malformed = client - .send(GatewayRequest::raw_post(Bytes::from_static(b"{not-json"))) - .await - .expect("raw malformed request should capture server rejection"); - - assert_eq!(get.status(), 405); - assert_eq!(delete.status(), 200); - assert_eq!(malformed.status(), 400); - let requests = server.requests(); - assert_eq!(requests[0].method, "GET"); - assert_eq!(requests[0].headers[MCP_PROTOCOL_VERSION], "invalid-version"); - assert_eq!(requests[0].headers[MCP_SESSION_ID], "invalid-session"); - assert_eq!(requests[0].headers["accept"], "text/event-stream"); - assert!(!requests[1].headers.contains_key(MCP_SESSION_ID)); - assert_eq!(requests[2].method, "POST"); - assert_eq!(requests[2].headers["content-type"], "application/json"); - assert_eq!(requests[2].body, b"{not-json"); -} - -#[tokio::test] -async fn get_returns_after_headers_without_draining_an_open_sse_stream() { - let server = MockServer::start([MockResponse::open_sse().dataplane()]).await; - let mut client = GatewayClient::new( - GatewayTopology::Dataplane, - &server.base_url, - "server", - "secret-token", - ) - .expect("valid gateway client should build"); - - let exchange = tokio::time::timeout(Duration::from_secs(1), client.send(GatewayRequest::get())) - .await - .expect("GET must return after receiving SSE response headers") - .expect("open SSE GET should be captured"); - - assert_eq!(exchange.status(), 200); - assert_eq!(exchange.body(), ""); - assert_eq!( - exchange.headers().get("content-type").map(String::as_str), - Some("text/event-stream") - ); + assert_eq!(request.headers[MCP_PROTOCOL_VERSION], "2099-01-01"); } #[tokio::test] @@ -512,11 +391,13 @@ async fn absent_response_session_is_never_synthesized_or_sent() { .expect("valid gateway client should build"); client - .send(GatewayRequest::initialize(json!(1))) + .send(initialize(json!(1))) .await .expect("initialize should succeed without a session header"); client - .send(GatewayRequest::initialized()) + .send(GatewayRequest::probe( + json!({"jsonrpc":"2.0", "method":"notifications/initialized"}), + )) .await .expect("notification should succeed without a session header"); @@ -545,7 +426,7 @@ async fn assigned_session_must_be_nonempty_visible_ascii_without_spaces() { .expect("valid gateway client should build"); let error = client - .send(GatewayRequest::initialize(json!(1))) + .send(initialize(json!(1))) .await .expect_err("invalid response session IDs must be rejected"); @@ -561,7 +442,6 @@ async fn http_failure_diagnostics_capture_exchange_without_leaking_secrets_or_co status: StatusCode::INTERNAL_SERVER_ERROR, headers: vec![("x-debug".to_owned(), format!("leaked-{token}"))], body: format!("failed {token}\nnext\u{0007}"), - open_stream: false, } .dataplane()]) .await; @@ -573,18 +453,15 @@ async fn http_failure_diagnostics_capture_exchange_without_leaking_secrets_or_co ) .expect("valid gateway client should build"); - let error = client - .send(GatewayRequest::request("tools/list", None, json!(4))) + let exchange = client + .send(rpc("tools/list", None, json!(4))) .await - .expect_err("500 should fail a JSON-RPC request"); - let diagnostic = format!("{error}\n{error:?}"); - let exchange = error.exchange().expect("HTTP error should retain exchange"); - - assert_eq!(error.mode(), GatewayTopology::Dataplane); + .expect("HTTP errors are returned for workflow validation"); + let diagnostic = format!("{exchange:?}"); assert_eq!(exchange.status(), 500); assert_eq!(exchange.request().mode(), GatewayTopology::Dataplane); assert!(diagnostic.contains("Dataplane")); - assert!(diagnostic.contains("status 500")); + assert!(diagnostic.contains("status: 500")); assert!(diagnostic.contains("")); assert!(diagnostic.contains("\\n")); assert!(diagnostic.contains("\\u{0007}")); @@ -613,7 +490,6 @@ async fn failure_exchange_redacts_session_ids_reflected_in_response_bodies() { status: StatusCode::INTERNAL_SERVER_ERROR, headers: vec![("x-debug".to_owned(), format!("reflected {session}"))], body: format!("failed session {session}"), - open_stream: false, } .dataplane(), ]) @@ -626,16 +502,15 @@ async fn failure_exchange_redacts_session_ids_reflected_in_response_bodies() { ) .expect("valid gateway client should build"); client - .send(GatewayRequest::initialize(json!(1))) + .send(initialize(json!(1))) .await .expect("initialize should assign a session"); - let error = client - .send(GatewayRequest::request("tools/list", None, json!(2))) + let exchange = client + .send(rpc("tools/list", None, json!(2))) .await - .expect_err("500 should fail"); - let exchange = error.exchange().expect("failure should retain exchange"); - let diagnostic = format!("{error:?}"); + .expect("HTTP failure should retain its diagnostic capture"); + let diagnostic = format!("{exchange:?}"); assert!(!exchange.body().contains(session)); assert!(!exchange.headers()["x-debug"].contains(session)); @@ -643,49 +518,6 @@ async fn failure_exchange_redacts_session_ids_reflected_in_response_bodies() { assert!(exchange.body().contains("")); } -#[tokio::test] -async fn malformed_jsonrpc_version_id_and_error_shapes_are_rejected_with_exchange() { - let server = MockServer::start([ - MockResponse::json( - StatusCode::OK, - json!({"jsonrpc": "1.0", "id": 1, "result": {}}), - ), - MockResponse::json(StatusCode::OK, response(99, json!({}))), - MockResponse::json( - StatusCode::OK, - json!({"jsonrpc": "2.0", "id": 3, "error": {"code": "bad", "message": 7}}), - ), - MockResponse::json( - StatusCode::OK, - json!({"jsonrpc": "2.0", "id": 4, "result": {}, "error": {"code": -1, "message": "bad"}}), - ), - ]) - .await; - let mut client = GatewayClient::new( - GatewayTopology::Direct, - &server.base_url, - "server", - "secret-token", - ) - .expect("valid gateway client should build"); - - let cases = [ - (1_u64, "JSON-RPC version"), - (2, "response id"), - (3, "error object"), - (4, "exactly one of result or error"), - ]; - for (id, expected) in cases { - let error = client - .send(GatewayRequest::request("tools/list", None, json!(id))) - .await - .expect_err("malformed JSON-RPC response should fail"); - assert!(error.to_string().contains(expected), "{error}"); - assert!(error.exchange().is_some()); - assert_eq!(error.mode(), GatewayTopology::Direct); - } -} - #[tokio::test] async fn debug_output_redacts_bearer_token_session_and_payload() { let token = "debug-secret-token"; @@ -701,12 +533,13 @@ async fn debug_output_redacts_bearer_token_session_and_payload() { ) .expect("valid gateway client should build"); client - .send(GatewayRequest::initialize(json!(1))) + .send(initialize(json!(1))) .await .expect("initialize should succeed"); - let request = - GatewayRequest::notification("notifications/custom", Some(json!({"token": token}))) - .session(HeaderOverride::Value("debug-secret-session".to_owned())); + let request = GatewayRequest::probe( + json!({"jsonrpc":"2.0", "method":"notifications/custom", "params":{"token":token}}), + ) + .session(HeaderOverride::Value("debug-secret-session".to_owned())); let diagnostic = format!("{client:?}\n{request:?}"); @@ -757,7 +590,7 @@ async fn dataplane_rejects_absent_fallback_forged_and_duplicate_backend_markers( .expect("valid gateway client should build"); let error = client - .send(GatewayRequest::initialize(json!(1))) + .send(initialize(json!(1))) .await .expect_err("dataplane responses must carry one exact backend marker"); let diagnostic = format!("{error}\n{error:?}"); @@ -781,7 +614,7 @@ async fn controlplane_does_not_require_the_harness_only_backend_marker() { .expect("valid gateway client should build"); client - .send(GatewayRequest::initialize(json!(1))) + .send(initialize(json!(1))) .await .expect("stock controlplane nginx has no integration backend marker"); } diff --git a/src/performance/python_adapter_tests.rs b/src/performance/python_adapter_tests.rs index 00c0fa5..e52d5c4 100644 --- a/src/performance/python_adapter_tests.rs +++ b/src/performance/python_adapter_tests.rs @@ -20,26 +20,6 @@ fn workspace_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() } -#[test] -fn standalone_config_writer_has_valid_javascript_syntax() { - for script in [ - "conformance/write_dataplane_config.mjs", - "standalone/generate_auth_key.mjs", - ] { - let output = Command::new("node") - .arg("--check") - .arg(scripts_dir().join(script)) - .output() - .expect("Node standalone-helper syntax check should run"); - - assert!( - output.status.success(), - "standalone helper syntax check failed for {script}: {}", - String::from_utf8_lossy(&output.stderr) - ); - } -} - fn locust_stub() -> TempDir { let directory = tempfile::tempdir().expect("temporary Python stub should be created"); fs::write( @@ -556,137 +536,3 @@ assert response.successes == 1 and not response.failures String::from_utf8_lossy(&output.stderr) ); } - -#[test] -fn standalone_fixture_catalog_preserves_discovered_routes_and_schemas() { - let script = r#" -import assert from 'node:assert/strict'; -import { pathToFileURL } from 'node:url'; -const scriptPath = process.argv[1]; -process.argv[1] = undefined; -const { fixtureCatalog } = await import(pathToFileURL(scriptPath).href); -const schema = { type: 'object', properties: { value: { type: 'string', 'x-mcp-header': 'Value' } } }; -const calls = []; -globalThis.fetch = async (_, options) => { - const request = JSON.parse(options.body); - calls.push(request); - assert.equal(request.params._meta['io.modelcontextprotocol/protocolVersion'], '2026-07-28'); - assert.equal(options.headers['mcp-method'], request.method); - let result; - switch (request.method) { - case 'server/discover': result = {}; break; - case 'tools/list': result = request.params.cursor !== undefined - ? { tools: [{ name: 'new_diagnostic_tool', inputSchema: schema }], nextCursor: null } - : { tools: [{ name: 'first', inputSchema: {} }], nextCursor: '' }; break; - case 'resources/list': result = { resources: [{ uri: 'test://new-resource' }] }; break; - case 'resources/templates/list': result = { resourceTemplates: [{ uriTemplate: 'test://new/{id}' }] }; break; - case 'prompts/list': result = { prompts: [{ name: 'new_prompt' }] }; break; - default: assert.fail(request.method); - } - const message = JSON.stringify({ jsonrpc: '2.0', id: request.id, result }); - return new Response(request.params.cursor !== undefined ? `event: message\ndata: ${message}\n\n` : message, - { headers: { 'content-type': request.params.cursor !== undefined ? 'text/event-stream' : 'application/json' } }); -}; -const catalog = await fixtureCatalog('http://fixture/mcp', '2026-07-28'); -assert.deepEqual(catalog.tools, ['first', 'new_diagnostic_tool']); -assert.deepEqual(catalog.toolSchemas.new_diagnostic_tool, schema); -assert.deepEqual(catalog.resources, ['test://new-resource']); -assert.deepEqual(catalog.resourceTemplates, ['test://new/{id}']); -assert.deepEqual(catalog.prompts, ['new_prompt']); -assert.equal(calls.filter((r) => r.method === 'tools/list').length, 2); - -globalThis.fetch = async (_, options) => { - const request = JSON.parse(options.body); - return Response.json({ id: request.id, error: { code: -32603, message: 'fixture failed' } }); -}; -await assert.rejects(fixtureCatalog('http://fixture/mcp', '2026-07-28'), /successful result/); - -globalThis.fetch = async (_, options) => { - const request = JSON.parse(options.body); - return Response.json({ id: request.id, result: request.method === 'server/discover' ? {} : { - tools: [{ name: 'first', inputSchema: {} }], nextCursor: 'repeated', - }}); -}; -await assert.rejects(fixtureCatalog('http://fixture/mcp', '2026-07-28'), /repeated cursor/); - -const legacyMethods = []; -globalThis.fetch = async (_, options) => { - if (options.method === 'DELETE') { - assert.equal(options.headers['mcp-session-id'], 'legacy-session'); - legacyMethods.push('DELETE'); - return new Response(null, { status: 204 }); - } - const request = JSON.parse(options.body); - legacyMethods.push(request.method); - assert.equal(options.headers['mcp-method'], undefined); - assert.equal(request.params._meta, undefined); - let result; - if (request.method === 'initialize') { - assert.equal(options.headers['mcp-protocol-version'], undefined); - assert.equal(request.params.protocolVersion, '2025-11-25'); - result = { protocolVersion: '2025-11-25' }; - } else { - assert.equal(options.headers['mcp-protocol-version'], '2025-11-25'); - assert.equal(options.headers['mcp-session-id'], 'legacy-session'); - if (request.method === 'notifications/initialized') return new Response(null, { status: 202 }); - result = request.method === 'tools/list' ? { tools: [{ name: 'legacy_tool', inputSchema: schema }] } - : request.method === 'resources/list' ? { resources: [] } - : request.method === 'resources/templates/list' ? { resourceTemplates: [] } - : { prompts: [] }; - } - const message = JSON.stringify({ id: request.id, result }); - return new Response(`event: message\ndata:\n\nevent: message\ndata: ${message}\n\n`, { - headers: { 'content-type': 'text/event-stream', 'mcp-session-id': 'legacy-session' }, - }); -}; -const legacyCatalog = await fixtureCatalog('http://fixture/mcp', '2025-11-25'); -assert.deepEqual(legacyCatalog.tools, ['legacy_tool']); -assert.deepEqual(legacyCatalog.toolSchemas.legacy_tool, schema); -assert.deepEqual(legacyMethods, ['initialize', 'notifications/initialized', 'tools/list', - 'resources/list', 'resources/templates/list', 'prompts/list', 'DELETE']); -"#; - let output = Command::new("node") - .args(["--input-type=module", "--eval", script]) - .arg(scripts_dir().join("conformance/write_dataplane_config.mjs")) - .output() - .expect("Node fixture catalog test runs"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn client_config_writer_publishes_a_schema_for_each_scenario_tool() { - let script = r#" -import assert from 'node:assert/strict'; -import { pathToFileURL } from 'node:url'; -const scriptPath = process.argv[1]; -process.argv = ['node', scriptPath, 'client', 'scenario-server', 'http://fixture/mcp', - '2026-07-28', '["metadata_probe","add_numbers"]']; -process.env.MCP_CONFORMANCE_TOKEN = `header.${Buffer.from('{"sub":"scenario-user"}').toString('base64url')}.signature`; -let published = false; -globalThis.fetch = async (url, options) => { - assert.ok(url.endsWith('/userconfigs/scenario-user')); - assert.equal(options.method, 'POST'); - const host = JSON.parse(options.body).virtual_hosts['scenario-server']; - assert.deepEqual(Object.keys(host.tools), ['metadata_probe', 'add_numbers']); - assert.deepEqual(host.backends['conformance-backend'].tool_schemas, { metadata_probe: {}, add_numbers: {} }); - published = true; - return new Response(null, { status: 202 }); -}; -await import(pathToFileURL(scriptPath).href); -assert.ok(published); -"#; - let output = Command::new("node") - .args(["--input-type=module", "--eval", script]) - .arg(scripts_dir().join("conformance/write_dataplane_config.mjs")) - .output() - .expect("Node config writer test runs"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); -} diff --git a/src/runtime/conformance/mod.rs b/src/runtime/conformance/mod.rs index 326515f..bda07fc 100644 --- a/src/runtime/conformance/mod.rs +++ b/src/runtime/conformance/mod.rs @@ -357,9 +357,9 @@ impl RuntimeContext { Ok(outcome) if outcome.operational_failures.is_empty() ); let results = if run_completed { - executor.load_selected_conformance_results(&paths, &lanes) + load_selected_conformance_results(&paths, &lanes) } else { - executor.load_completed_conformance_results(&paths, &lanes) + load_completed_conformance_results(&paths, &lanes) }; let operational_failures = match run_result { Ok(outcome) => outcome.operational_failures, @@ -379,249 +379,95 @@ impl RuntimeContext { if !operational_failures.is_empty() { reported_failure = true; } - let server_operational_failures = operational_failures - .iter() - .filter(|failure| failure.direction == ConformanceDirection::Server) - .cloned() - .collect::>(); - let client_operational_failures = operational_failures - .iter() - .filter(|failure| failure.direction == ConformanceDirection::Client) - .cloned() - .collect::>(); - - if results.is_err() && !server_operational_failures.is_empty() { - println!( - "{}", - render_conformance_results( - &BTreeMap::new(), - (&client_version, server_era), - ConformanceDirection::Server, - ConformanceGateDisplay::new( - None, - &server_operational_failures, - bless, - ), - matrix_started.elapsed(), - OutputStyle::stdout(), - ) - ); - } - let evaluated = results.and_then(|results| { - if results.is_empty() { - if !server_operational_failures.is_empty() { - println!( - "{}", - render_conformance_results( - &results, - (&client_version, server_era), - ConformanceDirection::Server, - ConformanceGateDisplay::new( - None, - &server_operational_failures, - bless, - ), - matrix_started.elapsed(), - OutputStyle::stdout(), - ) - ); - } - return Ok(None); - } - let completed_lanes = results.keys().copied().collect::>(); - match evaluate_baselines( - &results, - &completed_lanes, - &baseline_root, - &client_version, - server_era, - bless, - ) { - Ok(evaluation) => { - println!( - "{}", - render_conformance_results( - &results, - (&client_version, server_era), - ConformanceDirection::Server, - ConformanceGateDisplay::new( - Some(&evaluation.comparisons), - &server_operational_failures, - bless, - ), - matrix_started.elapsed(), - OutputStyle::stdout(), - ) - ); - Ok(Some(evaluation)) - } + for (direction, results) in [ + (ConformanceDirection::Server, results), + ( + ConformanceDirection::Client, + load_completed_client_conformance_results(&paths), + ), + ] { + let direction_failures = operational_failures + .iter() + .filter(|failure| failure.direction == direction) + .cloned() + .collect::>(); + let results = match results { + Ok(results) => results, Err(error) => { - println!( - "{}", - render_conformance_results( - &results, - (&client_version, server_era), - ConformanceDirection::Server, - ConformanceGateDisplay::new( - None, - &server_operational_failures, - bless, - ), - matrix_started.elapsed(), - OutputStyle::stdout(), - ) - ); - Err(AppFailure::from(error)) - } - } - }); - match evaluated { - Ok(Some(evaluation)) => { - for comparison in &evaluation.comparisons { - let report = paths.baseline_report(comparison.lane); - if let Err(error) = write_baseline_report( - &report, - &client_version, - server_era, - comparison, - ) { - failures.push(format!( - "{} {} baseline report: {error}", - paths.identity(), - comparison.lane.slug() - )); - } - if !bless && !comparison.matches() { - failures.push(format!( - "{} {} baseline mismatch: unexpected={:?}; stale={:?}", - paths.identity(), - comparison.lane.slug(), - comparison.unexpected, - comparison.stale - )); - } + failures.push(format!( + "{} {direction} artifacts: {error}", + paths.identity() + )); + BTreeMap::new() } - updates.extend(evaluation.updates); - } - Ok(None) => {} - Err(error) => { - failures.push(format!("{} baseline gate: {error}", paths.identity())); - } - } - - match executor.load_completed_client_conformance_results(&paths) { - Err(error) => { - if !client_operational_failures.is_empty() { - println!( - "{}", - render_conformance_results( - &BTreeMap::new(), - (&client_version, server_era), - ConformanceDirection::Client, - ConformanceGateDisplay::new( - None, - &client_operational_failures, - bless, - ), - matrix_started.elapsed(), - OutputStyle::stdout(), - ) - ); - } - failures.push(format!("{} client artifacts: {error}", paths.identity())) - } - Ok(client_results) if !client_results.is_empty() => { - match evaluate_client_baselines( - &client_results, - &[SemanticLane::ExternalDataPlane], + }; + let evaluation = if results.is_empty() { + Ok(None) + } else { + evaluate_baselines( + direction, + &results, + &results.keys().copied().collect::>(), &baseline_root, &client_version, server_era, bless, - ) { - Ok(evaluation) => { - println!( - "{}", - render_conformance_results( - &client_results, - (&client_version, server_era), - ConformanceDirection::Client, - ConformanceGateDisplay::new( - Some(&evaluation.comparisons), - &client_operational_failures, - bless, - ), - matrix_started.elapsed(), - OutputStyle::stdout(), - ) - ); - for comparison in &evaluation.comparisons { - let report = paths.client_baseline_report(comparison.lane); - if let Err(error) = write_client_baseline_report( - &report, - &client_version, - server_era, - comparison, - ) { - failures.push(format!( - "{} client {} baseline report: {error}", - paths.identity(), - comparison.lane.slug() - )); - } - if !bless && !comparison.matches() { - failures.push(format!( - "{} client {} baseline mismatch: unexpected={:?}; stale={:?}", - paths.identity(), - comparison.lane.slug(), - comparison.unexpected, - comparison.stale - )); - } + ) + .map(Some) + }; + if !results.is_empty() || !direction_failures.is_empty() { + println!( + "{}", + render_conformance_results( + &results, + (&client_version, server_era), + direction, + ConformanceGateDisplay::new( + evaluation + .as_ref() + .ok() + .and_then(|value| value.as_ref()) + .map(|evaluation| evaluation.comparisons.as_slice()), + &direction_failures, + bless, + ), + matrix_started.elapsed(), + OutputStyle::stdout(), + ) + ); + } + match evaluation { + Ok(Some(evaluation)) => { + for comparison in &evaluation.comparisons { + let report = paths.baseline_report(direction, comparison.lane); + if let Err(error) = write_baseline_report( + direction, + &report, + &client_version, + server_era, + comparison, + ) { + failures.push(format!( + "{} {direction} {} baseline report: {error}", + paths.identity(), + comparison.lane.slug() + )); + } + if !bless && !comparison.matches() { + failures.push(format!( + "{} {direction} {} baseline mismatch: unexpected={:?}; stale={:?}", + paths.identity(), comparison.lane.slug(), + comparison.unexpected, comparison.stale + )); } - updates.extend(evaluation.updates); - } - Err(error) => { - println!( - "{}", - render_conformance_results( - &client_results, - (&client_version, server_era), - ConformanceDirection::Client, - ConformanceGateDisplay::new( - None, - &client_operational_failures, - bless, - ), - matrix_started.elapsed(), - OutputStyle::stdout(), - ) - ); - failures.push(format!( - "{} client baseline gate: {error}", - paths.identity() - )); } + updates.extend(evaluation.updates); } - } - Ok(client_results) => { - if !client_operational_failures.is_empty() { - println!( - "{}", - render_conformance_results( - &client_results, - (&client_version, server_era), - ConformanceDirection::Client, - ConformanceGateDisplay::new( - None, - &client_operational_failures, - bless, - ), - matrix_started.elapsed(), - OutputStyle::stdout(), - ) - ); - } + Ok(None) => {} + Err(error) => failures.push(format!( + "{} {direction} baseline gate: {error}", + paths.identity() + )), } } if !operational_failures.is_empty() { @@ -724,14 +570,17 @@ impl RuntimeContext { revision: OFFICIAL_CONFORMANCE_REVISION.to_owned(), server_id: OFFICIAL_CONFORMANCE_SERVER_ID.to_owned(), }; - let direct_run = DirectConformanceRun { + let direct_run = SemanticLaneRun { + target: SemanticLane::FixtureDirect, endpoint: &endpoint, + token: None, + standalone: false, spec_version, server_era, fixture: &metadata, cancellation: cancellation_receiver.clone(), }; - let direct = self.run_official_conformance_direct(&direct_run, paths); + let direct = self.run_official_conformance_target(&direct_run, paths); tokio::pin!(direct); tokio::select! { result = &mut direct => result.err(), @@ -929,6 +778,7 @@ impl RuntimeContext { self.run_official_conformance_mode( &OfficialConformanceRun { topology, + standalone: standalone_topology, server_id, token: &token.value, spec_version, @@ -1057,7 +907,7 @@ impl RuntimeContext { .iter() .all(|lane| lanes.contains(lane)) { - match self.write_comparison_from_artifacts( + match write_comparison_from_artifacts( paths, Some((spec_version, server_era, DEFAULT_CONFORMANCE_SUITE)), ) { @@ -1103,33 +953,20 @@ impl RuntimeContext { .map_err(AppFailure::from)? .endpoint() .clone(); - let proxy = match run.topology { - StackMode::Controlplane => { - AuthProxy::start_builtin_data_plane(endpoint, run.token).await - } - StackMode::Dataplane => AuthProxy::start(endpoint, run.token).await, - } - .context("failed to start the conformance authentication proxy") - .map_err(AppFailure::from)?; - let result = self - .run_official_conformance_target( - &SemanticLaneRun { - target, - endpoint: proxy.url(), - spec_version: run.spec_version, - server_era: run.server_era, - fixture: run.fixture, - cancellation: run.cancellation.clone(), - }, - paths, - ) - .await; - let shutdown = proxy - .shutdown() - .await - .context("failed to stop the conformance authentication proxy") - .map_err(AppFailure::from); - finish_with_cleanup(result.err(), shutdown) + self.run_official_conformance_target( + &SemanticLaneRun { + target, + endpoint: &endpoint, + token: Some(run.token), + standalone: run.standalone, + spec_version: run.spec_version, + server_era: run.server_era, + fixture: run.fixture, + cancellation: run.cancellation.clone(), + }, + paths, + ) + .await } async fn run_external_client_conformance( @@ -1243,6 +1080,7 @@ impl RuntimeContext { .standalone_conformance_compose_project(observability) .command([ "run", + "--quiet-build", "--rm", "--no-deps", "-e", @@ -1283,7 +1121,7 @@ impl RuntimeContext { let expected_scenarios = expected_client_scenarios(spec_version).map_err(AppFailure::from)?; let target = SemanticLane::ExternalDataPlane; - let lane_paths = paths.client_conformance_lane(target); + let lane_paths = paths.lane(ConformanceDirection::Client, target); remove_file_if_exists(&lane_paths.completion)?; recreate_directory(&lane_paths.official_results)?; fs::create_dir_all(&lane_paths.root) @@ -1323,73 +1161,41 @@ impl RuntimeContext { self.standalone_conformance_compose_project(true) } else { self.conformance_runtime_project(StackMode::Dataplane) - }; - let compose = if standalone { - self.standalone_dataplane_environment( - compose_project.command(std::iter::empty::<&str>()), - true, - )? - } else { - self.compose_environment( + } + .with_tools(self.config.asset_root()); + let compose = self + .target_environment( compose_project.command(std::iter::empty::<&str>()), StackMode::Dataplane, - true, + standalone, )? - }; - let compose_args = compose - .arguments() - .iter() - .map(|argument| { - argument - .to_str() - .context("client conformance Compose argument is not UTF-8") - }) - .collect::>>() - .and_then(|arguments| { - serde_json::to_string(&arguments) - .context("failed to serialize client conformance Compose arguments") - }) - .map_err(AppFailure::from)?; - let (client_command, client_path) = client_driver_command().map_err(AppFailure::from)?; + .env(CLIENT_BASE_URL_ENV, "http://nginx") + .env(CLIENT_SERVER_ID_ENV, CLIENT_CONFORMANCE_SERVER_ID) + .env(CLIENT_TOKEN_ENV, token); let progress = Activity::spinner(format!( "Run external dataplane client ({} scenarios)", expected_scenarios.len() )); let mut operational_failures = Vec::new(); for scenario in DEFAULT_CLIENT_CONFORMANCE_SCENARIOS { - let mut command = allowlisted_npx_environment( - official_client_command( - &client_command, - scenario, - spec_version, - &lane_paths.expected_failures, - &lane_paths.official_results, - ) - .cwd(self.config.root()), - ); - for (key, value) in compose.environment() { - command = command.env(key.clone(), value.clone()); - } - command = command - .env(CLIENT_COMPOSE_ARGS_ENV, &compose_args) - .env(CLIENT_BASE_URL_ENV, self.base_url()?) - .env(CLIENT_SERVER_ID_ENV, CLIENT_CONFORMANCE_SERVER_ID) - .env(CLIENT_TOKEN_ENV, token) - .env("PATH", client_path.clone()); + let arguments = ["client", scenario, spec_version].map(OsString::from); let result = self - .runner - .run_async_cancellable_to_log( - &command, + .run_tool( + compose.clone(), + &arguments, + Some(&lane_paths.root), + Some(&lane_paths.root.join(format!("runner-{scenario}.log"))), cancellation.clone(), - &lane_paths.root.join(format!("runner-{scenario}.log")), ) - .await - .map_err(AppFailure::from); + .await; if !conformance_process_completed(&result) && let Err(error) = result { operational_failures.push(format!("{scenario}: {error}")); } + if *cancellation.borrow() { + break; + } } match client_driver_failures(&lane_paths.official_results) { @@ -1427,25 +1233,6 @@ impl RuntimeContext { result } - async fn run_official_conformance_direct( - &self, - run: &DirectConformanceRun<'_>, - paths: &ConformancePaths, - ) -> AppResult<()> { - self.run_official_conformance_target( - &SemanticLaneRun { - target: SemanticLane::FixtureDirect, - endpoint: run.endpoint, - spec_version: run.spec_version, - server_era: run.server_era, - fixture: run.fixture, - cancellation: run.cancellation.clone(), - }, - paths, - ) - .await - } - async fn run_official_conformance_target( &self, run: &SemanticLaneRun<'_>, @@ -1454,7 +1241,7 @@ impl RuntimeContext { let expected_scenarios = expected_server_scenarios(DEFAULT_CONFORMANCE_SUITE, run.spec_version) .map_err(AppFailure::from)?; - let lane_paths = paths.conformance_lane(run.target); + let lane_paths = paths.lane(ConformanceDirection::Server, run.target); remove_file_if_exists(&lane_paths.completion)?; recreate_directory(&lane_paths.official_results)?; fs::create_dir_all(&lane_paths.root) @@ -1486,30 +1273,68 @@ impl RuntimeContext { }, )?; - let command = allowlisted_npx_environment( - official_server_command( - run.endpoint.as_str(), - DEFAULT_CONFORMANCE_SUITE, - run.spec_version, - &lane_paths.expected_failures, - &lane_paths.official_results, + let (compose, endpoint) = if run.target == SemanticLane::FixtureDirect { + ( + self.standalone_conformance_environment( + self.standalone_conformance_project() + .with_tools(self.config.asset_root()) + .command(std::iter::empty::<&str>()), + run.server_era, + ) + .env("CF_MCP_TOOLS_NETWORK_SERVICE", OFFICIAL_CONFORMANCE_SERVICE), + "http://127.0.0.1:3000/mcp".to_owned(), ) - .cwd(self.config.root()), - ); + } else { + let topology = match run.target { + SemanticLane::BuiltInDataPlane => StackMode::Controlplane, + _ => StackMode::Dataplane, + }; + let project = self + .routed_conformance_project(topology, run.standalone, true) + .with_tools(self.config.asset_root()); + ( + self.target_environment( + project.command(std::iter::empty::<&str>()), + topology, + run.standalone, + )? + .env( + CLIENT_TOKEN_ENV, + run.token.context("routed conformance requires a token")?, + ), + run.endpoint.to_string(), + ) + }; + let mut arguments = vec![ + OsString::from("server"), + endpoint.into(), + run.spec_version.into(), + ]; + if run.target != SemanticLane::FixtureDirect { + arguments.extend([ + "--proxy".into(), + if run.target == SemanticLane::BuiltInDataPlane { + "builtin" + } else { + "external" + } + .into(), + ]); + } let runner_progress = Activity::spinner(format!( "Run {} ({} scenarios)", run.target, expected_scenarios.len() )); let process_result = self - .runner - .run_async_cancellable_to_log( - &command, + .run_tool( + compose, + &arguments, + Some(&lane_paths.root), + Some(&lane_paths.runner_log), run.cancellation.clone(), - &lane_paths.runner_log, ) - .await - .map_err(AppFailure::from); + .await; runner_progress.finish(conformance_process_completed(&process_result)); let results = load_server_results(&lane_paths.official_results).map_err(AppFailure::from); @@ -1835,6 +1660,7 @@ fn parse_conformance_fixture_endpoint(output: &[u8]) -> anyhow::Result struct OfficialConformanceRun<'a> { topology: StackMode, + standalone: bool, server_id: &'a str, token: &'a str, spec_version: &'a str, @@ -1843,16 +1669,10 @@ struct OfficialConformanceRun<'a> { cancellation: tokio::sync::watch::Receiver, } -struct DirectConformanceRun<'a> { - endpoint: &'a url::Url, - spec_version: &'a str, - server_era: ConformanceServerEra, - fixture: &'a ConformanceFixtureMetadata, - cancellation: tokio::sync::watch::Receiver, -} - struct SemanticLaneRun<'a> { target: SemanticLane, + token: Option<&'a str>, + standalone: bool, endpoint: &'a url::Url, spec_version: &'a str, server_era: ConformanceServerEra, @@ -1919,35 +1739,6 @@ fn interrupted_conformance_failure() -> AppFailure { AppFailure::from(anyhow!("conformance workflow interrupted by Ctrl-C")) } -fn client_driver_command() -> anyhow::Result<(String, OsString)> { - let executable = - std::env::current_exe().context("failed to locate the cf-integration binary")?; - let file_name = executable - .file_name() - .and_then(OsStr::to_str) - .context("cf-integration binary name is not UTF-8")?; - if !file_name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) - { - return Err(anyhow!( - "cf-integration binary name contains characters unsupported by the official client runner" - )); - } - let directory = executable - .parent() - .context("cf-integration binary path has no parent directory")?; - let inherited = std::env::var_os("PATH").context("PATH is required for client conformance")?; - let search_path = std::env::join_paths( - std::iter::once(directory.to_owned()).chain(std::env::split_paths(&inherited)), - ) - .context("failed to prepend cf-integration to the client-conformance PATH")?; - Ok(( - format!("{file_name} {INTERNAL_CLIENT_COMMAND}"), - search_path, - )) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/runtime/conformance/reports.rs b/src/runtime/conformance/reports.rs index f43f99b..f440fa4 100644 --- a/src/runtime/conformance/reports.rs +++ b/src/runtime/conformance/reports.rs @@ -17,7 +17,7 @@ impl RuntimeContext { let runs = discover_conformance_runs(artifact_root, &report_root)?; let mut failures = Vec::new(); for paths in runs { - match self.write_comparison_from_artifacts(&paths, None) { + match write_comparison_from_artifacts(&paths, None) { Ok(comparison) => println!( "{} {}", OutputStyle::stdout().info("Conformance comparison:"), @@ -35,195 +35,183 @@ impl RuntimeContext { ))) } } +} - pub(super) fn write_comparison_from_artifacts( - &self, - paths: &ConformancePaths, - expected_run: Option<(&str, ConformanceServerEra, &str)>, - ) -> AppResult { - let fixture = self.load_conformance_artifact(paths, SemanticLane::FixtureDirect)?; - let built_in = self.load_conformance_artifact(paths, SemanticLane::BuiltInDataPlane)?; - let external = self.load_conformance_artifact(paths, SemanticLane::ExternalDataPlane)?; - if fixture.is_none() && built_in.is_none() && external.is_none() { - return Err(AppFailure::from(anyhow!( - "no official conformance artifacts found beneath {}", - paths.conformance_root.display() - ))); - } - let missing = [ - (SemanticLane::FixtureDirect, fixture.is_none()), - (SemanticLane::BuiltInDataPlane, built_in.is_none()), - (SemanticLane::ExternalDataPlane, external.is_none()), - ] - .into_iter() - .filter_map(|(lane, missing)| missing.then_some(lane.slug())) - .collect::>(); - if !missing.is_empty() { - return Err(AppFailure::from(anyhow!( - "missing conformance lanes for {}: {}", - paths.identity(), - missing.join(", ") - ))); - } - - let fixture = fixture.ok_or_else(|| { - AppFailure::from(anyhow!("missing fixture-direct conformance artifact")) - })?; - let built_in = built_in.ok_or_else(|| { - AppFailure::from(anyhow!("missing built-in dataplane conformance artifact")) - })?; - let external = external.ok_or_else(|| { - AppFailure::from(anyhow!("missing external dataplane conformance artifact")) - })?; - let metadata = compatible_metadata( - Some(&fixture.metadata), - Some(&built_in.metadata), - Some(&external.metadata), - expected_run, - )?; - let scenarios = compare_result_sets(&fixture.results, &built_in.results, &external.results); - let output = paths.report_output.join("mcp-conformance-comparison.md"); - write_comparison_report( - &output, - &ComparisonReport { - client_version: metadata.client_version.clone(), - server_era: metadata.server_era, - suite: metadata.suite.clone(), - fixture: metadata.fixture.clone(), - scenarios, - }, - ) - .map_err(AppFailure::from)?; - Ok(output) +pub(super) fn write_comparison_from_artifacts( + paths: &ConformancePaths, + expected_run: Option<(&str, ConformanceServerEra, &str)>, +) -> AppResult { + let fixture = load_conformance_artifact( + paths, + ConformanceDirection::Server, + SemanticLane::FixtureDirect, + )?; + let built_in = load_conformance_artifact( + paths, + ConformanceDirection::Server, + SemanticLane::BuiltInDataPlane, + )?; + let external = load_conformance_artifact( + paths, + ConformanceDirection::Server, + SemanticLane::ExternalDataPlane, + )?; + if fixture.is_none() && built_in.is_none() && external.is_none() { + return Err(AppFailure::from(anyhow!( + "no official conformance artifacts found beneath {}", + paths.conformance_root.display() + ))); } - - fn load_conformance_artifact( - &self, - paths: &ConformancePaths, - target: SemanticLane, - ) -> AppResult> { - let artifact = paths.conformance_lane(target); - if !artifact.metadata.is_file() - && !artifact.official_results.is_dir() - && !artifact.completion.is_file() - { - return Ok(None); - } - if !artifact.metadata.is_file() - || !artifact.official_results.is_dir() - || !artifact.completion.is_file() - { - return Err(AppFailure::from(anyhow!( - "incomplete conformance artifacts for {target} beneath {}", - artifact.root.display() - ))); - } - verify_completion_marker(&artifact.completion)?; - let metadata = read_run_metadata(&artifact.metadata)?; - if metadata.direction != ConformanceDirection::Server { - return Err(AppFailure::from(anyhow!( - "conformance metadata direction {} does not match server", - metadata.direction - ))); - } - if metadata.target != target.label() { - return Err(AppFailure::from(anyhow!( - "conformance metadata target {:?} does not match {target}", - metadata.target - ))); - } - if metadata.oracle != crate::conformance::results::OFFICIAL_CONFORMANCE_PACKAGE { - return Err(AppFailure::from(anyhow!( - "conformance artifacts used oracle {:?}, expected {:?}", - metadata.oracle, - crate::conformance::results::OFFICIAL_CONFORMANCE_PACKAGE - ))); - } - if !is_trusted_official_fixture(&metadata.fixture) { - return Err(AppFailure::from(anyhow!( - "conformance artifacts do not identify the pinned official fixture" - ))); - } - let results = load_server_results(&artifact.official_results).map_err(AppFailure::from)?; - validate_server_scenario_set(&results, &metadata.suite, &metadata.client_version) - .map_err(AppFailure::from)?; - validate_scored_results(&results).map_err(AppFailure::from)?; - Ok(Some(LoadedConformanceArtifact { results, metadata })) + let missing = [ + (SemanticLane::FixtureDirect, fixture.is_none()), + (SemanticLane::BuiltInDataPlane, built_in.is_none()), + (SemanticLane::ExternalDataPlane, external.is_none()), + ] + .into_iter() + .filter_map(|(lane, missing)| missing.then_some(lane.slug())) + .collect::>(); + if !missing.is_empty() { + return Err(AppFailure::from(anyhow!( + "missing conformance lanes for {}: {}", + paths.identity(), + missing.join(", ") + ))); } - pub(super) fn load_selected_conformance_results( - &self, - paths: &ConformancePaths, - lanes: &[SemanticLane], - ) -> AppResult> { - let results = self.load_completed_conformance_results(paths, lanes)?; - for lane in conformance_evidence_lanes(lanes) { - if !results.contains_key(&lane) { - return Err(AppFailure::from(anyhow!( - "missing required conformance lane {} for {}", - lane.slug(), - paths.identity() - ))); - } - } - Ok(results) - } + let fixture = fixture + .ok_or_else(|| AppFailure::from(anyhow!("missing fixture-direct conformance artifact")))?; + let built_in = built_in.ok_or_else(|| { + AppFailure::from(anyhow!("missing built-in dataplane conformance artifact")) + })?; + let external = external.ok_or_else(|| { + AppFailure::from(anyhow!("missing external dataplane conformance artifact")) + })?; + let metadata = compatible_metadata( + &fixture.metadata, + &built_in.metadata, + &external.metadata, + expected_run, + )?; + let scenarios = compare_result_sets(&fixture.results, &built_in.results, &external.results); + let output = paths.report_output.join("mcp-conformance-comparison.md"); + write_comparison_report( + &output, + &ComparisonReport { + client_version: metadata.client_version.clone(), + server_era: metadata.server_era, + suite: metadata.suite.clone(), + fixture: metadata.fixture.clone(), + scenarios, + }, + ) + .map_err(AppFailure::from)?; + Ok(output) +} - pub(super) fn load_completed_conformance_results( - &self, - paths: &ConformancePaths, - lanes: &[SemanticLane], - ) -> AppResult> { - let mut results = BTreeMap::new(); - for lane in conformance_evidence_lanes(lanes) { - if let Some(artifact) = self.load_conformance_artifact(paths, lane)? { - results.insert(lane, artifact.results); - } - } - Ok(results) +fn load_conformance_artifact( + paths: &ConformancePaths, + direction: ConformanceDirection, + target: SemanticLane, +) -> AppResult> { + let artifact = paths.lane(direction, target); + if !artifact.metadata.is_file() + && !artifact.official_results.is_dir() + && !artifact.completion.is_file() + { + return Ok(None); } - - pub(super) fn load_completed_client_conformance_results( - &self, - paths: &ConformancePaths, - ) -> AppResult> { - let lane = SemanticLane::ExternalDataPlane; - let artifact = paths.client_conformance_lane(lane); - if !artifact.metadata.is_file() - && !artifact.official_results.is_dir() - && !artifact.completion.is_file() - { - return Ok(BTreeMap::new()); + if !artifact.metadata.is_file() + || !artifact.official_results.is_dir() + || !artifact.completion.is_file() + { + return Err(AppFailure::from(anyhow!( + "incomplete conformance artifacts for {target} beneath {}", + artifact.root.display() + ))); + } + verify_completion_marker(&artifact.completion)?; + let metadata = read_run_metadata(&artifact.metadata)?; + if metadata.direction != direction { + return Err(AppFailure::from(anyhow!( + "conformance metadata direction {} does not match {direction}", + metadata.direction + ))); + } + if metadata.target != target.label() { + return Err(AppFailure::from(anyhow!( + "conformance metadata target {:?} does not match {target}", + metadata.target + ))); + } + if metadata.oracle != crate::conformance::results::OFFICIAL_CONFORMANCE_PACKAGE { + return Err(AppFailure::from(anyhow!( + "conformance artifacts used oracle {:?}, expected {:?}", + metadata.oracle, + crate::conformance::results::OFFICIAL_CONFORMANCE_PACKAGE + ))); + } + if !is_trusted_official_fixture(&metadata.fixture) { + return Err(AppFailure::from(anyhow!( + "conformance artifacts do not identify the pinned official fixture" + ))); + } + let results = match direction { + ConformanceDirection::Server => { + let results = load_server_results(&artifact.official_results)?; + validate_server_scenario_set(&results, &metadata.suite, &metadata.client_version)?; + results } - if !artifact.metadata.is_file() - || !artifact.official_results.is_dir() - || !artifact.completion.is_file() - { - return Err(AppFailure::from(anyhow!( - "incomplete client-conformance artifacts for {lane} beneath {}", - artifact.root.display() - ))); + ConformanceDirection::Client => { + let results = load_client_results(&artifact.official_results)?; + validate_client_scenario_set(&results, &metadata.client_version)?; + results } - verify_completion_marker(&artifact.completion)?; - let metadata = read_run_metadata(&artifact.metadata)?; - if metadata.direction != ConformanceDirection::Client || metadata.target != lane.label() { + }; + validate_scored_results(&results).map_err(AppFailure::from)?; + Ok(Some(LoadedConformanceArtifact { results, metadata })) +} + +pub(super) fn load_selected_conformance_results( + paths: &ConformancePaths, + lanes: &[SemanticLane], +) -> AppResult> { + let results = load_completed_conformance_results(paths, lanes)?; + for lane in conformance_evidence_lanes(lanes) { + if !results.contains_key(&lane) { return Err(AppFailure::from(anyhow!( - "client-conformance metadata does not match external dataplane" + "missing required conformance lane {} for {}", + lane.slug(), + paths.identity() ))); } - if metadata.oracle != crate::conformance::results::OFFICIAL_CONFORMANCE_PACKAGE - || !is_trusted_official_fixture(&metadata.fixture) + } + Ok(results) +} + +pub(super) fn load_completed_conformance_results( + paths: &ConformancePaths, + lanes: &[SemanticLane], +) -> AppResult> { + let mut results = BTreeMap::new(); + for lane in conformance_evidence_lanes(lanes) { + if let Some(artifact) = + load_conformance_artifact(paths, ConformanceDirection::Server, lane)? { - return Err(AppFailure::from(anyhow!( - "client-conformance artifacts do not identify the pinned official runner" - ))); + results.insert(lane, artifact.results); } - let results = load_client_results(&artifact.official_results).map_err(AppFailure::from)?; - validate_client_scenario_set(&results, &metadata.client_version) - .map_err(AppFailure::from)?; - validate_scored_results(&results).map_err(AppFailure::from)?; - Ok(BTreeMap::from([(lane, results)])) } + Ok(results) +} + +pub(super) fn load_completed_client_conformance_results( + paths: &ConformancePaths, +) -> AppResult> { + let lane = SemanticLane::ExternalDataPlane; + Ok( + load_conformance_artifact(paths, ConformanceDirection::Client, lane)? + .map(|artifact| BTreeMap::from([(lane, artifact.results)])) + .unwrap_or_default(), + ) } fn conformance_evidence_lanes(selected: &[SemanticLane]) -> Vec { @@ -286,33 +274,28 @@ impl ConformancePaths { self.conformance_root.join("setup.log") } - pub(super) fn baseline_report(&self, target: SemanticLane) -> PathBuf { - self.report_output - .join(target.slug()) - .join("baseline-comparison.yml") - } - - pub(super) fn client_baseline_report(&self, target: SemanticLane) -> PathBuf { - self.report_output - .join("client") - .join(target.slug()) - .join("baseline-comparison.yml") + pub(super) fn baseline_report( + &self, + direction: ConformanceDirection, + target: SemanticLane, + ) -> PathBuf { + let root = match direction { + ConformanceDirection::Server => self.report_output.clone(), + ConformanceDirection::Client => self.report_output.join("client"), + }; + root.join(target.slug()).join("baseline-comparison.yml") } - pub(super) fn conformance_lane(&self, target: SemanticLane) -> ConformanceLanePaths { - let root = self.conformance_root.join(target.slug()); - ConformanceLanePaths { - official_results: root.join("official"), - runner_log: root.join("runner.log"), - expected_failures: root.join("expected-failures.yml"), - metadata: root.join("metadata.json"), - completion: root.join("complete"), - root, + pub(super) fn lane( + &self, + direction: ConformanceDirection, + target: SemanticLane, + ) -> ConformanceLanePaths { + let root = match direction { + ConformanceDirection::Server => self.conformance_root.clone(), + ConformanceDirection::Client => self.conformance_root.join("client"), } - } - - pub(super) fn client_conformance_lane(&self, target: SemanticLane) -> ConformanceLanePaths { - let root = self.conformance_root.join("client").join(target.slug()); + .join(target.slug()); ConformanceLanePaths { official_results: root.join("official"), runner_log: root.join("runner.log"), @@ -329,11 +312,14 @@ impl ConformancePaths { SemanticLane::BuiltInDataPlane, SemanticLane::ExternalDataPlane, ] { - remove_artifact_directory(&self.conformance_lane(target).root)?; + remove_artifact_directory(&self.lane(ConformanceDirection::Server, target).root)?; } remove_artifact_directory( &self - .client_conformance_lane(SemanticLane::ExternalDataPlane) + .lane( + ConformanceDirection::Client, + SemanticLane::ExternalDataPlane, + ) .root, )?; Ok(()) @@ -522,17 +508,13 @@ fn read_run_metadata(path: &Path) -> AppResult { } fn compatible_metadata<'a>( - fixture: Option<&'a ConformanceRunMetadata>, - built_in: Option<&'a ConformanceRunMetadata>, - external: Option<&'a ConformanceRunMetadata>, + fixture: &'a ConformanceRunMetadata, + built_in: &'a ConformanceRunMetadata, + external: &'a ConformanceRunMetadata, expected_run: Option<(&str, ConformanceServerEra, &str)>, ) -> AppResult<&'a ConformanceRunMetadata> { - let metadata = fixture.or(built_in).or(external).ok_or_else(|| { - AppFailure::from(anyhow!( - "no conformance metadata is available for reporting" - )) - })?; - for candidate in [fixture, built_in, external].into_iter().flatten() { + let metadata = fixture; + for candidate in [built_in, external] { if candidate.fixture != metadata.fixture { return Err(AppFailure::from(anyhow!( "direct fixture, built-in dataplane, and external dataplane conformance fixture provenance mismatch" @@ -612,19 +594,28 @@ mod tests { ); assert_eq!( - paths.conformance_lane(SemanticLane::FixtureDirect).root, + paths + .lane(ConformanceDirection::Server, SemanticLane::FixtureDirect) + .root, PathBuf::from("artifacts/conformance/2026-07-28/modern/fixture-direct") ); assert_eq!( - paths.conformance_lane(SemanticLane::BuiltInDataPlane).root, + paths + .lane(ConformanceDirection::Server, SemanticLane::BuiltInDataPlane) + .root, PathBuf::from("artifacts/conformance/2026-07-28/modern/built-in-data-plane") ); assert_eq!( - paths.conformance_lane(SemanticLane::ExternalDataPlane).root, + paths + .lane( + ConformanceDirection::Server, + SemanticLane::ExternalDataPlane + ) + .root, PathBuf::from("artifacts/conformance/2026-07-28/modern/external-data-plane") ); assert_eq!( - paths.baseline_report(SemanticLane::BuiltInDataPlane), + paths.baseline_report(ConformanceDirection::Server, SemanticLane::BuiltInDataPlane), PathBuf::from( "reports/conformance/2026-07-28/modern/built-in-data-plane/baseline-comparison.yml" ) @@ -635,6 +626,101 @@ mod tests { ); } + #[test] + fn both_directions_require_complete_trusted_artifacts() { + for direction in [ConformanceDirection::Server, ConformanceDirection::Client] { + let directory = tempfile::tempdir().expect("artifact root"); + let paths = ConformancePaths::new( + directory.path(), + directory.path().join("reports"), + "2026-07-28", + ConformanceServerEra::Modern, + ); + let lane = SemanticLane::ExternalDataPlane; + let artifact = paths.lane(direction, lane); + assert!( + load_conformance_artifact(&paths, direction, lane) + .expect("missing artifacts") + .is_none() + ); + fs::create_dir_all(&artifact.official_results).expect("results directory"); + let mut original = metadata(lane); + original.direction = direction; + original.server_era = ConformanceServerEra::Modern; + original.suite = if direction == ConformanceDirection::Server { + "all" + } else { + "scoped" + } + .to_owned(); + write_run_metadata(&artifact.metadata, &original).expect("metadata"); + let scenarios = match direction { + ConformanceDirection::Server => expected_server_scenarios("all", "2026-07-28"), + ConformanceDirection::Client => expected_client_scenarios("2026-07-28"), + } + .expect("scenario catalog"); + for scenario in &scenarios { + let prefix = if direction == ConformanceDirection::Server { + "server-" + } else { + "" + }; + let result = artifact + .official_results + .join(format!("{prefix}{scenario}-2026-09-07T12-00-00-000Z")); + fs::create_dir_all(&result).expect("scenario directory"); + fs::write( + result.join("checks.json"), + r#"[{"id":"check","status":"SUCCESS"}]"#, + ) + .expect("checks"); + } + assert!( + load_conformance_artifact(&paths, direction, lane).is_err(), + "missing completion marker" + ); + write_completion_marker(&artifact.completion).expect("completion"); + let loaded = load_conformance_artifact(&paths, direction, lane) + .expect("valid artifacts") + .expect("present artifacts"); + assert_eq!(loaded.results.scenarios.len(), scenarios.len()); + for field in ["direction", "target", "oracle", "fixture"] { + let mut invalid = original.clone(); + match field { + "direction" => { + invalid.direction = match direction { + ConformanceDirection::Server => ConformanceDirection::Client, + ConformanceDirection::Client => ConformanceDirection::Server, + } + } + "target" => invalid.target = "wrong-lane".to_owned(), + "oracle" => invalid.oracle = "untrusted-runner".to_owned(), + _ => invalid.fixture.revision = "untrusted-revision".to_owned(), + } + write_run_metadata(&artifact.metadata, &invalid).expect("invalid metadata"); + assert!( + load_conformance_artifact(&paths, direction, lane).is_err(), + "accepted invalid {direction} {field}" + ); + } + write_run_metadata(&artifact.metadata, &original).expect("restore metadata"); + fs::write(&artifact.completion, "partial").expect("invalid marker"); + assert!(load_conformance_artifact(&paths, direction, lane).is_err()); + write_completion_marker(&artifact.completion).expect("restore marker"); + let result = fs::read_dir(&artifact.official_results) + .expect("result directories") + .next() + .expect("one result") + .expect("result entry") + .path(); + fs::remove_file(result.join("checks.json")).expect("remove one scenario"); + assert!( + load_conformance_artifact(&paths, direction, lane).is_err(), + "incomplete scenarios must not pass" + ); + } + } + #[test] fn routed_selection_loads_direct_fixture_evidence_without_selecting_its_baseline() { assert_eq!( @@ -665,7 +751,7 @@ mod tests { SemanticLane::BuiltInDataPlane, SemanticLane::ExternalDataPlane, ] { - fs::create_dir_all(paths.conformance_lane(target).root) + fs::create_dir_all(paths.lane(ConformanceDirection::Server, target).root) .expect("lane directory should be created"); } @@ -678,19 +764,25 @@ mod tests { SemanticLane::BuiltInDataPlane, SemanticLane::ExternalDataPlane, ] { - assert!(!paths.conformance_lane(target).root.exists()); + assert!( + !paths + .lane(ConformanceDirection::Server, target) + .root + .exists() + ); } } #[test] - fn partial_lane_metadata_is_reportable_when_provenance_matches() { + fn complete_lane_metadata_is_reportable_when_provenance_matches() { let fixture = metadata(SemanticLane::FixtureDirect); let dataplane = metadata(SemanticLane::ExternalDataPlane); + let builtin = metadata(SemanticLane::BuiltInDataPlane); let selected = compatible_metadata( - Some(&fixture), - None, - Some(&dataplane), + &fixture, + &builtin, + &dataplane, Some(("2026-07-28", ConformanceServerEra::Dual, "all")), ) .expect("selected lanes should be compatible"); @@ -704,9 +796,14 @@ mod tests { let mut dataplane = metadata(SemanticLane::ExternalDataPlane); dataplane.fixture.revision = "different".to_owned(); - let error = compatible_metadata(Some(&fixture), None, Some(&dataplane), None) - .expect_err("mismatched provenance must fail") - .to_string(); + let error = compatible_metadata( + &fixture, + &metadata(SemanticLane::BuiltInDataPlane), + &dataplane, + None, + ) + .expect_err("mismatched provenance must fail") + .to_string(); assert!(error.contains("provenance mismatch")); assert!(!error.contains("different")); @@ -718,9 +815,14 @@ mod tests { let mut dataplane = metadata(SemanticLane::ExternalDataPlane); dataplane.server_era = ConformanceServerEra::Legacy; - let error = compatible_metadata(Some(&fixture), None, Some(&dataplane), None) - .expect_err("different server eras must not be compared") - .to_string(); + let error = compatible_metadata( + &fixture, + &metadata(SemanticLane::BuiltInDataPlane), + &dataplane, + None, + ) + .expect_err("different server eras must not be compared") + .to_string(); assert!(error.contains("incompatible runs")); } diff --git a/src/runtime/inspect.rs b/src/runtime/inspect.rs index 1286ea9..a78d15a 100644 --- a/src/runtime/inspect.rs +++ b/src/runtime/inspect.rs @@ -2,21 +2,6 @@ use super::*; -const INSPECTOR_PACKAGE: &str = "@modelcontextprotocol/inspector@2.2.0"; -pub(super) const NPM_ENV_ALLOWLIST: &[&str] = &[ - "PATH", - "HOME", - "TMPDIR", - "TMP", - "TEMP", - "XDG_CACHE_HOME", - "NPM_CONFIG_CACHE", - "npm_config_cache", - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "NODE_EXTRA_CA_CERTS", -]; - impl RuntimeContext { pub(super) async fn inspect( &self, @@ -47,53 +32,38 @@ impl RuntimeContext { .map_err(AppFailure::from)? .endpoint() .clone(); - let proxy = AuthProxy::start_with_protocol_version( - endpoint, - &token, - Some(protocol_version.wire_version()), - ) - .await - .context("failed to start the Inspector authentication proxy") - .map_err(AppFailure::from)?; - let command = allowlisted_npx_environment( - inspector_command(proxy.url().as_str(), method).cwd(self.config.root()), - ); - let process_result = self - .runner - .run_async(&command) - .await - .map_err(AppFailure::from); - let shutdown_result = proxy - .shutdown() - .await - .context("failed to stop the Inspector authentication proxy") - .map_err(AppFailure::from); - finish_with_cleanup(process_result.err(), shutdown_result) + let project = if standalone { + self.standalone_conformance_compose_project(true) + } else { + self.compose_project(mode) + } + .with_tools(self.config.asset_root()); + let compose = self + .target_environment( + project.command(std::iter::empty::<&str>()), + mode, + standalone, + )? + .env(CLIENT_TOKEN_ENV, token); + let arguments = [ + "inspect", + endpoint.as_str(), + protocol_version.wire_version(), + method, + ] + .map(OsString::from); + let (sender, receiver) = tokio::sync::watch::channel(false); + let run = self.run_tool(compose, &arguments, None, None, receiver); + tokio::pin!(run); + tokio::select! { + result = &mut run => result, + _ = tokio::signal::ctrl_c() => { + sender.send_replace(true); + run.await + } + } }, ) .await } } - -pub(super) fn inspector_command(endpoint: &str, method: &str) -> CommandSpec { - CommandSpec::new("npx").clear_environment().args([ - "-y", - INSPECTOR_PACKAGE, - "--cli", - endpoint, - "--transport", - "http", - "--method", - method, - ]) -} - -pub(super) fn allowlisted_npx_environment(mut command: CommandSpec) -> CommandSpec { - command = command.clear_environment(); - for key in NPM_ENV_ALLOWLIST { - if let Some(value) = std::env::var_os(key) { - command = command.env(key, value); - } - } - command -} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 3f14bfc..ccc2ce9 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -11,12 +11,10 @@ use std::time::Duration; use crate::conformance::baseline::{ BaselineComparison, BaselineUpdate, bless_baselines_transactionally, evaluate_baselines, - evaluate_client_baselines, validate_scored_results, write_baseline_report, - write_client_baseline_report, + validate_scored_results, write_baseline_report, }; use crate::conformance::client::{ - CLIENT_BASE_URL_ENV, CLIENT_COMPOSE_ARGS_ENV, CLIENT_DRIVER_FAILURE_PREFIX, - CLIENT_SERVER_ID_ENV, CLIENT_TOKEN_ENV, INTERNAL_CLIENT_COMMAND, + CLIENT_BASE_URL_ENV, CLIENT_DRIVER_FAILURE_PREFIX, CLIENT_SERVER_ID_ENV, CLIENT_TOKEN_ENV, }; use crate::conformance::fixture::{ ConformanceFixtureClient, OFFICIAL_CONFORMANCE_BACKEND_URL, OFFICIAL_CONFORMANCE_PROXY_SERVICE, @@ -27,21 +25,20 @@ use crate::conformance::results::{ ComparisonReport, ConformanceDirection, ConformanceFixtureMetadata, ConformanceResults, ConformanceRunMetadata, ConformanceServerEra, DEFAULT_CLIENT_CONFORMANCE_SCENARIOS, SemanticLane, compare_result_sets, expected_client_scenarios, expected_server_scenarios, - is_trusted_official_fixture, load_client_results, load_server_results, official_client_command, - official_server_command, validate_client_scenario_set, validate_server_scenario_set, - write_comparison_report, + is_trusted_official_fixture, load_client_results, load_server_results, + validate_client_scenario_set, validate_server_scenario_set, write_comparison_report, }; use crate::infrastructure::checkout::{CheckoutManager, CheckoutRequest}; use crate::infrastructure::compose::{ComposeProject, validate_integration_contract}; use crate::infrastructure::config::{AppConfig, ImagePullPolicy}; use crate::infrastructure::process::{CommandSpec, LoggingProcessRunner, ProcessRunner}; use crate::infrastructure::stack::{ - BuildInputs, BuildMode, CleanupKind, FreshnessSnapshot, ServiceSnapshot, StackCommandPlan, - StackFreshness, resolve_build, + BuildInputs, BuildMode, CleanupKind, FreshnessSnapshot, ServiceSnapshot, StackFreshness, + resolve_build, stack_cleanup_command, stack_config_command, stack_logs_command, + stack_up_command, }; use crate::infrastructure::{InfrastructureError, StackMode}; use crate::mcp::GatewayTopology; -use crate::mcp::auth_proxy::AuthProxy; use crate::mcp::gateway::GatewayClient; use crate::mcp::probe::{ProbeConfig, run_probe}; use crate::mcp::protocol::ACCEPT as MCP_ACCEPT; @@ -72,11 +69,11 @@ mod performance; mod probe; mod session; mod stack; +mod tools; #[cfg(test)] use control_plane::CONFORMANCE_TOKEN_DESCRIPTION; use control_plane::{ControlPlaneClient, ManagedBearerToken}; -use inspect::*; /// Runtime dependencies and execution of resolved CLI actions. pub(crate) struct RuntimeContext { @@ -388,7 +385,7 @@ mod tests { } } - fn app_config(root: &Path, base_url: &str, extra: &[(&str, &str)]) -> AppConfig { + pub(super) 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", diff --git a/src/runtime/session.rs b/src/runtime/session.rs index ada8425..2435779 100644 --- a/src/runtime/session.rs +++ b/src/runtime/session.rs @@ -64,6 +64,7 @@ impl RuntimeContext { ) -> AppResult { let command = self.standalone_dataplane_project(observability).command([ "run", + "--quiet-build", "--rm", "--no-deps", "config_writer", diff --git a/src/runtime/stack/mod.rs b/src/runtime/stack/mod.rs index 6e40fa1..4e22e93 100644 --- a/src/runtime/stack/mod.rs +++ b/src/runtime/stack/mod.rs @@ -83,9 +83,8 @@ impl RuntimeContext { standalone, } => { let project = self.stack_command_project(topology, standalone)?; - let command = StackCommandPlan::status(project); - let command = - self.target_environment(command.command().clone(), topology, standalone)?; + let command = project.command(["ps"]); + let command = self.target_environment(command, topology, standalone)?; Ok(self.runner.run(&command)?) } StackAction::Logs { @@ -94,9 +93,8 @@ impl RuntimeContext { standalone, } => { let project = self.stack_command_project(topology, standalone)?; - let command = StackCommandPlan::logs(project, services); - let command = - self.target_environment(command.command().clone(), topology, standalone)?; + let command = stack_logs_command(project, services); + let command = self.target_environment(command, topology, standalone)?; Ok(self.runner.run(&command)?) } StackAction::Config { @@ -113,9 +111,7 @@ impl RuntimeContext { let command = if standalone { project.command(["config", "--no-interpolate", "--no-env-resolution"]) } else { - StackCommandPlan::config(project, topology) - .command() - .clone() + stack_config_command(project, topology) }; let command = self.target_environment(command, topology, standalone)?; Ok(self.runner.run(&command)?) @@ -278,8 +274,8 @@ impl RuntimeContext { .map_err(|_| { AppFailure::from(anyhow!("CONTROLPLANE_LOCUST_WORKERS must be an integer")) })?; - let command = StackCommandPlan::up(project, mode, build, start_locust, locust_workers); - let command = self.compose_environment(command.command().clone(), mode, true)?; + let command = stack_up_command(project, mode, build, start_locust, locust_workers); + let command = self.compose_environment(command, mode, true)?; let (controlplane_pull_policy, dataplane_pull_policy) = compose_pull_policies( mode, build, @@ -648,7 +644,10 @@ impl RuntimeContext { .env("CF_DATAPLANE_PLATFORM", self.dataplane_platform()?)) } - fn host_identity_environment(&self, mut command: CommandSpec) -> AppResult { + pub(super) fn host_identity_environment( + &self, + mut command: CommandSpec, + ) -> AppResult { for (key, argument) in [("HOST_UID", "-u"), ("HOST_GID", "-g")] { if self.config.environment().get(OsStr::new(key)).is_none() { let value = self.host_identity(argument)?; @@ -1171,8 +1170,8 @@ impl RuntimeContext { let project = self .standalone_conformance_compose_project(true) .with_profiles(["conformance"]); - let command = StackCommandPlan::cleanup(project, kind); - let command = self.standalone_dataplane_environment(command.command().clone(), false)?; + let command = stack_cleanup_command(project, kind); + let command = self.standalone_dataplane_environment(command, false)?; let primary = self .run_cleanup_command(&command, true) .map_err(AppFailure::from) @@ -1202,8 +1201,8 @@ impl RuntimeContext { .compose_project(mode) .with_profiles(["testing", "inspector", "sso"]) .with_conformance_fixture(self.config.asset_root()); - let command = StackCommandPlan::cleanup(project, kind); - match self.compose_environment(command.command().clone(), mode, false) { + let command = stack_cleanup_command(project, kind); + match self.compose_environment(command, mode, false) { Ok(command) => { let result = self.run_cleanup_command(&command, inherit_output); if let Err(error) = result { diff --git a/src/runtime/tools.rs b/src/runtime/tools.rs new file mode 100644 index 0000000..7b9afd9 --- /dev/null +++ b/src/runtime/tools.rs @@ -0,0 +1,213 @@ +//! Docker lifecycle shared by conformance and Inspector. + +use super::*; + +impl RuntimeContext { + pub(super) async fn run_tool( + &self, + compose: CommandSpec, + arguments: &[OsString], + artifacts: Option<&Path>, + log: Option<&Path>, + cancellation: tokio::sync::watch::Receiver, + ) -> AppResult<()> { + let compose = self.host_identity_environment(compose)?; + let build = compose.clone().args(["build", "mcp_tools"]); + self.run_tool_process(&build, log, cancellation.clone()) + .await?; + if *cancellation.borrow() { + return Err(AppFailure::from(anyhow!( + "tool cancelled before container startup" + ))); + } + let name = format!("cf-mcp-tools-{}", uuid::Uuid::new_v4().simple()); + let mut command = + compose.args(["run", "--no-deps", "--pull", "never", "-T", "--name", &name]); + if let Some(directory) = artifacts { + let mut volume = directory.as_os_str().to_owned(); + volume.push(":/artifacts"); + command = command.arg("--volume").arg(volume); + } + command = command.arg("mcp_tools").args(arguments.iter().cloned()); + let result = self.run_tool_process(&command, log, cancellation).await; + // Killing the Docker client does not stop its container. Always remove + // the named container on the same daemon, including after cancellation. + let mut removal = CommandSpec::new("docker").args(["rm", "--force", &name]); + if !command.inherits_environment() { + removal = removal.clear_environment(); + } + if let Some(directory) = command.working_directory() { + removal = removal.cwd(directory); + } + for (key, value) in command.environment() { + removal = removal.env(key, value); + } + let cleanup = self + .runner + .run_async(&removal) + .await + .map_err(AppFailure::from); + finish_with_cleanup(result.err(), cleanup) + } + + async fn run_tool_process( + &self, + command: &CommandSpec, + log: Option<&Path>, + cancellation: tokio::sync::watch::Receiver, + ) -> AppResult<()> { + match log { + Some(path) => { + self.runner + .run_async_cancellable_to_log(command, cancellation, path) + .await + } + None => { + self.runner + .run_async_cancellable(command, cancellation) + .await + } + } + .map_err(AppFailure::from) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::infrastructure::process::CapturedOutput; + use std::cell::RefCell; + use std::pin::Pin; + + struct FailingRunner { + commands: RefCell>, + fail_at: &'static str, + interrupt: bool, + cancellation: tokio::sync::watch::Sender, + } + + impl ProcessRunner for FailingRunner { + fn run(&self, spec: &CommandSpec) -> Result<(), InfrastructureError> { + self.commands.borrow_mut().push(spec.clone()); + Ok(()) + } + fn run_async<'a>( + &'a self, + spec: &'a CommandSpec, + ) -> Pin> + 'a>> { + Box::pin(async move { + self.run(spec)?; + if spec.arguments().contains(&OsString::from(self.fail_at)) { + if self.interrupt { + self.cancellation.send_replace(true); + std::future::pending::<()>().await; + } + #[cfg(unix)] + let status = ::from_raw(1 << 8); + #[cfg(windows)] + let status = ::from_raw(1); + return Err(InfrastructureError::child_exit("docker".into(), status)); + } + Ok(()) + }) + } + fn capture_stdout(&self, _: &CommandSpec) -> Result, InfrastructureError> { + Ok(b"1000".to_vec()) + } + fn capture_output(&self, _: &CommandSpec) -> Result { + Ok(CapturedOutput::new(Vec::new(), Vec::new())) + } + fn run_to_log(&self, spec: &CommandSpec, _: &Path) -> Result<(), InfrastructureError> { + self.run(spec) + } + } + + #[tokio::test] + async fn failure_and_cancellation_remove_the_container_after_reaping_the_docker_client() { + for interrupt in [false, true] { + let directory = tempfile::tempdir().expect("workspace"); + let config = + crate::runtime::tests::app_config(directory.path(), "http://127.0.0.1:8080", &[]); + let (sender, receiver) = tokio::sync::watch::channel(false); + let runner = FailingRunner { + commands: RefCell::default(), + fail_at: "run", + interrupt, + cancellation: sender, + }; + let runtime = RuntimeContext::new(config, runner); + let result = runtime + .run_tool( + CommandSpec::new("docker") + .arg("compose") + .clear_environment() + .cwd(directory.path()) + .env("DOCKER_HOST", "tcp://test-daemon:2376") + .env("DOCKER_TLS_VERIFY", "1") + .env("DOCKER_CERT_PATH", "relative/certs"), + &["inspect".into()], + None, + None, + receiver, + ) + .await; + assert!(result.is_err()); + let commands = runtime.runner.commands.borrow(); + let run = &commands[1]; + let name = run + .arguments() + .windows(2) + .find(|args| args[0] == "--name") + .expect("container name")[1] + .clone(); + assert_eq!(commands[2].program(), "docker"); + assert_eq!( + commands[2].arguments(), + [OsString::from("rm"), "--force".into(), name] + ); + assert_eq!(commands.len(), 3); + assert_eq!(commands[2].environment(), run.environment()); + assert_eq!(commands[2].working_directory(), run.working_directory()); + assert!(!commands[2].inherits_environment()); + } + } + + #[tokio::test] + async fn container_removal_failure_is_operational_and_runner_exit_is_preserved() { + for fail_at in ["run", "rm"] { + let directory = tempfile::tempdir().expect("workspace"); + let config = + crate::runtime::tests::app_config(directory.path(), "http://127.0.0.1:8080", &[]); + let (sender, receiver) = tokio::sync::watch::channel(false); + let runtime = RuntimeContext::new( + config, + FailingRunner { + commands: RefCell::default(), + fail_at, + interrupt: false, + cancellation: sender, + }, + ); + let failure = runtime + .run_tool( + CommandSpec::new("docker").arg("compose"), + &["server".into()], + None, + None, + receiver, + ) + .await + .expect_err("configured Docker command fails"); + if fail_at == "rm" { + assert!(matches!(failure, AppFailure::Native(_)), "{failure}"); + assert!(failure.to_string().contains("cleanup failed")); + } else { + assert!(matches!( + failure, + AppFailure::Infrastructure(InfrastructureError::ChildExit { .. }) + )); + } + assert_eq!(runtime.runner.commands.borrow().len(), 3); + } + } +}