diff --git a/.env.example b/.env.example index a210c76..a9469ec 100644 --- a/.env.example +++ b/.env.example @@ -3,10 +3,10 @@ # Copy this file to .env for local runs. .env is ignored by git. # Shell variables override values from .env: # CF_CONTROLPLANE_REF=user/luca/dataplane-integration-fixes \ -# cargo run --locked -- stack up --topology dataplane +# cargo run --locked -- stack up --lane external -# Default single-stack mode. Possible: controlplane, dataplane. -CF_MCP_STACK_MODE=dataplane +# Default execution lane. Possible: builtin, external. +CF_MCP_LANE=external # Optional developer checkout containing source overlays. Without this, the # current directory is the workspace and the binary can materialize embedded diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b99aeca..ffffbbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: exit 1 fi test ! -e "$sandbox/state" - if (cd "$sandbox" && CF_INTEGRATION_DIR="$sandbox/state" "$binary" stack config --topology dataplane); then + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e256e0a..6afc3bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: exit 1 fi test ! -e "$sandbox/state" - if (cd "$sandbox" && CF_INTEGRATION_DIR="$sandbox/state" "$binary" stack config --topology dataplane); then + 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 diff --git a/Cargo.lock b/Cargo.lock index 68d7476..3df804e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,7 +177,7 @@ dependencies = [ [[package]] name = "cf-integration" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 475f48d..3604396 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cf-integration" -version = "0.2.0" +version = "0.2.1" edition = "2024" rust-version = "1.97" license = "Apache-2.0" diff --git a/README.md b/README.md index 961676c..fffb0d9 100644 --- a/README.md +++ b/README.md @@ -97,36 +97,36 @@ failure. Test results use aligned nextest-style labels: green `PASS`, yellow `CARGO_TERM_COLOR` control ANSI output. Command data such as tokens, Compose configuration, and report paths remains on standard output for scripting. -Stack commands use physical `--topology controlplane|dataplane`: +Every stack and workflow selector uses semantic `--lane` values. Stack +commands accept `builtin`, `external`, or `all` where both lanes are valid: ```bash -cf-integration stack up --topology dataplane -cf-integration stack up --topology dataplane --fresh -cf-integration stack status --topology dataplane -cf-integration stack config --topology dataplane -cf-integration stack down --topology all -cf-integration stack down --topology all --volumes +cf-integration stack up --lane external +cf-integration stack up --lane external --fresh +cf-integration stack status --lane external +cf-integration stack config --lane external +cf-integration stack down --lane all +cf-integration stack down --lane all --volumes ``` `stack down --volumes` is the explicit destructive reset. Managed workflows preserve the primary failure, attempt every token and stack cleanup, and report all cleanup failures. -Probe, load, and Inspector use physical lanes: +Probe, load, and Inspector use the same routed lanes: ```bash -cf-integration probe --lane dataplane --protocol-version 2026-07-28 -cf-integration load --lane dataplane --smoke -cf-integration debug inspect --lane dataplane --method tools/list +cf-integration probe --lane external --protocol-version modern +cf-integration load --lane builtin --smoke +cf-integration debug inspect --lane external --method tools/list ``` -Live and conformance share semantic lanes: `fixture-direct`, -`built-in-data-plane`, and `external-data-plane`. +Live and conformance additionally support the direct `fixture-direct` lane. ```bash -cf-integration live --lane external-data-plane --group mcp +cf-integration live --lane external --group mcp cf-integration live --lane fixture-direct --group protocol \ - --protocol-version 2025-06-18 + --protocol-version legacy cf-integration conformance run cf-integration conformance run \ @@ -139,10 +139,14 @@ cf-integration conformance report cf-integration --version ``` -Workflows accept only `--lane`; `--topology` is reserved for stack commands. -The direct fixture spelling is only `fixture-direct`. Probe, load, live, and -Inspector use `--protocol-version`; conformance uses the explicit -`--client-era` and `--server-era` matrix axes. +No command accepts `--topology`. The direct fixture spelling is only +`fixture-direct`. Probe, load, live, and Inspector use `--protocol-version`; +conformance uses the explicit `--client-era` and `--server-era` matrix axes. + +Operational protocol selection is semantic: `modern` maps to the latest +per-request revision and `legacy` maps to the latest initialization-based +revision. Exact date revisions remain internal wire values and conformance +matrix dimensions. ## MCP and conformance behavior @@ -165,7 +169,7 @@ built-in and external dataplane routes. For protocol `2026-07-28`, the client suite also makes the external dataplane send requests to the official scenario servers. The four downstream scenarios are `tools_call`, `request-metadata`, `http-standard-headers`, and `http-custom-headers`; they run automatically -whenever `external-data-plane` is selected. The workflow records raw official +whenever the `external` lane is selected. The workflow records raw official results without suppression, writes deterministic comparisons, and continues through every expanded client-revision/server-era combination before returning one aggregated result. `dual` is supported only when selected explicitly. @@ -226,7 +230,7 @@ Copy `.env.example` to `.env`. Process values override the file. ```bash CF_INTEGRATION_ROOT=/path/to/contextforge-dev-tools CF_INTEGRATION_DIR=.integration -CF_MCP_STACK_MODE=dataplane +CF_MCP_LANE=external CF_CONTROLPLANE_REPO=https://github.com/IBM/mcp-context-forge.git CF_CONTROLPLANE_REF=main @@ -242,7 +246,7 @@ CF_FAST_TIME_EXPECTED_IMAGE=ghcr.io/ibm/cfex-mcp-fast-time-server:latest CF_FAST_TIME_SERVER_ID=9779b6698cbd4b4995ee04a4fab38737 MCP_CLI_BASE_URL=http://127.0.0.1:8080 -MCP_PROTOCOL_VERSION=2026-07-28 +MCP_PROTOCOL_VERSION=modern MCP_SERVER_ID=9779b6698cbd4b4995ee04a4fab38737 PLATFORM_ADMIN_EMAIL=admin@example.com diff --git a/docker/docker-compose.cf-dataplane.yaml b/docker/docker-compose.cf-dataplane.yaml index 43e37f8..208f1f4 100644 --- a/docker/docker-compose.cf-dataplane.yaml +++ b/docker/docker-compose.cf-dataplane.yaml @@ -4,7 +4,7 @@ # export CF_INTEGRATION_ROOT="$PWD" # export CF_DATAPLANE_IMAGE="ghcr.io/contextforge-org/contextforge-data-plane:latest" # export CF_DATAPLANE_PLATFORM="linux/amd64" -# # Or let `cf-integration stack up --topology dataplane` resolve `auto`. +# # Or let `cf-integration stack up --lane external` resolve `auto`. # docker compose \ # -f /path/to/cf-controlplane/docker-compose.yml \ # -f "$CF_INTEGRATION_ROOT/docker/docker-compose.cf-dataplane.yaml" \ diff --git a/src/app.rs b/src/app.rs index a9907aa..4f4ba22 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,6 @@ use std::ffi::{OsStr, OsString}; use std::path::{Component, PathBuf}; use std::str::FromStr; -use crate::conformance::DEFAULT_MCP_SPEC_VERSION; use crate::conformance::profile::{ DUAL_CLIENT_PROTOCOL_VERSIONS, LEGACY_CLIENT_PROTOCOL_VERSIONS, MODERN_CLIENT_PROTOCOL_VERSIONS, }; @@ -16,10 +15,10 @@ use crate::performance::LoadRequest; use anyhow::{Result, bail}; use crate::cli::{ - CiCommand, Cli, CliLane, CliTopology, Command, ConformanceCommand, DebugCommand, LiveGroup, - ProtocolVersion, StackCommand, TokenKind, TopologySelection, + CiCommand, Cli, CliLane, CliRoutedLane, Command, ConformanceCommand, DebugCommand, + LaneSelection, LiveGroup, ProtocolVersion, StackCommand, TokenKind, }; -const STACK_MODE_ENV: &str = "CF_MCP_STACK_MODE"; +const LANE_ENV: &str = "CF_MCP_LANE"; const PROTOCOL_VERSION_ENV: &str = "MCP_PROTOCOL_VERSION"; /// Fully resolved application operation. @@ -77,14 +76,14 @@ impl Action { topology, protocol_version, .. - }) => topology_and_protocol(*topology, protocol_version), - Self::Load(args) => topology_and_protocol(args.topology, &args.protocol_version), + }) => lane_and_protocol(*topology, protocol_version), + Self::Load(args) => lane_and_protocol(args.topology, &args.protocol_version), Self::Live { lane, protocol_version, .. } => format!( - "Topology: {}\nProtocol version: {protocol_version}", + "Lane: {}\nProtocol version: {protocol_version}", lane.label() ), Self::Conformance(ConformanceAction::Run { @@ -93,16 +92,16 @@ impl Action { server_eras, .. }) => format!( - "Topology: {}\nClient era: {}\nServer era: {}", + "Lane: {}\nClient era: {}\nServer era: {}", join_lane_labels(lanes), join_client_eras(client_eras), join_server_eras(server_eras), ), Self::Conformance(ConformanceAction::Report { .. }) => String::from( - "Topology: recorded conformance results\nClient era: recorded conformance results\nServer era: recorded conformance results", + "Lane: recorded conformance results\nClient era: recorded conformance results\nServer era: recorded conformance results", ), Self::Debug(DebugAction::Token { .. }) => { - String::from("Topology: not applicable (token only)") + String::from("Lane: not applicable (token only)") } Self::Ci(CiAction::PrepareImage { .. }) => { String::from("CI operation: prepare prebuilt image") @@ -139,35 +138,36 @@ impl Action { impl StackAction { fn startup_summary(&self) -> String { - let topology = match self { + let lane = match self { Self::Up { topology, .. } | Self::Status(topology) | Self::Logs { topology, .. } - | Self::Config(topology) => topology.topology_label().to_owned(), - Self::Down { topology, .. } => match topology { - TopologySelection::Controlplane => { - StackMode::Controlplane.topology_label().to_owned() - } - TopologySelection::Dataplane => StackMode::Dataplane.topology_label().to_owned(), - TopologySelection::All => format!( + | Self::Config(topology) => topology.lane_label().to_owned(), + Self::Down { lane, .. } => match lane { + LaneSelection::Builtin => StackMode::Controlplane.lane_label().to_owned(), + LaneSelection::External => StackMode::Dataplane.lane_label().to_owned(), + LaneSelection::All => format!( "{}, {}", - StackMode::Controlplane.topology_label(), - StackMode::Dataplane.topology_label() + StackMode::Controlplane.lane_label(), + StackMode::Dataplane.lane_label() ), }, }; if matches!(self, Self::Up { .. }) { - format!("Topology: {topology}\nProtocol version: {DEFAULT_MCP_SPEC_VERSION}") + format!( + "Lane: {lane}\nProtocol version: {}", + ProtocolVersion::default() + ) } else { - format!("Topology: {topology}") + format!("Lane: {lane}") } } } -fn topology_and_protocol(topology: StackMode, protocol_version: &ProtocolVersion) -> String { +fn lane_and_protocol(topology: StackMode, protocol_version: &ProtocolVersion) -> String { format!( - "Topology: {}\nProtocol version: {protocol_version}", - topology.topology_label() + "Lane: {}\nProtocol version: {protocol_version}", + topology.lane_label() ) } @@ -210,7 +210,7 @@ pub(crate) enum StackAction { fresh: bool, }, Down { - topology: TopologySelection, + lane: LaneSelection, volumes: bool, }, Status(StackMode), @@ -284,13 +284,13 @@ pub(crate) enum CiAction { /// /// # Errors /// -/// Returns an error when a command needs `CF_MCP_STACK_MODE` and its value is -/// neither `controlplane` nor `dataplane`. +/// Returns an error when a command needs `CF_MCP_LANE` and its value is neither +/// `builtin` nor `external`. pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result { match cli.command { Command::Stack(args) => resolve_stack(args.command, environment).map(Action::Stack), Command::Probe(args) => { - let topology = resolve_topology(args.lane, environment)?; + let topology = resolve_lane(args.lane, environment)?; Ok(Action::Probe { topology, protocol_version: resolve_protocol_version( @@ -301,7 +301,7 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result { - let topology = resolve_topology(args.target.lane, environment)?; + let topology = resolve_lane(args.target.lane, environment)?; Ok(Action::Load(ResolvedLoadArgs { topology, protocol_version: resolve_protocol_version( @@ -353,7 +353,7 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result Ok(Action::Debug(match args.command { DebugCommand::Inspect(args) => { - let topology = resolve_topology(args.target.lane, environment)?; + let topology = resolve_lane(args.target.lane, environment)?; DebugAction::Inspect { topology, protocol_version: resolve_protocol_version( @@ -415,9 +415,9 @@ fn environment_utf8(environment: &Environment, key: &str) -> Option { fn resolve_live_lane(lane: Option, environment: &Environment) -> Result { Ok(match lane { Some(CliLane::FixtureDirect) => SemanticLane::FixtureDirect, - Some(CliLane::BuiltInDataPlane) => SemanticLane::BuiltInDataPlane, - Some(CliLane::ExternalDataPlane) => SemanticLane::ExternalDataPlane, - None => match resolve_topology(None, environment)? { + Some(CliLane::Builtin) => SemanticLane::BuiltInDataPlane, + Some(CliLane::External) => SemanticLane::ExternalDataPlane, + None => match resolve_lane(None, environment)? { StackMode::Controlplane => SemanticLane::BuiltInDataPlane, StackMode::Dataplane => SemanticLane::ExternalDataPlane, }, @@ -448,25 +448,23 @@ fn resolve_protocol_version( fn resolve_stack(command: StackCommand, environment: &Environment) -> Result { match command { StackCommand::Up(args) => Ok(StackAction::Up { - topology: resolve_topology(args.topology, environment)?, + topology: resolve_lane(args.lane, environment)?, fresh: args.fresh, }), StackCommand::Down(args) => Ok(StackAction::Down { - topology: args.topology.unwrap_or(TopologySelection::All), + lane: args.lane.unwrap_or(LaneSelection::All), volumes: args.volumes, }), - StackCommand::Status(args) => Ok(StackAction::Status(resolve_topology( - args.topology, - environment, - )?)), + StackCommand::Status(args) => { + Ok(StackAction::Status(resolve_lane(args.lane, environment)?)) + } StackCommand::Logs(args) => Ok(StackAction::Logs { - topology: resolve_topology(args.topology, environment)?, + topology: resolve_lane(args.lane, environment)?, services: args.services, }), - StackCommand::Config(args) => Ok(StackAction::Config(resolve_topology( - args.topology, - environment, - )?)), + StackCommand::Config(args) => { + Ok(StackAction::Config(resolve_lane(args.lane, environment)?)) + } } } @@ -530,40 +528,40 @@ fn resolve_server_eras(eras: Vec) -> Vec, environment: &Environment) -> Result { - if let Some(topology) = explicit { - return Ok(topology.into()); +fn resolve_lane(explicit: Option, environment: &Environment) -> Result { + if let Some(lane) = explicit { + return Ok(lane.into()); } - Ok(environment_topology(environment)?.unwrap_or(StackMode::Dataplane)) + Ok(environment_lane(environment)?.unwrap_or(StackMode::Dataplane)) } -fn environment_topology(environment: &Environment) -> Result> { - let Some(value) = environment.get(OsStr::new(STACK_MODE_ENV)) else { +fn environment_lane(environment: &Environment) -> Result> { + let Some(value) = environment.get(OsStr::new(LANE_ENV)) else { return Ok(None); }; match value.to_str() { - Some("controlplane") => Ok(Some(StackMode::Controlplane)), - Some("dataplane") => Ok(Some(StackMode::Dataplane)), + Some("builtin") => Ok(Some(StackMode::Controlplane)), + Some("external") => Ok(Some(StackMode::Dataplane)), _ => bail!( - "invalid {STACK_MODE_ENV}; expected controlplane or dataplane (got {:?})", + "invalid {LANE_ENV}; expected builtin or external (got {:?})", value ), } } -/// Converts a CLI topology selection into its ordered stack modes. -pub(crate) fn selected_topologies(selection: TopologySelection) -> Vec { +/// Converts a CLI lane selection into its ordered stack modes. +pub(crate) fn selected_topologies(selection: LaneSelection) -> Vec { match selection { - TopologySelection::Controlplane => vec![StackMode::Controlplane], - TopologySelection::Dataplane => vec![StackMode::Dataplane], - TopologySelection::All => vec![StackMode::Controlplane, StackMode::Dataplane], + LaneSelection::Builtin => vec![StackMode::Controlplane], + LaneSelection::External => vec![StackMode::Dataplane], + LaneSelection::All => vec![StackMode::Controlplane, StackMode::Dataplane], } } -/// Converts one concrete stack mode into a CLI topology selection. -pub(crate) const fn topology_selection(topology: StackMode) -> TopologySelection { +/// Converts one concrete stack mode into a CLI lane selection. +pub(crate) const fn topology_selection(topology: StackMode) -> LaneSelection { match topology { - StackMode::Controlplane => TopologySelection::Controlplane, - StackMode::Dataplane => TopologySelection::Dataplane, + StackMode::Controlplane => LaneSelection::Builtin, + StackMode::Dataplane => LaneSelection::External, } } diff --git a/src/app_tests.rs b/src/app_tests.rs index e391fdc..0c093a7 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use cf_integration::app::{ Action, CiAction, ConformanceAction, DebugAction, ResolvedLoadArgs, StackAction, resolve_action, }; -use cf_integration::cli::{Cli, LiveGroup, ProtocolVersion, TokenKind, TopologySelection}; +use cf_integration::cli::{Cli, LaneSelection, LiveGroup, ProtocolVersion, TokenKind}; use cf_integration::conformance::results::{ConformanceServerEra, SemanticLane}; use cf_integration::infrastructure::StackMode; use cf_integration::infrastructure::config::Environment; @@ -103,55 +103,46 @@ fn ci_image_preparation_rejects_nested_artifact_paths() { } #[test] -fn every_subcommand_reports_its_resolved_topology_at_startup() { +fn every_subcommand_reports_its_resolved_lane_at_startup() { let cases: &[(&[&str], &str)] = &[ ( &["cf-integration", "stack", "up"], - "Topology: external dataplane\nProtocol version: 2026-07-28", + "Lane: external\nProtocol version: modern", ), ( &["cf-integration", "stack", "down"], - "Topology: built-in dataplane, external dataplane", - ), - ( - &["cf-integration", "stack", "status"], - "Topology: external dataplane", - ), - ( - &["cf-integration", "stack", "logs"], - "Topology: external dataplane", - ), - ( - &["cf-integration", "stack", "config"], - "Topology: external dataplane", + "Lane: builtin, external", ), + (&["cf-integration", "stack", "status"], "Lane: external"), + (&["cf-integration", "stack", "logs"], "Lane: external"), + (&["cf-integration", "stack", "config"], "Lane: external"), ( &["cf-integration", "probe"], - "Topology: external dataplane\nProtocol version: 2026-07-28", + "Lane: external\nProtocol version: modern", ), ( &["cf-integration", "load"], - "Topology: external dataplane\nProtocol version: 2026-07-28", + "Lane: external\nProtocol version: modern", ), ( &["cf-integration", "live"], - "Topology: external dataplane\nProtocol version: 2026-07-28", + "Lane: external\nProtocol version: modern", ), ( &["cf-integration", "conformance", "run"], - "Topology: fixture direct, built-in dataplane, external dataplane\nClient era: modern [2026-07-28]\nServer era: legacy [2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25]; modern [2026-07-28]", + "Lane: fixture direct, builtin, external\nClient era: modern [2026-07-28]\nServer era: legacy [2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25]; modern [2026-07-28]", ), ( &["cf-integration", "conformance", "report"], - "Topology: recorded conformance results\nClient era: recorded conformance results\nServer era: recorded conformance results", + "Lane: recorded conformance results\nClient era: recorded conformance results\nServer era: recorded conformance results", ), ( &["cf-integration", "debug", "inspect"], - "Topology: external dataplane\nProtocol version: 2026-07-28", + "Lane: external\nProtocol version: modern", ), ( &["cf-integration", "debug", "token", "--kind", "admin"], - "Topology: not applicable (token only)", + "Lane: not applicable (token only)", ), ]; @@ -168,7 +159,7 @@ fn conformance_startup_reports_every_selected_client_and_server_protocol() { "conformance", "run", "--lane", - "built-in-data-plane", + "builtin", "--client-era", "legacy", "--client-era", @@ -181,7 +172,7 @@ fn conformance_startup_reports_every_selected_client_and_server_protocol() { assert_eq!( resolved.startup_summary(), - "Topology: built-in dataplane\nClient era: legacy [2025-06-18, 2025-11-25]; modern [2026-07-28]\nServer era: dual [2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, 2026-07-28]" + "Lane: builtin\nClient era: legacy [2025-06-18, 2025-11-25]; modern [2026-07-28]\nServer era: dual [2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, 2026-07-28]" ); } @@ -202,7 +193,7 @@ fn conformance_startup_labels_both_legacy_era_selections() { assert_eq!( resolved.startup_summary(), - "Topology: fixture direct, built-in dataplane, external dataplane\nClient era: legacy [2025-06-18, 2025-11-25]\nServer era: legacy [2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25]" + "Lane: fixture direct, builtin, external\nClient era: legacy [2025-06-18, 2025-11-25]\nServer era: legacy [2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25]" ); } @@ -216,7 +207,7 @@ fn multi_phase_commands_own_detailed_progress_while_simple_commands_use_global_p } #[test] -fn topology_precedence_is_cli_then_environment_then_dataplane() { +fn lane_precedence_is_cli_then_environment_then_external() { assert_eq!( action(&["cf-integration", "probe"], &[]), Action::Probe { @@ -225,10 +216,7 @@ fn topology_precedence_is_cli_then_environment_then_dataplane() { } ); assert_eq!( - action( - &["cf-integration", "probe"], - &[("CF_MCP_STACK_MODE", "controlplane")], - ), + action(&["cf-integration", "probe"], &[("CF_MCP_LANE", "builtin")],), Action::Probe { topology: StackMode::Controlplane, protocol_version: ProtocolVersion::default(), @@ -240,29 +228,44 @@ fn topology_precedence_is_cli_then_environment_then_dataplane() { "cf-integration", "probe", "--lane", - "dataplane", + "external", "--protocol-version", - "2025-06-18", + "legacy", ], - &[("CF_MCP_STACK_MODE", "invalid")], + &[("CF_MCP_LANE", "invalid")], ), Action::Probe { topology: StackMode::Dataplane, - protocol_version: "2025-06-18" - .parse::() - .expect("valid protocol version"), + protocol_version: ProtocolVersion::Legacy, } ); } #[test] -fn invalid_environment_topology_is_rejected_when_used() { +fn invalid_environment_lane_is_rejected_when_used() { let cli = Cli::try_parse_from(["cf-integration", "probe"]).expect("CLI should parse"); - let environment = [(OsString::from("CF_MCP_STACK_MODE"), OsString::from("bad"))] + let environment = [(OsString::from("CF_MCP_LANE"), OsString::from("bad"))] .into_iter() .collect(); - let error = resolve_action(cli, &environment).expect_err("invalid topology must fail"); - assert!(error.to_string().contains("invalid CF_MCP_STACK_MODE")); + let error = resolve_action(cli, &environment).expect_err("invalid lane must fail"); + assert!(error.to_string().contains("invalid CF_MCP_LANE")); +} + +#[test] +fn date_based_protocol_environment_is_rejected() { + let cli = Cli::try_parse_from(["cf-integration", "probe"]).expect("CLI should parse"); + let environment = [( + OsString::from("MCP_PROTOCOL_VERSION"), + OsString::from("2026-07-28"), + )] + .into_iter() + .collect(); + let error = resolve_action(cli, &environment).expect_err("wire revisions must remain internal"); + + assert_eq!( + error.to_string(), + "invalid MCP_PROTOCOL_VERSION: must be modern or legacy" + ); } #[test] @@ -273,8 +276,8 @@ fn stack_actions_resolve_freshness_and_volume_cleanup() { "cf-integration", "stack", "up", - "--topology", - "controlplane", + "--lane", + "builtin", "--fresh", ], &[], @@ -287,7 +290,7 @@ fn stack_actions_resolve_freshness_and_volume_cleanup() { assert_eq!( action(&["cf-integration", "stack", "down", "--volumes"], &[],), Action::Stack(StackAction::Down { - topology: TopologySelection::All, + lane: LaneSelection::All, volumes: true, }) ); @@ -301,9 +304,9 @@ fn load_preserves_explicit_locust_settings() { "cf-integration", "load", "--lane", - "controlplane", + "builtin", "--protocol-version", - "2025-06-18", + "legacy", "--smoke", "--users", "2", @@ -316,9 +319,7 @@ fn load_preserves_explicit_locust_settings() { ), Action::Load(ResolvedLoadArgs { topology: StackMode::Controlplane, - protocol_version: "2025-06-18" - .parse::() - .expect("valid protocol version"), + protocol_version: ProtocolVersion::Legacy, request: LoadRequest { smoke: true, users: Some(2), @@ -335,16 +336,14 @@ fn live_resolves_lane_group_and_protocol_version() { action( &["cf-integration", "live", "--group", "mcp"], &[ - ("CF_MCP_STACK_MODE", "controlplane"), - ("MCP_PROTOCOL_VERSION", "2025-06-18"), + ("CF_MCP_LANE", "builtin"), + ("MCP_PROTOCOL_VERSION", "legacy"), ], ), Action::Live { lane: SemanticLane::BuiltInDataPlane, group: LiveGroup::Mcp, - protocol_version: "2025-06-18" - .parse::() - .expect("valid protocol version"), + protocol_version: ProtocolVersion::Legacy, } ); } @@ -361,19 +360,17 @@ fn live_fixture_lane_bypasses_topology_and_cli_version_wins() { "--group", "protocol", "--protocol-version", - "2025-03-26", + "modern", ], &[ - ("CF_MCP_STACK_MODE", "invalid"), - ("MCP_PROTOCOL_VERSION", "2025-06-18"), + ("CF_MCP_LANE", "invalid"), + ("MCP_PROTOCOL_VERSION", "legacy"), ], ), Action::Live { lane: SemanticLane::FixtureDirect, group: LiveGroup::Protocol, - protocol_version: "2025-03-26" - .parse::() - .expect("valid protocol version"), + protocol_version: ProtocolVersion::Modern, } ); } @@ -428,11 +425,11 @@ fn conformance_lanes_are_deduplicated_and_normalized() { "conformance", "run", "--lane", - "external-data-plane", + "external", "--lane", "fixture-direct", "--lane", - "external-data-plane", + "external", "--client-era", "legacy", "--client-era", @@ -500,13 +497,7 @@ fn only_report_and_token_actions_skip_runtime_assets() { &[], ); let stack = action( - &[ - "cf-integration", - "stack", - "status", - "--topology", - "dataplane", - ], + &["cf-integration", "stack", "status", "--lane", "external"], &[], ); @@ -542,9 +533,9 @@ fn debug_token_and_inspector_remain_explicit_non_gate_operations() { "debug", "inspect", "--lane", - "controlplane", + "builtin", "--protocol-version", - "2025-06-18", + "legacy", "--method", "prompts/list", ], @@ -552,9 +543,7 @@ fn debug_token_and_inspector_remain_explicit_non_gate_operations() { ), Action::Debug(DebugAction::Inspect { topology: StackMode::Controlplane, - protocol_version: "2025-06-18" - .parse::() - .expect("valid protocol version"), + protocol_version: ProtocolVersion::Legacy, method: "prompts/list".to_owned(), server_id: None, }) diff --git a/src/cli.rs b/src/cli.rs index 9566556..1c0bf4a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,12 +5,12 @@ use std::fmt; use std::path::PathBuf; use std::str::FromStr; -use crate::mcp::protocol::PROTOCOL_VERSION; +use crate::mcp::protocol::{LEGACY_PROTOCOL_VERSION, PROTOCOL_VERSION}; use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum}; const RUN_TIME_ERROR: &str = "must be a positive Locust duration using h, m, and s at most once in that order"; -const PROTOCOL_VERSION_ERROR: &str = "must use the MCP YYYY-MM-DD version format"; +const PROTOCOL_VERSION_ERROR: &str = "must be modern or legacy"; fn parse_positive_usize(value: &str) -> Result { let parsed = value @@ -75,7 +75,7 @@ fn parse_run_time(value: &str) -> Result { Ok(value.to_owned()) } -/// Orchestrates control-plane and dataplane integration workflows. +/// Orchestrates built-in and external dataplane integration workflows. #[derive(Debug, Clone, PartialEq, Parser)] #[command(name = "cf-integration", version, arg_required_else_help = true)] pub(crate) struct Cli { @@ -170,24 +170,24 @@ pub(crate) struct StackArgs { /// Operation on one or more Compose stacks. #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub(crate) enum StackCommand { - /// Start one stack topology. + /// Start one execution lane. Up(StackUpArgs), - /// Stop one or both stack topologies. + /// Stop one or both execution lanes. Down(StackDownArgs), - /// Show services for one stack topology. - Status(TopologyArgs), - /// Follow logs for one stack topology. + /// Show services for one execution lane. + Status(StackLaneArgs), + /// Follow logs for one execution lane. Logs(StackLogsArgs), - /// Render the merged configuration for one stack topology. - Config(TopologyArgs), + /// Render the merged configuration for one execution lane. + Config(StackLaneArgs), } /// Options for starting one stack. #[derive(Debug, Clone, PartialEq, Eq, Args)] pub(crate) struct StackUpArgs { - /// Stack topology; defaults to CF_MCP_STACK_MODE, then dataplane. + /// Execution lane; defaults to CF_MCP_LANE, then external. #[arg(long, value_enum)] - pub(crate) topology: Option, + pub(crate) lane: Option, /// Remove existing stack volumes before starting. #[arg(long)] @@ -197,85 +197,85 @@ pub(crate) struct StackUpArgs { /// Options for stopping stacks. #[derive(Debug, Clone, PartialEq, Eq, Args)] pub(crate) struct StackDownArgs { - /// Stack topology; defaults to all. + /// Execution lane; defaults to all. #[arg(long, value_enum)] - pub(crate) topology: Option, + pub(crate) lane: Option, /// Remove persistent volumes as well as containers and networks. #[arg(long)] pub(crate) volumes: bool, } -/// A command targeting one stack topology. +/// A command targeting one stack lane. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub(crate) struct TopologyArgs { - /// Stack topology; defaults to CF_MCP_STACK_MODE, then dataplane. +pub(crate) struct StackLaneArgs { + /// Execution lane; defaults to CF_MCP_LANE, then external. #[arg(long, value_enum)] - pub(crate) topology: Option, + pub(crate) lane: Option, } /// Target selection for routed MCP workflows. #[derive(Debug, Clone, PartialEq, Eq, Args)] pub(crate) struct RoutedWorkflowTargetArgs { - /// Execution lane; defaults to CF_MCP_STACK_MODE, then dataplane. + /// Execution lane; defaults to CF_MCP_LANE, then external. #[arg(long, value_enum)] - pub(crate) lane: Option, + pub(crate) lane: Option, - /// MCP version; defaults to MCP_PROTOCOL_VERSION, then 2026-07-28. - #[arg(long)] + /// MCP mode; defaults to MCP_PROTOCOL_VERSION, then modern. + #[arg(long, value_enum)] pub(crate) protocol_version: Option, } /// Target selection for MCP workflows that support a direct fixture lane. #[derive(Debug, Clone, PartialEq, Eq, Args)] pub(crate) struct WorkflowTargetArgs { - /// Execution lane; defaults to CF_MCP_STACK_MODE, then dataplane. + /// Execution lane; defaults to CF_MCP_LANE, then external. #[arg(long, value_enum)] pub(crate) lane: Option, - /// MCP version; defaults to MCP_PROTOCOL_VERSION, then 2026-07-28. - #[arg(long)] + /// MCP mode; defaults to MCP_PROTOCOL_VERSION, then modern. + #[arg(long, value_enum)] pub(crate) protocol_version: Option, } /// Options for following stack logs. #[derive(Debug, Clone, PartialEq, Eq, Args)] pub(crate) struct StackLogsArgs { - /// Stack topology; defaults to CF_MCP_STACK_MODE, then dataplane. + /// Execution lane; defaults to CF_MCP_LANE, then external. #[arg(long, value_enum)] - pub(crate) topology: Option, + pub(crate) lane: Option, /// Services whose logs to follow; all services when omitted. #[arg(value_name = "SERVICE")] pub(crate) services: Vec, } -/// A live stack topology. +/// A routed MCP execution lane. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub(crate) enum CliTopology { - /// Python control plane only. - Controlplane, - /// Python control plane routed through the Rust dataplane. - Dataplane, -} - -impl From for crate::infrastructure::StackMode { - fn from(topology: CliTopology) -> Self { - match topology { - CliTopology::Controlplane => Self::Controlplane, - CliTopology::Dataplane => Self::Dataplane, +pub(crate) enum CliRoutedLane { + /// Route through the Python built-in dataplane. + Builtin, + /// Route through the external Rust dataplane. + External, +} + +impl From for crate::infrastructure::StackMode { + fn from(lane: CliRoutedLane) -> Self { + match lane { + CliRoutedLane::Builtin => Self::Controlplane, + CliRoutedLane::External => Self::Dataplane, } } } -/// One or both stack topologies. +/// One or both routed MCP execution lanes. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub(crate) enum TopologySelection { - /// Python control plane only. - Controlplane, - /// Python control plane routed through the Rust dataplane. - Dataplane, - /// Run controlplane and dataplane sequentially. +pub(crate) enum LaneSelection { + /// Route through the Python built-in dataplane. + Builtin, + /// Route through the external Rust dataplane. + External, + /// Run the built-in and external lanes sequentially. All, } @@ -321,9 +321,9 @@ pub(crate) enum CliLane { /// Run directly against the workflow's reference fixture. FixtureDirect, /// Run the routed endpoint through the Python built-in dataplane. - BuiltInDataPlane, + Builtin, /// Run the routed endpoint through the external Rust data plane. - ExternalDataPlane, + External, } /// Upstream live-test group. @@ -339,27 +339,33 @@ pub(crate) enum LiveGroup { All, } -/// A syntactically valid date-based MCP protocol version shared by workflows. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ProtocolVersion(String); +/// Semantic MCP protocol mode shared by operational workflows. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] +pub(crate) enum ProtocolVersion { + /// Use the latest per-request, stateless MCP revision. + #[default] + Modern, + /// Use the latest initialization-based MCP revision. + Legacy, +} impl ProtocolVersion { - /// Returns the exact selected MCP protocol version. + /// Returns the exact MCP wire revision selected by this mode. #[must_use] - pub(crate) fn as_str(&self) -> &str { - &self.0 - } -} - -impl Default for ProtocolVersion { - fn default() -> Self { - Self(PROTOCOL_VERSION.to_owned()) + pub(crate) const fn wire_version(self) -> &'static str { + match self { + Self::Modern => PROTOCOL_VERSION, + Self::Legacy => LEGACY_PROTOCOL_VERSION, + } } } impl fmt::Display for ProtocolVersion { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.0) + formatter.write_str(match self { + Self::Modern => "modern", + Self::Legacy => "legacy", + }) } } @@ -367,18 +373,10 @@ impl FromStr for ProtocolVersion { type Err = String; fn from_str(value: &str) -> Result { - let bytes = value.as_bytes(); - let valid = bytes.len() == 10 - && bytes[4] == b'-' - && bytes[7] == b'-' - && bytes - .iter() - .enumerate() - .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()); - if valid { - Ok(Self(value.to_owned())) - } else { - Err(String::from(PROTOCOL_VERSION_ERROR)) + match value { + "modern" => Ok(Self::Modern), + "legacy" => Ok(Self::Legacy), + _ => Err(String::from(PROTOCOL_VERSION_ERROR)), } } } @@ -436,8 +434,8 @@ impl From for crate::conformance::results::SemanticLane { fn from(lane: CliLane) -> Self { match lane { CliLane::FixtureDirect => Self::FixtureDirect, - CliLane::BuiltInDataPlane => Self::BuiltInDataPlane, - CliLane::ExternalDataPlane => Self::ExternalDataPlane, + CliLane::Builtin => Self::BuiltInDataPlane, + CliLane::External => Self::ExternalDataPlane, } } } diff --git a/src/cli_public_tests.rs b/src/cli_public_tests.rs index 8ec14c4..c837028 100644 --- a/src/cli_public_tests.rs +++ b/src/cli_public_tests.rs @@ -1,9 +1,9 @@ use std::ffi::OsString; use cf_integration::cli::{ - Cli, CliConformanceEra, CliLane, CliTopology, Command, ConformanceArgs, ConformanceCommand, - DebugArgs, DebugCommand, LiveGroup, LoadArgs, ProtocolVersion, RoutedWorkflowTargetArgs, - StackArgs, StackCommand, TokenKind, TopologySelection, WorkflowTargetArgs, + Cli, CliConformanceEra, CliLane, CliRoutedLane, Command, ConformanceArgs, ConformanceCommand, + DebugArgs, DebugCommand, LaneSelection, LiveGroup, LoadArgs, ProtocolVersion, + RoutedWorkflowTargetArgs, StackArgs, StackCommand, TokenKind, WorkflowTargetArgs, }; use clap::{CommandFactory, Parser, error::ErrorKind}; @@ -100,6 +100,38 @@ fn every_public_command_renders_help() { } } +#[test] +fn every_public_stack_or_workflow_selector_uses_lane_only() { + let paths: &[&[&str]] = &[ + &["stack", "up"], + &["stack", "down"], + &["stack", "status"], + &["stack", "logs"], + &["stack", "config"], + &["probe"], + &["load"], + &["live"], + &["conformance", "run"], + &["debug", "inspect"], + ]; + + for path in paths { + let command = command_at(path); + let argument_ids = command + .get_arguments() + .map(|argument| argument.get_id().as_str()) + .collect::>(); + assert!( + argument_ids.contains(&"lane"), + "missing --lane for {path:?}" + ); + assert!( + !argument_ids.contains(&"topology"), + "obsolete --topology remains on {path:?}" + ); + } +} + #[test] fn obsolete_root_commands_and_combined_workflows_are_rejected() { for command in REMOVED_COMMANDS { @@ -118,15 +150,15 @@ fn stack_up_and_down_make_destructive_behavior_explicit() { "cf-integration", "stack", "up", - "--topology", - "dataplane", + "--lane", + "external", "--fresh", ]) .command else { panic!("expected stack up") }; - assert_eq!(up.topology, Some(CliTopology::Dataplane)); + assert_eq!(up.lane, Some(CliRoutedLane::External)); assert!(up.fresh); let Command::Stack(StackArgs { @@ -135,7 +167,7 @@ fn stack_up_and_down_make_destructive_behavior_explicit() { "cf-integration", "stack", "down", - "--topology", + "--lane", "all", "--volumes", ]) @@ -143,7 +175,7 @@ fn stack_up_and_down_make_destructive_behavior_explicit() { else { panic!("expected stack down") }; - assert_eq!(down.topology, Some(TopologySelection::All)); + assert_eq!(down.lane, Some(LaneSelection::All)); assert!(down.volumes); } @@ -155,8 +187,8 @@ fn stack_logs_preserve_service_arguments() { "cf-integration", "stack", "logs", - "--topology", - "controlplane", + "--lane", + "builtin", "gateway", "worker", ]) @@ -164,7 +196,7 @@ fn stack_logs_preserve_service_arguments() { else { panic!("expected stack logs") }; - assert_eq!(args.topology, Some(CliTopology::Controlplane)); + assert_eq!(args.lane, Some(CliRoutedLane::Builtin)); assert_eq!( args.services, [OsString::from("gateway"), OsString::from("worker")] @@ -224,7 +256,7 @@ fn live_defaults_to_all_and_accepts_the_main_harness_groups() { "cf-integration", "live", "--lane", - "external-data-plane", + "external", "--group", name, ]) @@ -232,13 +264,13 @@ fn live_defaults_to_all_and_accepts_the_main_harness_groups() { else { panic!("expected live workflow") }; - assert_eq!(args.target.lane, Some(CliLane::ExternalDataPlane)); + assert_eq!(args.target.lane, Some(CliLane::External)); assert_eq!(args.group, expected); } } #[test] -fn live_accepts_fixture_lane_and_explicit_protocol_version() { +fn live_accepts_fixture_lane_and_explicit_protocol_mode() { let Command::Live(args) = parse(&[ "cf-integration", "live", @@ -247,7 +279,7 @@ fn live_accepts_fixture_lane_and_explicit_protocol_version() { "--group", "protocol", "--protocol-version", - "2025-06-18", + "legacy", ]) .command else { @@ -256,67 +288,69 @@ fn live_accepts_fixture_lane_and_explicit_protocol_version() { assert_eq!(args.target.lane, Some(CliLane::FixtureDirect)); assert_eq!(args.group, LiveGroup::Protocol); - assert_eq!( - args.target.protocol_version, - Some( - "2025-06-18" - .parse::() - .expect("valid protocol version") - ) - ); + assert_eq!(args.target.protocol_version, Some(ProtocolVersion::Legacy)); + assert_eq!(ProtocolVersion::Legacy.wire_version(), "2025-11-25"); + assert_eq!(ProtocolVersion::Modern.wire_version(), "2026-07-28"); rejected(&["cf-integration", "live", "--protocol-version", "latest"]); + rejected(&["cf-integration", "live", "--protocol-version", "2026-07-28"]); rejected(&["cf-integration", "live", "--lane", "fixture"]); } #[test] -fn probe_rejects_removed_topology_alias() { - rejected(&["cf-integration", "probe", "--topology", "dataplane"]); -} - -#[test] -fn load_rejects_removed_topology_alias() { - rejected(&["cf-integration", "load", "--topology", "dataplane"]); +fn every_public_selector_rejects_the_removed_topology_flag() { + for arguments in [ + vec!["cf-integration", "stack", "up", "--topology", "dataplane"], + vec!["cf-integration", "probe", "--topology", "dataplane"], + vec!["cf-integration", "load", "--topology", "dataplane"], + vec!["cf-integration", "live", "--topology", "dataplane"], + vec![ + "cf-integration", + "debug", + "inspect", + "--topology", + "dataplane", + ], + ] { + rejected(&arguments); + } } #[test] -fn live_rejects_removed_topology_alias() { - rejected(&[ - "cf-integration", - "live", - "--topology", - "external-data-plane", - ]); +fn public_lane_values_reject_physical_and_obsolete_spellings() { + for arguments in [ + vec!["cf-integration", "stack", "up", "--lane", "controlplane"], + vec!["cf-integration", "stack", "up", "--lane", "dataplane"], + vec!["cf-integration", "load", "--lane", "controlplane"], + vec!["cf-integration", "load", "--lane", "dataplane"], + vec!["cf-integration", "live", "--lane", "built-in-data-plane"], + vec!["cf-integration", "live", "--lane", "external-data-plane"], + vec![ + "cf-integration", + "conformance", + "run", + "--lane", + "external-data-plane", + ], + ] { + rejected(&arguments); + } } #[test] fn operational_workflows_share_canonical_lane_and_protocol_version_flags() { fn assert_routed_target(target: &RoutedWorkflowTargetArgs) { - assert_eq!(target.lane, Some(CliTopology::Controlplane)); - assert_eq!( - target.protocol_version, - Some( - "2025-06-18" - .parse::() - .expect("valid protocol version") - ) - ); + assert_eq!(target.lane, Some(CliRoutedLane::Builtin)); + assert_eq!(target.protocol_version, Some(ProtocolVersion::Legacy)); } fn assert_fixture_target(target: &WorkflowTargetArgs) { - assert_eq!(target.lane, Some(CliLane::BuiltInDataPlane)); - assert_eq!( - target.protocol_version, - Some( - "2025-06-18" - .parse::() - .expect("valid protocol version") - ) - ); + assert_eq!(target.lane, Some(CliLane::Builtin)); + assert_eq!(target.protocol_version, Some(ProtocolVersion::Legacy)); } - let common = ["--lane", "controlplane", "--protocol-version", "2025-06-18"]; + let common = ["--lane", "builtin", "--protocol-version", "legacy"]; let Command::Probe(probe) = parse( &["cf-integration", "probe"] .into_iter() @@ -345,9 +379,9 @@ fn operational_workflows_share_canonical_lane_and_protocol_version_flags() { "cf-integration", "live", "--lane", - "built-in-data-plane", + "builtin", "--protocol-version", - "2025-06-18", + "legacy", ]) .command else { @@ -416,7 +450,7 @@ fn conformance_accepts_repeatable_exact_lanes_and_protocol_eras() { "--lane", "fixture-direct", "--lane", - "external-data-plane", + "external", "--client-era", "legacy", "--client-era", @@ -435,10 +469,7 @@ fn conformance_accepts_repeatable_exact_lanes_and_protocol_eras() { else { panic!("expected conformance run") }; - assert_eq!( - args.lane, - [CliLane::FixtureDirect, CliLane::ExternalDataPlane] - ); + assert_eq!(args.lane, [CliLane::FixtureDirect, CliLane::External]); assert_eq!( args.client_era, [CliConformanceEra::Legacy, CliConformanceEra::Dual] diff --git a/src/conformance/results.rs b/src/conformance/results.rs index a61fc98..a0a535f 100644 --- a/src/conformance/results.rs +++ b/src/conformance/results.rs @@ -165,8 +165,8 @@ impl SemanticLane { pub(crate) const fn label(self) -> &'static str { match self { Self::FixtureDirect => "fixture direct", - Self::BuiltInDataPlane => "built-in dataplane", - Self::ExternalDataPlane => "external dataplane", + Self::BuiltInDataPlane => "builtin", + Self::ExternalDataPlane => "external", } } diff --git a/src/conformance/results_tests.rs b/src/conformance/results_tests.rs index 554dcec..2ff926e 100644 --- a/src/conformance/results_tests.rs +++ b/src/conformance/results_tests.rs @@ -24,11 +24,8 @@ const SPEC_REFERENCE: &str = #[test] fn semantic_lanes_have_one_shared_stable_vocabulary() { assert_eq!(SemanticLane::FixtureDirect.label(), "fixture direct"); - assert_eq!(SemanticLane::BuiltInDataPlane.label(), "built-in dataplane"); - assert_eq!( - SemanticLane::ExternalDataPlane.label(), - "external dataplane" - ); + assert_eq!(SemanticLane::BuiltInDataPlane.label(), "builtin"); + assert_eq!(SemanticLane::ExternalDataPlane.label(), "external"); } #[test] diff --git a/src/infrastructure/mode.rs b/src/infrastructure/mode.rs index 87ce6cb..0d0e031 100644 --- a/src/infrastructure/mode.rs +++ b/src/infrastructure/mode.rs @@ -8,21 +8,21 @@ pub(crate) enum StackMode { } impl StackMode { - /// Semantic topology name shown to users. + /// Semantic lane name shown to users. #[must_use] - pub(crate) const fn topology_label(self) -> &'static str { + pub(crate) const fn lane_label(self) -> &'static str { match self { - Self::Controlplane => "built-in dataplane", - Self::Dataplane => "external dataplane", + Self::Controlplane => "builtin", + Self::Dataplane => "external", } } - /// Canonical physical topology value accepted by stack commands. + /// Canonical semantic lane value accepted by public commands. #[must_use] - pub(crate) const fn cli_value(self) -> &'static str { + pub(crate) const fn lane_value(self) -> &'static str { match self { - Self::Controlplane => "controlplane", - Self::Dataplane => "dataplane", + Self::Controlplane => "builtin", + Self::Dataplane => "external", } } } diff --git a/src/lib.rs b/src/lib.rs index 92d7b6b..4762520 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,7 @@ #[cfg(test)] extern crate self as cf_integration; -use std::process::ExitCode; +use std::{io::Write, process::ExitCode}; use clap::Parser; @@ -107,6 +107,9 @@ pub async fn run() -> ExitCode { } fn report_failure(error: AppFailure) -> ExitCode { + // Keep completed result output ahead of wrapper diagnostics such as Make's + // nonzero-exit message when stdout and stderr are captured separately. + let _ = std::io::stdout().flush(); if !error.is_reported() { eprintln!("{}", OutputStyle::stderr().failure(&error.to_string())); } diff --git a/src/mcp/protocol.rs b/src/mcp/protocol.rs index d72ebca..c26efe2 100644 --- a/src/mcp/protocol.rs +++ b/src/mcp/protocol.rs @@ -6,6 +6,8 @@ use uuid::Uuid; /// Latest MCP protocol version used when a workflow does not select one explicitly. pub(crate) const PROTOCOL_VERSION: &str = "2026-07-28"; +/// Latest initialization-based MCP protocol version used by legacy workflows. +pub(crate) const LEGACY_PROTOCOL_VERSION: &str = "2025-11-25"; /// Stateless MCP protocol version used by the modern dataplane lane. pub(crate) const STATELESS_PROTOCOL_VERSION: &str = "2026-07-28"; /// Accepted MCP streamable-HTTP response media types. @@ -37,6 +39,19 @@ pub(crate) fn is_stateless_protocol(protocol_version: &str) -> bool { protocol_version >= STATELESS_PROTOCOL_VERSION } +/// Returns whether a value has the date-based syntax used by MCP revisions. +#[must_use] +pub(crate) fn is_protocol_revision(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes + .iter() + .enumerate() + .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) +} + /// Builds the mandatory per-request metadata for stateless MCP requests. #[must_use] pub(crate) fn request_metadata(protocol_version: &str) -> Value { @@ -196,3 +211,15 @@ pub(crate) fn tool_call_args(tool_name: &str) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protocol_revision_requires_the_date_based_wire_syntax() { + assert!(is_protocol_revision("2026-07-28")); + assert!(!is_protocol_revision("modern")); + assert!(!is_protocol_revision("2026-7-28")); + } +} diff --git a/src/runtime/conformance/mod.rs b/src/runtime/conformance/mod.rs index 11d5b66..4b1a24a 100644 --- a/src/runtime/conformance/mod.rs +++ b/src/runtime/conformance/mod.rs @@ -625,6 +625,11 @@ impl RuntimeContext { } expected_server_scenarios(DEFAULT_CONFORMANCE_SUITE, spec_version) .map_err(AppFailure::from)?; + let run_external_client = spec_version == DEFAULT_MCP_SPEC_VERSION + && lanes.contains(&SemanticLane::ExternalDataPlane); + if run_external_client { + expected_client_scenarios(spec_version).map_err(AppFailure::from)?; + } paths.clear_conformance()?; let topologies = conformance_topologies(lanes); @@ -633,6 +638,7 @@ impl RuntimeContext { } let mut failures = Vec::new(); let mut interrupted = false; + let mut external_stack_retained = false; tokio::pin!(interrupt); let (cancellation_sender, cancellation_receiver) = tokio::sync::watch::channel(false); @@ -706,7 +712,7 @@ impl RuntimeContext { if !topologies.is_empty() && !interrupted { let cleanup_progress = Activity::spinner("Clear prior integration stacks"); - let cleanup_result = self.cleanup(TopologySelection::All, CleanupKind::Reset); + let cleanup_result = self.cleanup(LaneSelection::All, CleanupKind::Reset); cleanup_progress.finish(cleanup_result.is_ok()); if let Err(error) = cleanup_result { failures.push(ConformanceOperationalFailure::server( @@ -723,9 +729,9 @@ impl RuntimeContext { } let target = conformance_target(topology); let run_routed = lanes.contains(&target); - let stack_progress = - Activity::spinner(format!("Prepare {}", topology.topology_label())); + let stack_progress = Activity::spinner(format!("Prepare {}", topology.lane_label())); let mut topology_failure = self.stack_up_for_conformance(topology, true).await.err(); + let stack_started = topology_failure.is_none(); stack_progress.finish(topology_failure.is_none()); let mut fixture_state = None; let mut fixture_metadata = None; @@ -735,7 +741,7 @@ impl RuntimeContext { if topology_failure.is_none() { let fixture_progress = Activity::spinner(format!( "Start the official fixture for {}", - topology.topology_label() + topology.lane_label() )); let (start_result, start_interrupted) = finish_phase_after_interrupt( self.start_conformance_service(topology, server_era), @@ -776,7 +782,7 @@ impl RuntimeContext { Ok(client) => { let provision_progress = Activity::spinner(format!( "Register the official fixture for {}", - topology.topology_label() + topology.lane_label() )); let (provision_result, provision_interrupted) = finish_phase_after_interrupt( @@ -896,16 +902,20 @@ impl RuntimeContext { .err(); } - topology_failure = finish_with_cleanup( - topology_failure, - self.cleanup(topology_selection(topology), CleanupKind::Down), - ) - .err(); + if can_reuse_external_stack(topology, stack_started, interrupted, run_external_client) { + external_stack_retained = true; + } else { + topology_failure = finish_with_cleanup( + topology_failure, + self.cleanup(topology_selection(topology), CleanupKind::Down), + ) + .err(); + } if let Some(error) = topology_failure { failures.push(ConformanceOperationalFailure::server( Some(target), "run", - format!("{} topology: {error}", topology.topology_label()), + format!("{} lane: {error}", topology.lane_label()), )); } if interrupted { @@ -914,15 +924,13 @@ impl RuntimeContext { } } - if !interrupted - && spec_version == DEFAULT_MCP_SPEC_VERSION - && lanes.contains(&SemanticLane::ExternalDataPlane) - { + if !interrupted && run_external_client { let client = self.run_external_client_conformance( spec_version, server_era, paths, cancellation_receiver.clone(), + external_stack_retained, ); tokio::pin!(client); tokio::select! { @@ -1039,11 +1047,16 @@ impl RuntimeContext { server_era: ConformanceServerEra, paths: &ConformancePaths, cancellation: tokio::sync::watch::Receiver, + reuse_stack: bool, ) -> AppResult<()> { - expected_client_scenarios(spec_version).map_err(AppFailure::from)?; - let stack_progress = Activity::spinner("Prepare external dataplane client conformance"); + let progress = if reuse_stack { + "Reuse external dataplane for client conformance" + } else { + "Prepare external dataplane client conformance" + }; + let stack_progress = Activity::spinner(progress); let stack_result = self - .stack_up_for_conformance(StackMode::Dataplane, true) + .stack_up_for_conformance(StackMode::Dataplane, !reuse_stack) .await; stack_progress.finish(stack_result.is_ok()); let mut failure = stack_result.err(); @@ -1638,6 +1651,15 @@ fn conformance_topologies(lanes: &[SemanticLane]) -> Vec { topologies } +fn can_reuse_external_stack( + topology: StackMode, + stack_started: bool, + interrupted: bool, + run_external_client: bool, +) -> bool { + topology == StackMode::Dataplane && stack_started && !interrupted && run_external_client +} + fn parse_conformance_fixture_endpoint(output: &[u8]) -> anyhow::Result { let output = std::str::from_utf8(output).context("Compose fixture port output is not UTF-8")?; let address = output @@ -1697,7 +1719,7 @@ fn combine_cleanup_results(first: AppResult<()>, second: AppResult<()>) -> AppRe fn fixture_registration_context(topology: StackMode, server_era: ConformanceServerEra) -> String { format!( "ContextForge could not register the official fixture for {} with server era {} [{}]; routed tests for this lane were skipped", - topology.topology_label(), + topology.lane_label(), server_era.label(), server_era.protocol_versions_label() ) @@ -1875,6 +1897,24 @@ mod tests { ); } + #[test] + fn external_stack_is_reused_only_for_a_started_uninterrupted_client_run() { + let cases = [ + (StackMode::Dataplane, true, false, true, true), + (StackMode::Controlplane, true, false, true, false), + (StackMode::Dataplane, false, false, true, false), + (StackMode::Dataplane, true, true, true, false), + (StackMode::Dataplane, true, false, false, false), + ]; + + for (topology, stack_started, interrupted, run_client, expected) in cases { + assert_eq!( + can_reuse_external_stack(topology, stack_started, interrupted, run_client), + expected, + ); + } + } + #[test] fn direct_fixture_endpoint_accepts_only_loopback_bindings() { assert_eq!( @@ -1931,7 +1971,7 @@ mod tests { assert_eq!( context, - "ContextForge could not register the official fixture for built-in dataplane with server era modern [2026-07-28]; routed tests for this lane were skipped" + "ContextForge could not register the official fixture for builtin with server era modern [2026-07-28]; routed tests for this lane were skipped" ); } @@ -1999,7 +2039,7 @@ mod tests { assert_eq!( rendered, - "────────────\n MCP server conformance results: external dataplane\n Client era: modern [2026-07-28]\n Server era: legacy [2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25]\n XFAIL (1/2) server::external-data-plane::failing\n PASS (2/2) server::external-data-plane::passing\n────────────\n Summary [ 1.250s] 1 passed, 1 xfailed, 0 xpassed, 0 failed, 0 skipped, 0 unknown" + "────────────\n MCP server conformance results: external\n Client era: modern [2026-07-28]\n Server era: legacy [2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25]\n XFAIL (1/2) server::external-data-plane::failing\n PASS (2/2) server::external-data-plane::passing\n────────────\n Summary [ 1.250s] 1 passed, 1 xfailed, 0 xpassed, 0 failed, 0 skipped, 0 unknown" ); } @@ -2062,7 +2102,7 @@ mod tests { assert_eq!( rendered, - "────────────\n MCP server conformance results: external dataplane\n Client era: modern [2026-07-28]\n Server era: modern [2026-07-28]\n FAIL (1/2) server::external-data-plane::failing\n PASS (2/2) server::external-data-plane::passing\n────────────\n Summary [ 1.250s] 1 passed, 0 xfailed, 0 xpassed, 1 failed, 0 skipped, 0 unknown" + "────────────\n MCP server conformance results: external\n Client era: modern [2026-07-28]\n Server era: modern [2026-07-28]\n FAIL (1/2) server::external-data-plane::failing\n PASS (2/2) server::external-data-plane::passing\n────────────\n Summary [ 1.250s] 1 passed, 0 xfailed, 0 xpassed, 1 failed, 0 skipped, 0 unknown" ); } @@ -2077,7 +2117,7 @@ mod tests { OutputStyle::plain(), ); - assert!(rendered.contains("MCP client conformance results: external dataplane")); + assert!(rendered.contains("MCP client conformance results: external")); assert!(rendered.contains("Client era: modern [2026-07-28]")); assert!(rendered.contains("Server era: modern [2026-07-28]")); assert!(rendered.contains("client::external-data-plane::failing")); diff --git a/src/runtime/conformance/reports.rs b/src/runtime/conformance/reports.rs index a73bae1..f43f99b 100644 --- a/src/runtime/conformance/reports.rs +++ b/src/runtime/conformance/reports.rs @@ -367,11 +367,11 @@ fn discover_conformance_runs( .file_name() .into_string() .map_err(|_| AppFailure::from(anyhow!("client-version directory is not UTF-8")))?; - ProtocolVersion::from_str(&client_version).map_err(|error| { - AppFailure::from(anyhow!( - "invalid conformance client-version directory {client_version:?}: {error}" - )) - })?; + if !crate::mcp::protocol::is_protocol_revision(&client_version) { + return Err(AppFailure::from(anyhow!( + "invalid conformance client-version directory {client_version:?}: must use the MCP YYYY-MM-DD version format" + ))); + } for era_entry in strict_directories(&version_entry.path(), "server-era")? { let label = era_entry .file_name() diff --git a/src/runtime/inspect.rs b/src/runtime/inspect.rs index 481ee27..bdfb431 100644 --- a/src/runtime/inspect.rs +++ b/src/runtime/inspect.rs @@ -43,7 +43,7 @@ impl RuntimeContext { let proxy = AuthProxy::start_with_protocol_version( endpoint, &token, - Some(protocol_version.as_str()), + Some(protocol_version.wire_version()), ) .await .context("failed to start the Inspector authentication proxy") diff --git a/src/runtime/live/mod.rs b/src/runtime/live/mod.rs index d8d33f6..ab5109c 100644 --- a/src/runtime/live/mod.rs +++ b/src/runtime/live/mod.rs @@ -131,7 +131,7 @@ impl RuntimeContext { .join("scripts") .join("live_protocol"), inherited_python_path, - protocol_version.as_str(), + protocol_version.wire_version(), ) } } diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 2d523d8..678789d 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -52,7 +52,7 @@ use crate::app::{ Action, CiAction, ConformanceAction, DebugAction, ResolvedLoadArgs, StackAction, selected_topologies, topology_selection, }; -use crate::cli::{LiveGroup, ProtocolVersion, TokenKind as CliTokenKind, TopologySelection}; +use crate::cli::{LaneSelection, LiveGroup, ProtocolVersion, TokenKind as CliTokenKind}; use crate::error::AppFailure; use crate::{Activity, OutputStyle, TestStatus}; @@ -321,7 +321,7 @@ async fn wait_for_http_endpoint( if now >= deadline { return Err(AppFailure::from(anyhow!( "{} public MCP endpoint {} was not ready within {:.3}s; last result: {last_failure}", - mode.topology_label(), + mode.lane_label(), endpoint, timeout.as_secs_f64() ))); diff --git a/src/runtime/performance/mod.rs b/src/runtime/performance/mod.rs index c9f5310..373b494 100644 --- a/src/runtime/performance/mod.rs +++ b/src/runtime/performance/mod.rs @@ -16,7 +16,7 @@ impl RuntimeContext { &settings, &token, (args.topology == StackMode::Dataplane).then_some(operation_server_id.as_str()), - args.protocol_version.as_str(), + args.protocol_version.wire_version(), ) .map_err(AppFailure::from)?; let command_spec = @@ -52,7 +52,7 @@ impl RuntimeContext { "{}", OutputStyle::stdout().test_result( status, - &format!("performance::{}", args.topology.topology_label()), + &format!("performance::{}", args.topology.lane_label()), Some(elapsed), None, ) diff --git a/src/runtime/probe.rs b/src/runtime/probe.rs index eee3d34..760f4f5 100644 --- a/src/runtime/probe.rs +++ b/src/runtime/probe.rs @@ -22,7 +22,7 @@ impl RuntimeContext { request_timeout: Duration::from_secs( self.environment_u64("CF_PROBE_REQUEST_TIMEOUT", 30)?, ), - protocol_version: protocol_version.to_string(), + protocol_version: protocol_version.wire_version().to_owned(), output_style: OutputStyle::stdout(), }; let transport = GatewayClient::builder( diff --git a/src/runtime/stack/mod.rs b/src/runtime/stack/mod.rs index 514e7ac..146ae0c 100644 --- a/src/runtime/stack/mod.rs +++ b/src/runtime/stack/mod.rs @@ -4,6 +4,8 @@ mod sources; use super::*; +const COMPOSE_PROTOCOL_VERSION_ENV: &str = "MCP_PROTOCOL_VERSION"; + impl RuntimeContext { pub(super) async fn execute_stack(&self, action: StackAction) -> AppResult<()> { match action { @@ -37,8 +39,8 @@ impl RuntimeContext { Activity::completed("Integration stack ready"); self.print_stack_summary(topology, &conformance_endpoint) } - StackAction::Down { topology, volumes } => self.cleanup( - topology, + StackAction::Down { lane, volumes } => self.cleanup( + lane, if volumes { CleanupKind::Reset } else { @@ -164,7 +166,7 @@ impl RuntimeContext { if report_progress { println!( "{}", - OutputStyle::stdout().success(&format!("{} stack started.", mode.topology_label())) + OutputStyle::stdout().success(&format!("{} stack started.", mode.lane_label())) ); } Ok(()) @@ -182,7 +184,7 @@ impl RuntimeContext { OutputStyle::stderr().info(&format!( "Waiting up to {}s for the public {} MCP endpoint.", STACK_READY_TIMEOUT.as_secs(), - mode.topology_label() + mode.lane_label() )) ); } @@ -262,6 +264,12 @@ impl RuntimeContext { command = command.env(key.clone(), value.value.clone()); } } + if let Some(protocol_version) = compose_protocol_version( + &command_environment, + self.environment_text(COMPOSE_PROTOCOL_VERSION_ENV), + )? { + command = command.env(COMPOSE_PROTOCOL_VERSION_ENV, protocol_version); + } let (controlplane_pull_policy, dataplane_pull_policy) = compose_pull_policies( mode, false, @@ -809,19 +817,19 @@ impl RuntimeContext { &self.config.controlplane_project().value, "CF_CONTROLPLANE_PROJECT", )?, - StackMode::Controlplane.topology_label(), + StackMode::Controlplane.lane_label(), ), StackMode::Controlplane => ( required_text( &self.config.integration_project().value, "CF_INTEGRATION_PROJECT", )?, - StackMode::Dataplane.topology_label(), + StackMode::Dataplane.lane_label(), ), }; if self.project_has_running_containers(other)? { return Err(AppFailure::from(anyhow!( - "the {label} stack is running on the same host ports; run `cf-integration stack down --topology all` first" + "the {label} stack is running on the same host ports; run `cf-integration stack down --lane all` first" ))); } Ok(()) @@ -838,13 +846,13 @@ impl RuntimeContext { .is_empty()) } - pub(super) fn cleanup(&self, selection: TopologySelection, kind: CleanupKind) -> AppResult<()> { + pub(super) fn cleanup(&self, selection: LaneSelection, kind: CleanupKind) -> AppResult<()> { self.cleanup_with_output(selection, kind, true) } pub(super) fn cleanup_quiet( &self, - selection: TopologySelection, + selection: LaneSelection, kind: CleanupKind, ) -> AppResult<()> { self.cleanup_with_output(selection, kind, false) @@ -852,7 +860,7 @@ impl RuntimeContext { fn cleanup_with_output( &self, - selection: TopologySelection, + selection: LaneSelection, kind: CleanupKind, inherit_output: bool, ) -> AppResult<()> { @@ -1031,6 +1039,24 @@ fn require_preloaded_image(label: &str, image: &OsStr, local_exists: bool) -> Ap ))) } +fn compose_protocol_version( + command_environment: &BTreeMap, + configured: Option<&str>, +) -> AppResult> { + if command_environment.contains_key(OsStr::new(COMPOSE_PROTOCOL_VERSION_ENV)) { + return Ok(None); + } + let mode = configured + .filter(|value| !value.is_empty()) + .map(str::parse::) + .transpose() + .map_err(|error| { + AppFailure::from(anyhow!("invalid {COMPOSE_PROTOCOL_VERSION_ENV}: {error}")) + })? + .unwrap_or_default(); + Ok(Some(mode.wire_version())) +} + fn compose_pull_policies( mode: StackMode, build: bool, @@ -1228,6 +1254,36 @@ mod tests { ); } + #[test] + fn compose_translates_semantic_protocol_modes_to_wire_revisions() { + let command_environment = BTreeMap::new(); + + assert_eq!( + compose_protocol_version(&command_environment, None) + .expect("default protocol mode should resolve"), + Some("2026-07-28") + ); + assert_eq!( + compose_protocol_version(&command_environment, Some("legacy")) + .expect("legacy protocol mode should resolve"), + Some("2025-11-25") + ); + } + + #[test] + fn compose_preserves_an_explicit_internal_wire_revision() { + let command_environment = BTreeMap::from([( + OsString::from(COMPOSE_PROTOCOL_VERSION_ENV), + OsString::from("2025-11-25"), + )]); + + assert_eq!( + compose_protocol_version(&command_environment, Some("modern")) + .expect("explicit command environment should be preserved"), + None + ); + } + #[test] fn explicit_conformance_era_is_not_replaced_by_the_stack_default() { let command = with_default_conformance_server_era( diff --git a/src/runtime/stack/sources.rs b/src/runtime/stack/sources.rs index 93d7c82..e97249c 100644 --- a/src/runtime/stack/sources.rs +++ b/src/runtime/stack/sources.rs @@ -7,9 +7,9 @@ impl RuntimeContext { let controlplane_compose = self.config.controlplane_dir().join("docker-compose.yml"); if !controlplane_compose.is_file() { return Err(AppFailure::from(anyhow!( - "control-plane checkout is unavailable at {}; run `cf-integration stack up --topology {}` first", + "control-plane checkout is unavailable at {}; run `cf-integration stack up --lane {}` first", self.config.controlplane_dir().display(), - mode.cli_value() + mode.lane_value() ))); } if mode == StackMode::Dataplane @@ -17,7 +17,7 @@ impl RuntimeContext { && !self.config.dataplane_dir().is_dir() { return Err(AppFailure::from(anyhow!( - "dataplane source checkout is unavailable at {}; run `cf-integration stack up --topology dataplane` first", + "dataplane source checkout is unavailable at {}; run `cf-integration stack up --lane external` first", self.config.dataplane_dir().display() ))); }