From d40c48b43263f78bf4243f9a846d07a0c9e4a445 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Mon, 7 Sep 2026 16:17:35 +0100 Subject: [PATCH 1/6] Run standalone workflows against production dataplane images Signed-off-by: lucarlig --- CHANGELOG.md | 8 ++ Cargo.lock | 2 +- Cargo.toml | 7 +- README.md | 14 ++- .../docker-compose.cf-dataplane-config.yaml | 5 +- ...ocker-compose.cf-dataplane-standalone.yaml | 49 +++++----- docker/docker-compose.cf-dataplane.yaml | 7 +- scripts/conformance/Dockerfile | 4 + scripts/conformance/package-lock.json | 56 ++++++++++++ scripts/conformance/package.json | 9 ++ .../conformance/write_dataplane_config.mjs | 65 +++++-------- scripts/standalone/auth.mjs | 38 ++++++++ scripts/standalone/generate_auth_key.mjs | 25 ----- src/infrastructure/assets.rs | 6 +- src/infrastructure/compose.rs | 2 +- .../compose_integration_tests.rs | 39 ++++---- src/performance/python_adapter_tests.rs | 91 +++++++++++++++---- 17 files changed, 288 insertions(+), 139 deletions(-) create mode 100644 scripts/conformance/Dockerfile create mode 100644 scripts/conformance/package-lock.json create mode 100644 scripts/conformance/package.json create mode 100644 scripts/standalone/auth.mjs delete mode 100644 scripts/standalone/generate_auth_key.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 979ae07..d633fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,14 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [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 +31,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..ad83dbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,7 +177,7 @@ dependencies = [ [[package]] name = "cf-integration" -version = "0.3.1" +version = "0.3.2" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 35cd8cc..0660895 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" @@ -20,7 +20,10 @@ include = [ "/scripts/locustfile_mcp.py", "/scripts/live_protocol/sitecustomize.py", "/scripts/conformance/write_dataplane_config.mjs", - "/scripts/standalone/generate_auth_key.mjs", + "/scripts/conformance/package.json", + "/scripts/conformance/package-lock.json", + "/scripts/conformance/Dockerfile", + "/scripts/standalone/auth.mjs", "/tests/conformance/baselines/**", "/README.md", "/CHANGELOG.md", diff --git a/README.md b/README.md index b39ab0b..6c4007c 100644 --- a/README.md +++ b/README.md @@ -42,15 +42,21 @@ 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 installs pinned Redis and MessagePack packages on its first build. 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 diff --git a/docker/docker-compose.cf-dataplane-config.yaml b/docker/docker-compose.cf-dataplane-config.yaml index c557e41..b04fc8e 100644 --- a/docker/docker-compose.cf-dataplane-config.yaml +++ b/docker/docker-compose.cf-dataplane-config.yaml @@ -1,7 +1,10 @@ services: config_writer: profiles: ["helpers"] - image: node:22-bookworm-slim + image: cf-integration-helpers:0.3.2 + pull_policy: never + build: + context: ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/conformance labels: name: cf-dataplane-config-writer restart: "no" diff --git a/docker/docker-compose.cf-dataplane-standalone.yaml b/docker/docker-compose.cf-dataplane-standalone.yaml index c92b154..7655c89 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 + pull_policy: never + build: + context: ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/conformance 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 + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/standalone/auth.mjs:/opt/contextforge-integration/auth.mjs:ro + command: ["node", "/opt/contextforge-integration/auth.mjs"] + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4446/.well-known/jwks.json').then(r => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1))"] + interval: 2s + timeout: 2s + retries: 30 + start_period: 2s + + config_writer: + volumes: + - standalone_auth:/keys:ro redis: image: redis:8.2.8-alpine3.22 @@ -54,27 +66,18 @@ services: - 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 +95,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..7baecdf 100644 --- a/docker/docker-compose.cf-dataplane.yaml +++ b/docker/docker-compose.cf-dataplane.yaml @@ -47,12 +47,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/scripts/conformance/Dockerfile b/scripts/conformance/Dockerfile new file mode 100644 index 0000000..2b2a6c3 --- /dev/null +++ b/scripts/conformance/Dockerfile @@ -0,0 +1,4 @@ +FROM node:22-bookworm-slim +WORKDIR /opt/contextforge-integration +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force diff --git a/scripts/conformance/package-lock.json b/scripts/conformance/package-lock.json new file mode 100644 index 0000000..d2667f3 --- /dev/null +++ b/scripts/conformance/package-lock.json @@ -0,0 +1,56 @@ +{ + "name": "cf-integration-config-writer", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cf-integration-config-writer", + "dependencies": { + "@msgpack/msgpack": "3.1.3", + "@redis/client": "6.2.1" + } + }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@redis/client": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-6.2.1.tgz", + "integrity": "sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/scripts/conformance/package.json b/scripts/conformance/package.json new file mode 100644 index 0000000..6eb1543 --- /dev/null +++ b/scripts/conformance/package.json @@ -0,0 +1,9 @@ +{ + "name": "cf-integration-config-writer", + "private": true, + "type": "module", + "dependencies": { + "@msgpack/msgpack": "3.1.3", + "@redis/client": "6.2.1" + } +} diff --git a/scripts/conformance/write_dataplane_config.mjs b/scripts/conformance/write_dataplane_config.mjs index c32fbb5..e3d4217 100644 --- a/scripts/conformance/write_dataplane_config.mjs +++ b/scripts/conformance/write_dataplane_config.mjs @@ -1,13 +1,9 @@ #!/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'; +/** Publish a test routing snapshot directly to the harness Redis. */ +import { sign } from 'node:crypto'; +import { readFileSync, 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; @@ -131,7 +127,7 @@ function routes(names, backendName) { ); } -function config(serverId, backendUrl, protocolVersion, catalogs) { +export function config(serverId, backendUrl, protocolVersion, catalogs) { const backendName = 'conformance-backend'; return { virtual_hosts: { @@ -158,41 +154,30 @@ function config(serverId, backendUrl, protocolVersion, catalogs) { } 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); + const { encode } = await import('@msgpack/msgpack'); + const { createClient } = await import('@redis/client'); + const client = createClient({ + url: process.env.CF_CONFIG_REDIS_URL ?? 'redis://redis:6379', + socket: { connectTimeout: 10000, reconnectStrategy: false }, + }); + client.on('error', (error) => process.stderr.write(`Redis: ${error.message}\n`)); + try { + await client.connect(); + // User::new(subject) uses the compact [KeyType::UserConfig, subject] key. + // Named maps preserve empty maps and avoid depending on Rust field order. + await client.set(Buffer.from(encode(['UserConfig', subject])), Buffer.from(encode(body))); + } finally { + if (client.isOpen) client.destroy(); } - 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}`); +export function issueToken(tenantId, userId, privateKey = readFileSync('/keys/jwt.key')) { + const now = Math.floor(Date.now() / 1000); + const header = { alg: 'RS256', typ: 'JWT', kid: 'cf-integration-standalone' }; + const claims = { sub: userId, tenant_id: tenantId, iat: now, nbf: now, exp: now + 86400 }; + const payload = [header, claims].map((value) => + Buffer.from(JSON.stringify(value)).toString('base64url')).join('.'); + return `${payload}.${sign('RSA-SHA256', Buffer.from(payload), privateKey).toString('base64url')}`; } async function main() { diff --git a/scripts/standalone/auth.mjs b/scripts/standalone/auth.mjs new file mode 100644 index 0000000..5a6fc8e --- /dev/null +++ b/scripts/standalone/auth.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/** Own the ephemeral signing key and loopback JWKS for standalone tests. */ +import { createPublicKey, generateKeyPairSync } from 'node:crypto'; +import { chmodSync, existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { pathToFileURL } from 'node:url'; + +export function startAuth(keyPath = '/keys/jwt.key', port = 4446) { + if (!existsSync(keyPath)) { + const { privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + }); + writeFileSync(keyPath, privateKey, { mode: 0o600 }); + } + chmodSync(keyPath, 0o600); + const jwk = createPublicKey(readFileSync(keyPath)).export({ format: 'jwk' }); + const jwks = JSON.stringify({ keys: [{ + ...jwk, kid: 'cf-integration-standalone', alg: 'RS256', use: 'sig', + }] }); + const server = createServer((request, response) => { + if (request.url !== '/.well-known/jwks.json') { + response.writeHead(404).end(); + } else if (!['GET', 'HEAD'].includes(request.method)) { + response.writeHead(405, { allow: 'GET, HEAD' }).end(); + } else { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(request.method === 'HEAD' ? undefined : jwks); + } + }); + return server.listen(port, '127.0.0.1'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { + const server = startAuth(); + for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close()); +} 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/infrastructure/assets.rs b/src/infrastructure/assets.rs index 866fa27..e5bb2b6 100644 --- a/src/infrastructure/assets.rs +++ b/src/infrastructure/assets.rs @@ -23,6 +23,7 @@ macro_rules! asset { } const ASSETS: &[EmbeddedAsset] = &[ + 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"), @@ -44,7 +45,10 @@ const ASSETS: &[EmbeddedAsset] = &[ 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!("scripts/standalone/auth.mjs"), + asset!("scripts/conformance/package.json"), + asset!("scripts/conformance/package-lock.json"), + asset!("scripts/conformance/Dockerfile"), 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"), diff --git a/src/infrastructure/compose.rs b/src/infrastructure/compose.rs index cadff54..693e528 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"), diff --git a/src/infrastructure/compose_integration_tests.rs b/src/infrastructure/compose_integration_tests.rs index c6d4ed5..ba5308a 100644 --- a/src/infrastructure/compose_integration_tests.rs +++ b/src/infrastructure/compose_integration_tests.rs @@ -258,8 +258,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 +269,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())] @@ -394,7 +391,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!( @@ -412,7 +409,7 @@ fn both_external_projects_provide_the_client_conformance_config_writer() { } #[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 +420,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"]["nginx"]["depends_on"]["auth"]["condition"].as_str(), + Some("service_healthy") ); assert_eq!( - compose["services"]["dataplane"]["command"][1].as_str(), - Some("/keys/jwt.key") + compose["services"]["config_writer"]["volumes"][0].as_str(), + Some("standalone_auth:/keys:ro") ); assert!( compose["services"]["dataplane"]["environment"]["CONTEXTFORGE_DATA_PLANE_TOKEN_SECRET"] diff --git a/src/performance/python_adapter_tests.rs b/src/performance/python_adapter_tests.rs index 00c0fa5..7f0956a 100644 --- a/src/performance/python_adapter_tests.rs +++ b/src/performance/python_adapter_tests.rs @@ -24,7 +24,7 @@ fn workspace_root() -> PathBuf { fn standalone_config_writer_has_valid_javascript_syntax() { for script in [ "conformance/write_dataplane_config.mjs", - "standalone/generate_auth_key.mjs", + "standalone/auth.mjs", ] { let output = Command::new("node") .arg("--check") @@ -658,26 +658,23 @@ assert.deepEqual(legacyMethods, ['initialize', 'notifications/initialized', 'too } #[test] -fn client_config_writer_publishes_a_schema_for_each_scenario_tool() { +fn client_config_writer_preserves_scenario_schemas_and_empty_maps() { 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); +process.argv = ['node']; +const { config } = await import(pathToFileURL(scriptPath).href); +const host = config('scenario-server', 'http://fixture/mcp', '2026-07-28', { + tools: ['metadata_probe', 'add_numbers'], + toolSchemas: { metadata_probe: {}, add_numbers: {} }, + resources: [], resourceTemplates: [], prompts: [], +}).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: {} }); +assert.deepEqual(host.resources, {}); +assert.deepEqual(host.resource_templates, {}); +assert.deepEqual(host.prompts, {}); "#; let output = Command::new("node") .args(["--input-type=module", "--eval", script]) @@ -690,3 +687,63 @@ assert.ok(published); String::from_utf8_lossy(&output.stderr) ); } + +#[test] +fn standalone_auth_serves_public_jwks_and_signs_verifiable_tokens() { + let script = r#" +import assert from 'node:assert/strict'; +import { createPublicKey, verify, generateKeyPairSync } from 'node:crypto'; +import { once } from 'node:events'; +import { readFileSync, statSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +const [authPath, writerPath, keyPath] = process.argv.slice(1); +process.argv = ['node']; +const { startAuth } = await import(pathToFileURL(authPath).href); +const { issueToken } = await import(pathToFileURL(writerPath).href); +let originalJwks; +for (let run = 0; run < 2; run++) { + const server = startAuth(keyPath, 0); + try { + await once(server, 'listening'); + const base = `http://127.0.0.1:${server.address().port}`; + const response = await fetch(`${base}/.well-known/jwks.json`); + assert.equal(response.status, 200); + const jwks = await response.json(); + if (originalJwks) assert.deepEqual(jwks, originalJwks); + originalJwks = jwks; + const jwk = jwks.keys[0]; + assert.equal(jwk.d, undefined); + assert.equal(jwk.p, undefined); + const token = issueToken('tenant', 'subject', readFileSync(keyPath)); + const [header, claims, signature] = token.split('.'); + assert.equal(JSON.parse(Buffer.from(header, 'base64url')).kid, jwk.kid); + const decoded = JSON.parse(Buffer.from(claims, 'base64url')); + assert.equal(decoded.sub, 'subject'); + assert.equal(decoded.tenant_id, 'tenant'); + assert.ok(decoded.exp > Date.now() / 1000); + const data = Buffer.from(`${header}.${claims}`); + const bytes = Buffer.from(signature, 'base64url'); + assert.ok(verify('RSA-SHA256', data, createPublicKey({ key: jwk, format: 'jwk' }), bytes)); + assert.equal(verify('RSA-SHA256', data, generateKeyPairSync('rsa', { modulusLength: 2048 }).publicKey, bytes), false); + assert.equal((await fetch(`${base}/jwt.key`)).status, 404); + assert.equal((await fetch(`${base}/.well-known/jwks.json`, { method: 'POST' })).status, 405); + if (process.platform !== 'win32') assert.equal(statSync(keyPath).mode & 0o777, 0o600); + } finally { + await new Promise(resolve => server.close(resolve)); + } +} +"#; + let directory = tempfile::tempdir().expect("temporary auth directory"); + let output = Command::new("node") + .args(["--input-type=module", "--eval", script]) + .arg(scripts_dir().join("standalone/auth.mjs")) + .arg(scripts_dir().join("conformance/write_dataplane_config.mjs")) + .arg(directory.path().join("jwt.key")) + .output() + .expect("Node auth test runs"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} From 26f4fb32499e7b4d3ea250c368a755500f9b15b1 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Mon, 7 Sep 2026 22:36:13 +0100 Subject: [PATCH 2/6] refactor: simplify harness command and conformance paths Signed-off-by: lucarlig --- .github/workflows/ci.yml | 49 +- .github/workflows/quality.yml | 64 ++ .github/workflows/release.yml | 49 +- CHANGELOG.md | 8 + src/conformance/baseline.rs | 89 +-- src/infrastructure/process.rs | 118 ++-- .../process_integration_tests.rs | 91 +-- src/infrastructure/stack.rs | 151 ++--- src/infrastructure/stack_integration_tests.rs | 58 +- src/mcp/gateway.rs | 415 +------------ src/mcp/gateway_integration_tests.rs | 269 ++------- src/runtime/conformance/mod.rs | 356 +++-------- src/runtime/conformance/reports.rs | 556 +++++++++++------- src/runtime/mod.rs | 8 +- src/runtime/stack/mod.rs | 26 +- 15 files changed, 746 insertions(+), 1561 deletions(-) create mode 100644 .github/workflows/quality.yml 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/CHANGELOG.md b/CHANGELOG.md index d633fd8..1374fe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Changed + +- 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. + ## [0.3.2] - 2026-09-07 ### Changed 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/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/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/runtime/conformance/mod.rs b/src/runtime/conformance/mod.rs index 326515f..c96350b 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,15 @@ 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, 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(), @@ -1057,7 +904,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)), ) { @@ -1283,7 +1130,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) @@ -1427,25 +1274,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 +1282,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) @@ -1843,14 +1671,6 @@ 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, endpoint: &'a url::Url, 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/mod.rs b/src/runtime/mod.rs index 3f14bfc..acf4cd6 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -11,8 +11,7 @@ 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, @@ -36,8 +35,9 @@ use crate::infrastructure::compose::{ComposeProject, validate_integration_contra 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; diff --git a/src/runtime/stack/mod.rs b/src/runtime/stack/mod.rs index 6e40fa1..9c4f980 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, @@ -1171,8 +1167,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 +1198,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 { From d1b51951cc92bb09fbaec4731b31ccbf2de1f108 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 8 Sep 2026 12:16:21 +0100 Subject: [PATCH 3/6] refactor: move standalone helpers into the Rust CLI Signed-off-by: lucarlig --- AGENTS.md | 2 + CHANGELOG.md | 3 + Cargo.lock | 294 ++++++++++++++- Cargo.toml | 12 +- README.md | 5 +- ...docker-compose.cf-conformance-fixture.yaml | 6 +- .../docker-compose.cf-dataplane-config.yaml | 13 +- ...ocker-compose.cf-dataplane-standalone.yaml | 12 +- docker/helpers.Dockerfile | 19 + docker/mcp-conformance-server.Dockerfile | 6 +- docker/mcp-conformance.patch | 71 ++++ docker/patch-mcp-conformance-hosts.mjs | 97 ----- scripts/conformance/Dockerfile | 4 - scripts/conformance/package-lock.json | 56 --- scripts/conformance/package.json | 9 - .../conformance/write_dataplane_config.mjs | 224 ------------ scripts/standalone/auth.mjs | 38 -- src/conformance/client.rs | 1 + src/helpers/auth.rs | 83 +++++ src/helpers/config.rs | 272 ++++++++++++++ src/helpers/mod.rs | 132 +++++++ src/helpers/tests.rs | 339 ++++++++++++++++++ src/infrastructure/assets.rs | 105 +++--- .../compose_integration_tests.rs | 98 ++--- src/lib.rs | 7 + src/performance/python_adapter_tests.rs | 211 ----------- src/runtime/conformance/mod.rs | 1 + src/runtime/session.rs | 1 + 28 files changed, 1357 insertions(+), 764 deletions(-) create mode 100644 docker/helpers.Dockerfile create mode 100644 docker/mcp-conformance.patch delete mode 100644 docker/patch-mcp-conformance-hosts.mjs delete mode 100644 scripts/conformance/Dockerfile delete mode 100644 scripts/conformance/package-lock.json delete mode 100644 scripts/conformance/package.json delete mode 100644 scripts/conformance/write_dataplane_config.mjs delete mode 100644 scripts/standalone/auth.mjs create mode 100644 src/helpers/auth.rs create mode 100644 src/helpers/config.rs create mode 100644 src/helpers/mod.rs create mode 100644 src/helpers/tests.rs 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 1374fe3..aed6926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Changed +- 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 diff --git a/Cargo.lock b/Cargo.lock index ad83dbd..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", ] @@ -180,10 +204,17 @@ name = "cf-integration" 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 0660895..a03cffb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,11 +19,6 @@ include = [ "/docker/**", "/scripts/locustfile_mcp.py", "/scripts/live_protocol/sitecustomize.py", - "/scripts/conformance/write_dataplane_config.mjs", - "/scripts/conformance/package.json", - "/scripts/conformance/package-lock.json", - "/scripts/conformance/Dockerfile", - "/scripts/standalone/auth.mjs", "/tests/conformance/baselines/**", "/README.md", "/CHANGELOG.md", @@ -36,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 6c4007c..8c471ce 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,10 @@ 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 installs pinned Redis and MessagePack packages on its first build. +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. Node.js is used only by the upstream conformance tools; +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 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 b04fc8e..085e3ab 100644 --- a/docker/docker-compose.cf-dataplane-config.yaml +++ b/docker/docker-compose.cf-dataplane-config.yaml @@ -1,17 +1,14 @@ services: config_writer: profiles: ["helpers"] - image: cf-integration-helpers:0.3.2 - pull_policy: never + 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}/scripts/conformance + 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 7655c89..a760d3a 100644 --- a/docker/docker-compose.cf-dataplane-standalone.yaml +++ b/docker/docker-compose.cf-dataplane-standalone.yaml @@ -4,10 +4,11 @@ services: auth: - image: cf-integration-helpers:0.3.2 - pull_policy: never + 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}/scripts/conformance + context: ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root} + dockerfile: docker/helpers.Dockerfile labels: name: cf-dataplane-auth restart: "no" @@ -16,10 +17,9 @@ services: network_mode: service:dataplane volumes: - standalone_auth:/keys - - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/standalone/auth.mjs:/opt/contextforge-integration/auth.mjs:ro - command: ["node", "/opt/contextforge-integration/auth.mjs"] + command: ["__helper", "auth"] healthcheck: - test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4446/.well-known/jwks.json').then(r => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1))"] + test: ["CMD", "cf-integration", "__helper", "health"] interval: 2s timeout: 2s retries: 30 diff --git a/docker/helpers.Dockerfile b/docker/helpers.Dockerfile new file mode 100644 index 0000000..53cd32e --- /dev/null +++ b/docker/helpers.Dockerfile @@ -0,0 +1,19 @@ +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 debian:bookworm-slim +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/Dockerfile b/scripts/conformance/Dockerfile deleted file mode 100644 index 2b2a6c3..0000000 --- a/scripts/conformance/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM node:22-bookworm-slim -WORKDIR /opt/contextforge-integration -COPY package.json package-lock.json ./ -RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force diff --git a/scripts/conformance/package-lock.json b/scripts/conformance/package-lock.json deleted file mode 100644 index d2667f3..0000000 --- a/scripts/conformance/package-lock.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "name": "cf-integration-config-writer", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "cf-integration-config-writer", - "dependencies": { - "@msgpack/msgpack": "3.1.3", - "@redis/client": "6.2.1" - } - }, - "node_modules/@msgpack/msgpack": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", - "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", - "license": "ISC", - "engines": { - "node": ">= 18" - } - }, - "node_modules/@redis/client": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-6.2.1.tgz", - "integrity": "sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q==", - "license": "MIT", - "dependencies": { - "cluster-key-slot": "1.1.2" - }, - "engines": { - "node": ">= 20.0.0" - }, - "peerDependencies": { - "@node-rs/xxhash": "^1.1.0", - "@opentelemetry/api": ">=1 <2" - }, - "peerDependenciesMeta": { - "@node-rs/xxhash": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - } - } - }, - "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - } - } -} diff --git a/scripts/conformance/package.json b/scripts/conformance/package.json deleted file mode 100644 index 6eb1543..0000000 --- a/scripts/conformance/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "cf-integration-config-writer", - "private": true, - "type": "module", - "dependencies": { - "@msgpack/msgpack": "3.1.3", - "@redis/client": "6.2.1" - } -} diff --git a/scripts/conformance/write_dataplane_config.mjs b/scripts/conformance/write_dataplane_config.mjs deleted file mode 100644 index e3d4217..0000000 --- a/scripts/conformance/write_dataplane_config.mjs +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env node -/** Publish a test routing snapshot directly to the harness Redis. */ -import { sign } from 'node:crypto'; -import { readFileSync, realpathSync } from 'node:fs'; -import { pathToFileURL } from 'node:url'; - -/** 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 }]), - ); -} - -export 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 { encode } = await import('@msgpack/msgpack'); - const { createClient } = await import('@redis/client'); - const client = createClient({ - url: process.env.CF_CONFIG_REDIS_URL ?? 'redis://redis:6379', - socket: { connectTimeout: 10000, reconnectStrategy: false }, - }); - client.on('error', (error) => process.stderr.write(`Redis: ${error.message}\n`)); - try { - await client.connect(); - // User::new(subject) uses the compact [KeyType::UserConfig, subject] key. - // Named maps preserve empty maps and avoid depending on Rust field order. - await client.set(Buffer.from(encode(['UserConfig', subject])), Buffer.from(encode(body))); - } finally { - if (client.isOpen) client.destroy(); - } -} - -export function issueToken(tenantId, userId, privateKey = readFileSync('/keys/jwt.key')) { - const now = Math.floor(Date.now() / 1000); - const header = { alg: 'RS256', typ: 'JWT', kid: 'cf-integration-standalone' }; - const claims = { sub: userId, tenant_id: tenantId, iat: now, nbf: now, exp: now + 86400 }; - const payload = [header, claims].map((value) => - Buffer.from(JSON.stringify(value)).toString('base64url')).join('.'); - return `${payload}.${sign('RSA-SHA256', Buffer.from(payload), privateKey).toString('base64url')}`; -} - -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/auth.mjs b/scripts/standalone/auth.mjs deleted file mode 100644 index 5a6fc8e..0000000 --- a/scripts/standalone/auth.mjs +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env node -/** Own the ephemeral signing key and loopback JWKS for standalone tests. */ -import { createPublicKey, generateKeyPairSync } from 'node:crypto'; -import { chmodSync, existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; -import { createServer } from 'node:http'; -import { pathToFileURL } from 'node:url'; - -export function startAuth(keyPath = '/keys/jwt.key', port = 4446) { - if (!existsSync(keyPath)) { - const { privateKey } = generateKeyPairSync('rsa', { - modulusLength: 2048, - privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, - publicKeyEncoding: { type: 'spki', format: 'pem' }, - }); - writeFileSync(keyPath, privateKey, { mode: 0o600 }); - } - chmodSync(keyPath, 0o600); - const jwk = createPublicKey(readFileSync(keyPath)).export({ format: 'jwk' }); - const jwks = JSON.stringify({ keys: [{ - ...jwk, kid: 'cf-integration-standalone', alg: 'RS256', use: 'sig', - }] }); - const server = createServer((request, response) => { - if (request.url !== '/.well-known/jwks.json') { - response.writeHead(404).end(); - } else if (!['GET', 'HEAD'].includes(request.method)) { - response.writeHead(405, { allow: 'GET, HEAD' }).end(); - } else { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(request.method === 'HEAD' ? undefined : jwks); - } - }); - return server.listen(port, '127.0.0.1'); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { - const server = startAuth(); - for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close()); -} diff --git a/src/conformance/client.rs b/src/conformance/client.rs index abf510e..9af5dba 100644 --- a/src/conformance/client.rs +++ b/src/conformance/client.rs @@ -162,6 +162,7 @@ fn publish_scenario_config( .context("failed to serialize client conformance tool names")?; let command = CommandSpec::new("docker").args(compose_args).args([ "run", + "--quiet-build", "--rm", "--no-deps", "-e", 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..a5c5098 --- /dev/null +++ b/src/helpers/mod.rs @@ -0,0 +1,132 @@ +//! 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; + +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), + Client { + #[command(flatten)] + config: ConfigArgs, + tool_names_json: String, + }, +} + +#[derive(Args)] +struct ConfigArgs { + server_id: String, + backend_url: Url, + protocol_version: String, +} + +pub(crate) async fn run(arguments: &[OsString]) -> Result<()> { + let (args, tools) = 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, None), + HelperCommand::Client { + config, + tool_names_json, + } => { + let tools: Vec = serde_json::from_str(&tool_names_json) + .context("tool-names-json must be a JSON string array")?; + ensure!( + tools.iter().all(|name| !name.is_empty()), + "tool names must not be empty" + ); + (config, Some(tools)) + } + }; + 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 = match tools { + Some(tools) => config::Catalog::for_client(tools), + None => 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(()) +} + +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/infrastructure/assets.rs b/src/infrastructure/assets.rs index e5bb2b6..adce5e0 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,60 +12,74 @@ 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/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/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/auth.mjs"), - asset!("scripts/conformance/package.json"), - asset!("scripts/conformance/package-lock.json"), - asset!("scripts/conformance/Dockerfile"), - 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/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. @@ -106,8 +123,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()) @@ -132,8 +149,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", @@ -194,7 +211,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_integration_tests.rs b/src/infrastructure/compose_integration_tests.rs index ba5308a..0d0db65 100644 --- a/src/infrastructure/compose_integration_tests.rs +++ b/src/infrastructure/compose_integration_tests.rs @@ -39,15 +39,16 @@ fn messages(config: &serde_json::Value) -> Vec { fn run_fixture_patch(source: &str) -> (std::process::ExitStatus, 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) } @@ -401,10 +402,7 @@ 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")); } } @@ -699,7 +697,7 @@ fn conformance_container_inputs_pin_the_runner_revision_and_protocol_fixture() { let root = workspace_root(); 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")) @@ -724,10 +722,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'" )); @@ -745,8 +742,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")); @@ -772,10 +767,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 @@ -833,37 +826,46 @@ 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"); - + // 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 (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'")); + 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 (status, contents) = run_fixture_patch(&unchanged); + assert!(!status.success()); + assert_eq!(contents, unchanged); } #[test] diff --git a/src/lib.rs b/src/lib.rs index f682938..ac0d42e 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,12 @@ 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 == "__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/performance/python_adapter_tests.rs b/src/performance/python_adapter_tests.rs index 7f0956a..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/auth.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,194 +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_preserves_scenario_schemas_and_empty_maps() { - let script = r#" -import assert from 'node:assert/strict'; -import { pathToFileURL } from 'node:url'; -const scriptPath = process.argv[1]; -process.argv = ['node']; -const { config } = await import(pathToFileURL(scriptPath).href); -const host = config('scenario-server', 'http://fixture/mcp', '2026-07-28', { - tools: ['metadata_probe', 'add_numbers'], - toolSchemas: { metadata_probe: {}, add_numbers: {} }, - resources: [], resourceTemplates: [], prompts: [], -}).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: {} }); -assert.deepEqual(host.resources, {}); -assert.deepEqual(host.resource_templates, {}); -assert.deepEqual(host.prompts, {}); -"#; - 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) - ); -} - -#[test] -fn standalone_auth_serves_public_jwks_and_signs_verifiable_tokens() { - let script = r#" -import assert from 'node:assert/strict'; -import { createPublicKey, verify, generateKeyPairSync } from 'node:crypto'; -import { once } from 'node:events'; -import { readFileSync, statSync } from 'node:fs'; -import { pathToFileURL } from 'node:url'; -const [authPath, writerPath, keyPath] = process.argv.slice(1); -process.argv = ['node']; -const { startAuth } = await import(pathToFileURL(authPath).href); -const { issueToken } = await import(pathToFileURL(writerPath).href); -let originalJwks; -for (let run = 0; run < 2; run++) { - const server = startAuth(keyPath, 0); - try { - await once(server, 'listening'); - const base = `http://127.0.0.1:${server.address().port}`; - const response = await fetch(`${base}/.well-known/jwks.json`); - assert.equal(response.status, 200); - const jwks = await response.json(); - if (originalJwks) assert.deepEqual(jwks, originalJwks); - originalJwks = jwks; - const jwk = jwks.keys[0]; - assert.equal(jwk.d, undefined); - assert.equal(jwk.p, undefined); - const token = issueToken('tenant', 'subject', readFileSync(keyPath)); - const [header, claims, signature] = token.split('.'); - assert.equal(JSON.parse(Buffer.from(header, 'base64url')).kid, jwk.kid); - const decoded = JSON.parse(Buffer.from(claims, 'base64url')); - assert.equal(decoded.sub, 'subject'); - assert.equal(decoded.tenant_id, 'tenant'); - assert.ok(decoded.exp > Date.now() / 1000); - const data = Buffer.from(`${header}.${claims}`); - const bytes = Buffer.from(signature, 'base64url'); - assert.ok(verify('RSA-SHA256', data, createPublicKey({ key: jwk, format: 'jwk' }), bytes)); - assert.equal(verify('RSA-SHA256', data, generateKeyPairSync('rsa', { modulusLength: 2048 }).publicKey, bytes), false); - assert.equal((await fetch(`${base}/jwt.key`)).status, 404); - assert.equal((await fetch(`${base}/.well-known/jwks.json`, { method: 'POST' })).status, 405); - if (process.platform !== 'win32') assert.equal(statSync(keyPath).mode & 0o777, 0o600); - } finally { - await new Promise(resolve => server.close(resolve)); - } -} -"#; - let directory = tempfile::tempdir().expect("temporary auth directory"); - let output = Command::new("node") - .args(["--input-type=module", "--eval", script]) - .arg(scripts_dir().join("standalone/auth.mjs")) - .arg(scripts_dir().join("conformance/write_dataplane_config.mjs")) - .arg(directory.path().join("jwt.key")) - .output() - .expect("Node auth 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 c96350b..277fc62 100644 --- a/src/runtime/conformance/mod.rs +++ b/src/runtime/conformance/mod.rs @@ -1090,6 +1090,7 @@ impl RuntimeContext { .standalone_conformance_compose_project(observability) .command([ "run", + "--quiet-build", "--rm", "--no-deps", "-e", 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", From 287fdcd45efba19228ec7f5e7ea4ffd70567ca24 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 8 Sep 2026 12:19:50 +0100 Subject: [PATCH 4/6] fix: preserve fixture patch line endings on Windows Signed-off-by: lucarlig --- .gitattributes | 1 + src/infrastructure/compose_integration_tests.rs | 16 ++++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 .gitattributes 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/src/infrastructure/compose_integration_tests.rs b/src/infrastructure/compose_integration_tests.rs index 0d0db65..5315c2f 100644 --- a/src/infrastructure/compose_integration_tests.rs +++ b/src/infrastructure/compose_integration_tests.rs @@ -37,7 +37,7 @@ 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 relative = Path::new("examples/servers/typescript/everything-server.ts"); let target = directory.path().join(relative); @@ -50,7 +50,7 @@ fn run_fixture_patch(source: &str) -> (std::process::ExitStatus, String) { .output() .expect("apply conformance fixture patch"); let contents = fs::read_to_string(target).expect("read patch test output"); - (output.status, contents) + (output, contents) } #[test] @@ -853,8 +853,12 @@ fn conformance_fixture_patch_is_fail_closed_and_adds_server_era_routing() { } } let source = format!("{}\n", original.join("\n")); - let (status, patched) = run_fixture_patch(&source); - assert!(status.success()); + 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'")); @@ -863,8 +867,8 @@ fn conformance_fixture_patch_is_fail_closed_and_adds_server_era_routing() { "const app = createMcpExpressApp();", "changed upstream host setup", ); - let (status, contents) = run_fixture_patch(&unchanged); - assert!(!status.success()); + let (output, contents) = run_fixture_patch(&unchanged); + assert!(!output.status.success()); assert_eq!(contents, unchanged); } From a44a505413b829cd1939183de11e65c9f8c3ffa4 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 8 Sep 2026 13:44:36 +0100 Subject: [PATCH 5/6] fix: run conformance and Inspector inside Docker Signed-off-by: lucarlig --- CHANGELOG.md | 4 + README.md | 16 +- ...ocker-compose.cf-dataplane-standalone.yaml | 2 - docker/docker-compose.cf-dataplane.yaml | 2 - docker/docker-compose.cf-tools.yaml | 20 ++ docker/helpers.Dockerfile | 14 +- src/conformance/client.rs | 57 ++--- src/conformance/results.rs | 10 +- src/conformance/results_tests.rs | 8 +- src/helpers/mod.rs | 46 ++-- src/helpers/tools.rs | 192 ++++++++++++++++ src/infrastructure/assets.rs | 1 + src/infrastructure/compose.rs | 9 + .../compose_integration_tests.rs | 8 +- src/lib.rs | 6 + src/mcp/auth_proxy.rs | 74 ++---- src/mcp/auth_proxy_integration_tests.rs | 93 ++++++-- src/runtime/conformance/mod.rs | 212 ++++++++---------- src/runtime/inspect.rs | 90 +++----- src/runtime/mod.rs | 13 +- src/runtime/stack/mod.rs | 5 +- src/runtime/tools.rs | 149 ++++++++++++ 22 files changed, 668 insertions(+), 363 deletions(-) create mode 100644 docker/docker-compose.cf-tools.yaml create mode 100644 src/helpers/tools.rs create mode 100644 src/runtime/tools.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index aed6926..1c8b2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ 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. diff --git a/README.md b/README.md index 8c471ce..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 @@ -49,8 +50,12 @@ 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. Node.js is used only by the upstream conformance tools; -Python remains for Locust and upstream live-test integration. +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 @@ -177,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 @@ -189,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-dataplane-standalone.yaml b/docker/docker-compose.cf-dataplane-standalone.yaml index a760d3a..6260a2e 100644 --- a/docker/docker-compose.cf-dataplane-standalone.yaml +++ b/docker/docker-compose.cf-dataplane-standalone.yaml @@ -62,8 +62,6 @@ services: mcpnet: aliases: - cf-dataplane - extra_hosts: - - host.docker.internal:host-gateway expose: - "4445" environment: diff --git a/docker/docker-compose.cf-dataplane.yaml b/docker/docker-compose.cf-dataplane.yaml index 7baecdf..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: 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 index 53cd32e..154f184 100644 --- a/docker/helpers.Dockerfile +++ b/docker/helpers.Dockerfile @@ -11,7 +11,19 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ cargo build --locked --release --bin cf-integration \ && cp target/release/cf-integration /usr/local/bin/cf-integration -FROM debian:bookworm-slim +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/* diff --git a/src/conformance/client.rs b/src/conformance/client.rs index 9af5dba..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,49 +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", - "--quiet-build", - "--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")) @@ -199,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/mod.rs b/src/helpers/mod.rs index a5c5098..8dddd9f 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -10,6 +10,7 @@ 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"; @@ -24,16 +25,8 @@ struct HelperArgs { enum HelperCommand { Auth, Health, - Token { - tenant_id: String, - user_id: String, - }, + Token { tenant_id: String, user_id: String }, Fixture(ConfigArgs), - Client { - #[command(flatten)] - config: ConfigArgs, - tool_names_json: String, - }, } #[derive(Args)] @@ -44,7 +37,7 @@ struct ConfigArgs { } pub(crate) async fn run(arguments: &[OsString]) -> Result<()> { - let (args, tools) = match HelperArgs::try_parse_from(arguments)?.command { + 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?; @@ -74,19 +67,7 @@ pub(crate) async fn run(arguments: &[OsString]) -> Result<()> { ); return Ok(()); } - HelperCommand::Fixture(args) => (args, None), - HelperCommand::Client { - config, - tool_names_json, - } => { - let tools: Vec = serde_json::from_str(&tool_names_json) - .context("tool-names-json must be a JSON string array")?; - ensure!( - tools.iter().all(|name| !name.is_empty()), - "tool names must not be empty" - ); - (config, Some(tools)) - } + HelperCommand::Fixture(args) => args, }; ensure!( !args.server_id.is_empty() && !args.protocol_version.is_empty(), @@ -99,10 +80,7 @@ pub(crate) async fn run(arguments: &[OsString]) -> Result<()> { let token = std::env::var("MCP_CONFORMANCE_TOKEN").context("MCP_CONFORMANCE_TOKEN is required")?; let subject = auth::token_subject(&token)?; - let catalog = match tools { - Some(tools) => config::Catalog::for_client(tools), - None => config::fixture_catalog(args.backend_url.clone(), &args.protocol_version).await?, - }; + 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(), @@ -115,6 +93,20 @@ pub(crate) async fn run(arguments: &[OsString]) -> Result<()> { 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)] { 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 adce5e0..27f9fbc 100644 --- a/src/infrastructure/assets.rs +++ b/src/infrastructure/assets.rs @@ -45,6 +45,7 @@ static ASSETS: LazyLock> = LazyLock::new(|| { 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"), diff --git a/src/infrastructure/compose.rs b/src/infrastructure/compose.rs index 693e528..a7b41aa 100644 --- a/src/infrastructure/compose.rs +++ b/src/infrastructure/compose.rs @@ -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 5315c2f..2d505a8 100644 --- a/src/infrastructure/compose_integration_tests.rs +++ b/src/infrastructure/compose_integration_tests.rs @@ -284,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}") @@ -695,6 +690,9 @@ 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/mcp-conformance.patch")) diff --git a/src/lib.rs b/src/lib.rs index ac0d42e..f834f6e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,12 @@ 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, 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/runtime/conformance/mod.rs b/src/runtime/conformance/mod.rs index 277fc62..bda07fc 100644 --- a/src/runtime/conformance/mod.rs +++ b/src/runtime/conformance/mod.rs @@ -573,6 +573,8 @@ impl RuntimeContext { let direct_run = SemanticLaneRun { target: SemanticLane::FixtureDirect, endpoint: &endpoint, + token: None, + standalone: false, spec_version, server_era, fixture: &metadata, @@ -776,6 +778,7 @@ impl RuntimeContext { self.run_official_conformance_mode( &OfficialConformanceRun { topology, + standalone: standalone_topology, server_id, token: &token.value, spec_version, @@ -950,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( @@ -1171,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) { @@ -1315,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); @@ -1664,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, @@ -1674,6 +1671,8 @@ struct OfficialConformanceRun<'a> { struct SemanticLaneRun<'a> { target: SemanticLane, + token: Option<&'a str>, + standalone: bool, endpoint: &'a url::Url, spec_version: &'a str, server_era: ConformanceServerEra, @@ -1740,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/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 acf4cd6..ccc2ce9 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -14,8 +14,7 @@ use crate::conformance::baseline::{ 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, @@ -26,9 +25,8 @@ 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}; @@ -41,7 +39,6 @@ use crate::infrastructure::stack::{ }; 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/stack/mod.rs b/src/runtime/stack/mod.rs index 9c4f980..4e22e93 100644 --- a/src/runtime/stack/mod.rs +++ b/src/runtime/stack/mod.rs @@ -644,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)?; diff --git a/src/runtime/tools.rs b/src/runtime/tools.rs new file mode 100644 index 0000000..7d94276 --- /dev/null +++ b/src/runtime/tools.rs @@ -0,0 +1,149 @@ +//! 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, including after cancellation or runner failure. + let cleanup = self + .runner + .run_async(&CommandSpec::new("docker").args(["rm", "--force", &name])) + .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>, + 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("run")) { + if self.interrupt { + self.cancellation.send_replace(true); + std::future::pending::<()>().await; + } + return Err(anyhow!("runner failed").into()); + } + 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(), + interrupt, + cancellation: sender, + }; + let runtime = RuntimeContext::new(config, runner); + let result = runtime + .run_tool( + CommandSpec::new("docker").arg("compose"), + &["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); + } + } +} From 957eeb1a846c2d44c417707e45d2673a746805f2 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Tue, 8 Sep 2026 14:16:32 +0100 Subject: [PATCH 6/6] Preserve Docker connection settings during tool cleanup Signed-off-by: lucarlig --- CHANGELOG.md | 5 +++ src/runtime/tools.rs | 74 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c8b2b5..312ef6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) 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 diff --git a/src/runtime/tools.rs b/src/runtime/tools.rs index 7d94276..7b9afd9 100644 --- a/src/runtime/tools.rs +++ b/src/runtime/tools.rs @@ -31,10 +31,20 @@ impl RuntimeContext { 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, including after cancellation or runner failure. + // 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(&CommandSpec::new("docker").args(["rm", "--force", &name])) + .run_async(&removal) .await .map_err(AppFailure::from); finish_with_cleanup(result.err(), cleanup) @@ -71,6 +81,7 @@ mod tests { struct FailingRunner { commands: RefCell>, + fail_at: &'static str, interrupt: bool, cancellation: tokio::sync::watch::Sender, } @@ -86,12 +97,16 @@ mod tests { ) -> Pin> + 'a>> { Box::pin(async move { self.run(spec)?; - if spec.arguments().contains(&OsString::from("run")) { + if spec.arguments().contains(&OsString::from(self.fail_at)) { if self.interrupt { self.cancellation.send_replace(true); std::future::pending::<()>().await; } - return Err(anyhow!("runner failed").into()); + #[cfg(unix)] + let status = ::from_raw(1 << 8); + #[cfg(windows)] + let status = ::from_raw(1); + return Err(InfrastructureError::child_exit("docker".into(), status)); } Ok(()) }) @@ -116,13 +131,20 @@ mod tests { 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"), + 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, @@ -144,6 +166,48 @@ mod tests { [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); } } }