From 93eb5d7ef6f631d0e1664b40b33c45304f0b0c3c Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 17 Jul 2026 20:35:00 +0100 Subject: [PATCH 01/10] Probe: drop --debug from conformance proxy (test flake masking) DRAFT probe, not for merge. The tools-call-sampling conformance flake stopped appearing on runs that carry `thv run --debug` (4/4 debug runs passed vs a >50% base fail rate), suggesting the debug log I/O perturbs the timing race and masks the failure. Drop `--debug` while keeping the proxy-log capture (at default INFO level) to test that hypothesis: if this reliably fails tools-call-sampling again, --debug was masking it, and the captured INFO-level proxy logs (which still record a session/-32001 event) give a non-perturbing root-cause path. Co-Authored-By: Claude Opus 4.8 --- test/conformance/run-conformance.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/conformance/run-conformance.sh b/test/conformance/run-conformance.sh index 9b679f34ae..e0d14376e7 100755 --- a/test/conformance/run-conformance.sh +++ b/test/conformance/run-conformance.sh @@ -66,7 +66,7 @@ docker build -t "${IMAGE}" "${SERVER_DIR}" echo "==> Starting server through ToolHive" "${THV_BINARY}" rm -f "${SERVER_NAME}" >/dev/null 2>&1 || true "${THV_BINARY}" run "${IMAGE}" \ - --transport streamable-http --target-port 3000 --name "${SERVER_NAME}" --debug + --transport streamable-http --target-port 3000 --name "${SERVER_NAME}" echo "==> Resolving proxy URL" URL="" From a855a656da667c98c1920961ba254dd55db624ed Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 20 Jul 2026 09:38:19 +0100 Subject: [PATCH 02/10] Capture backend server logs in conformance CI Debug logs from PR #5839's failing run showed --debug does NOT mask the sampling flake: the run failed with --debug on, and the trace localized the stall to a missing server->client sampling/createMessage delivery (the client never sends its sampling-response POST). So restore --debug (it is our best signal, not a mask) and add the one artifact still missing: the Node backend container's own logs, which reveal whether the backend emitted the sampling request (-> proxy relay race) or not (-> backend-side). SSE bodies are not in the proxy log, so this is the only way to split those two legs. Co-Authored-By: Claude Opus 4.8 --- test/conformance/run-conformance.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/conformance/run-conformance.sh b/test/conformance/run-conformance.sh index e0d14376e7..14157626fc 100755 --- a/test/conformance/run-conformance.sh +++ b/test/conformance/run-conformance.sh @@ -35,6 +35,14 @@ cleanup() { # results artifact, not just the client-side checks.json. mkdir -p "${RESULTS_DIR}" "${THV_BINARY}" logs --proxy "${SERVER_NAME}" > "${RESULTS_DIR}/proxy-logs.txt" 2>&1 || true + # Backend (Node everything-server) logs: the proxy log cannot show whether the + # backend emitted the server->client sampling/createMessage request (SSE bodies + # are not logged), so capture the container's own stdout/stderr. This is the + # missing leg for diagnosing the tools-call-sampling round-trip flake. Must run + # before `thv rm`, which destroys the container. + "${THV_BINARY}" logs "${SERVER_NAME}" > "${RESULTS_DIR}/backend-logs.txt" 2>&1 || true + backend_ctr="$(docker ps -a --filter "ancestor=${IMAGE}" --format '{{.Names}}' 2>/dev/null | head -1)" + [ -n "${backend_ctr}" ] && docker logs "${backend_ctr}" > "${RESULTS_DIR}/backend-docker-logs.txt" 2>&1 || true "${THV_BINARY}" rm "${SERVER_NAME}" >/dev/null 2>&1 || true [ -n "${CLONE_DIR}" ] && rm -rf "${CLONE_DIR}" || true } @@ -66,7 +74,7 @@ docker build -t "${IMAGE}" "${SERVER_DIR}" echo "==> Starting server through ToolHive" "${THV_BINARY}" rm -f "${SERVER_NAME}" >/dev/null 2>&1 || true "${THV_BINARY}" run "${IMAGE}" \ - --transport streamable-http --target-port 3000 --name "${SERVER_NAME}" + --transport streamable-http --target-port 3000 --name "${SERVER_NAME}" --debug echo "==> Resolving proxy URL" URL="" From 257c99813cef5804630ebd3e6a6fa72487bc2c76 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 20 Jul 2026 10:02:40 +0100 Subject: [PATCH 03/10] Capture a pcap in conformance CI for the sampling flake The backend/proxy logs cannot show whether the backend actually emitted the server->client sampling/createMessage on the SSE stream (SSE bodies are not logged, and the Node reference server may not log its own writes). A packet capture shows the bytes on both legs and definitively splits the remaining fork: sampling/createMessage present on backend->proxy but absent on proxy->client => ToolHive relay bug; absent on both => backend never emitted. Start tcpdump before the suite and stop it in cleanup(). `-i any` covers the loopback and docker-bridge legs (the backend is a docker-published port via docker-proxy); full snaplen is required to see SSE payloads; -U flushes per packet. Best-effort and non-fatal when tcpdump/passwordless sudo are absent (local macOS). The pcap lands in the results dir already uploaded as an artifact, so no workflow change is needed. Co-Authored-By: Claude Opus 4.8 --- test/conformance/run-conformance.sh | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/conformance/run-conformance.sh b/test/conformance/run-conformance.sh index 14157626fc..993d04860e 100755 --- a/test/conformance/run-conformance.sh +++ b/test/conformance/run-conformance.sh @@ -30,6 +30,11 @@ export TOOLHIVE_SKIP_DESKTOP_CHECK=1 CLONE_DIR="" cleanup() { + # Stop the packet capture first so tcpdump flushes the pcap to disk before + # the container/proxy are torn down. Matches on the output file so we don't + # kill unrelated tcpdumps. No-op when no capture was started. + sudo pkill -TERM -f 'tcpdump.*cf\.pcap' 2>/dev/null || true + sleep 1 # Diagnostics: capture the ToolHive proxy logs before teardown so a CI failure # (e.g. the sampling round-trip timing out) is debuggable from the uploaded # results artifact, not just the client-side checks.json. @@ -103,6 +108,23 @@ done echo "==> Running conformance suite (${CONFORMANCE_SUITE})" rm -rf "${RESULTS_DIR}" mkdir -p "${RESULTS_DIR}" + +# Packet capture (best-effort; CI/Linux only). Captures client<->proxy and +# proxy<->backend TCP so a sampling-flake failure can be localized to the +# server->client SSE delivery leg: does `sampling/createMessage` reach the proxy +# from the backend, and does the proxy relay it to the client? `-i any` covers +# both the loopback and docker-bridge legs; full snaplen (-s 0) is required to +# see SSE payloads; -U flushes per packet so the pcap survives an abrupt stop. +# Non-fatal when tcpdump or passwordless sudo are unavailable (e.g. local macOS). +# Stopped in cleanup(). +if command -v tcpdump >/dev/null 2>&1 && sudo -n true 2>/dev/null; then + sudo tcpdump -i any -U -s 0 -w "${RESULTS_DIR}/cf.pcap" tcp >/dev/null 2>&1 & + sleep 1 + echo "==> packet capture started -> ${RESULTS_DIR}/cf.pcap" +else + echo "==> tcpdump/passwordless sudo unavailable; skipping packet capture" +fi + npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ --url "${URL}" \ --suite "${CONFORMANCE_SUITE}" \ From 581318cf9e29d3b86b5ceb187d13fbe097a0923e Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 20 Jul 2026 11:32:24 +0100 Subject: [PATCH 04/10] Add direct-vs-stack sampling comparison to conformance CI The pcap showed the backend never emits sampling/createMessage and that ToolHive fronts the workload with a Squid ingress proxy (createIngressSquidContainer) plus the Go transparent proxy. It is unresolved whether that proxy stack causes or merely aggravates the client-ordering race that starves the server->client sampling stream. Add compare-sampling.sh: run the conformance suite N times against the reference container DIRECTLY (no thv) vs THROUGH the thv stack (Go proxy -> Squid ingress), and tally the tools-call-sampling failure rate. If it only flakes through the stack, ToolHive owns it (likely the Squid ingress). Wire it as a diagnostic step in the conformance job (draft-only) and bump the job timeout to 30m to accommodate the extra runs. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/e2e-tests.yml | 8 ++- test/conformance/compare-sampling.sh | 79 ++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100755 test/conformance/compare-sampling.sh diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 6c909ae611..0900bd9bf2 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -159,7 +159,7 @@ jobs: name: MCP Conformance runs-on: ubuntu-8cores-32gb needs: build-binary - timeout-minutes: 20 + timeout-minutes: 30 steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -187,6 +187,12 @@ jobs: THV_BINARY: ${{ github.workspace }}/bin/thv run: ./test/conformance/run-conformance.sh + - name: Compare sampling direct vs ToolHive stack (diagnostic) + if: always() + env: + THV_BINARY: ${{ github.workspace }}/bin/thv + run: ./test/conformance/compare-sampling.sh + - name: Upload conformance results if: always() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 diff --git a/test/conformance/compare-sampling.sh b/test/conformance/compare-sampling.sh new file mode 100755 index 0000000000..6ac48263e6 --- /dev/null +++ b/test/conformance/compare-sampling.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# Diagnostic: does tools-call-sampling flake THROUGH the ToolHive stack +# (Go transparent proxy -> Squid ingress -> container) but not DIRECT +# (client -> container)? Runs the conformance suite N times each way and +# tallies the tools-call-sampling result. Reuses the mcp-conformance-server:ci +# image built by run-conformance.sh (run this step after it). +# +# Env: CONFORMANCE_VERSION (default 0.1.16), THV_BINARY (default thv), +# SAMPLING_COMPARE_N (default 4). +set -uo pipefail + +CONFORMANCE_VERSION="${CONFORMANCE_VERSION:-0.1.16}" +THV_BINARY="${THV_BINARY:-thv}" +N="${SAMPLING_COMPARE_N:-4}" +IMAGE="mcp-conformance-server:ci" +DIRECT_NAME="conf-cmp-direct" +PROXY_NAME="conf-cmp-proxy" +DIRECT_PORT=3009 + +export TOOLHIVE_DEV=true +export TOOLHIVE_SKIP_DESKTOP_CHECK=1 + +cleanup() { + docker rm -f "${DIRECT_NAME}" >/dev/null 2>&1 || true + "${THV_BINARY}" rm -f "${PROXY_NAME}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +if ! docker image inspect "${IMAGE}" >/dev/null 2>&1; then + echo "ERROR: ${IMAGE} not present; run run-conformance.sh first (it builds it)." >&2 + exit 1 +fi + +wait_ready() { # $1=url + for _ in $(seq 1 30); do + code="$(curl -s -o /dev/null -w '%{http_code}' "$1" -X POST \ + -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' 2>/dev/null || true)" + [ "${code}" = "200" ] && return 0 + sleep 2 + done + return 1 +} + +run_suite() { # $1=url -> echoes PASS or FAIL for tools-call-sampling + out="$(npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ + --url "$1" --suite active -o /tmp/cmp-results 2>&1 || true)" + if echo "$out" | grep -qE "✓ tools-call-sampling"; then echo PASS + elif echo "$out" | grep -qE "✗ tools-call-sampling"; then echo FAIL + else echo "UNKNOWN"; fi +} + +echo "==> DIRECT: client -> container (no ToolHive)" +docker rm -f "${DIRECT_NAME}" >/dev/null 2>&1 || true +docker run -d --rm -p "${DIRECT_PORT}:3000" --name "${DIRECT_NAME}" "${IMAGE}" >/dev/null +wait_ready "http://127.0.0.1:${DIRECT_PORT}/mcp" || { echo "direct server not ready"; } +dfail=0 +for i in $(seq 1 "$N"); do r="$(run_suite "http://127.0.0.1:${DIRECT_PORT}/mcp")"; echo " direct $i: $r"; [ "$r" = FAIL ] && dfail=$((dfail+1)); done +docker rm -f "${DIRECT_NAME}" >/dev/null 2>&1 || true + +echo "==> STACK: client -> thv (Go proxy -> Squid ingress) -> container" +"${THV_BINARY}" rm -f "${PROXY_NAME}" >/dev/null 2>&1 || true +"${THV_BINARY}" run "${IMAGE}" --transport streamable-http --target-port 3000 --name "${PROXY_NAME}" +URL="" +for _ in $(seq 1 30); do + URL="$("${THV_BINARY}" list --format json 2>/dev/null | python3 -c "import sys,json;d=json.load(sys.stdin);print(next((w['url'] for w in d if w.get('name')=='${PROXY_NAME}' and w.get('status')=='running' and w.get('url')),''))" 2>/dev/null || true)" + [ -n "${URL}" ] && break + sleep 2 +done +[ -z "${URL}" ] && { echo "ERROR: could not resolve ${PROXY_NAME} URL" >&2; exit 1; } +wait_ready "${URL}" || { echo "stack server not ready"; } +pfail=0 +for i in $(seq 1 "$N"); do r="$(run_suite "${URL}")"; echo " stack $i: $r"; [ "$r" = FAIL ] && pfail=$((pfail+1)); done +"${THV_BINARY}" rm -f "${PROXY_NAME}" >/dev/null 2>&1 || true + +echo "" +echo "===== tools-call-sampling failure rate =====" +echo "DIRECT (no ToolHive): ${dfail} / ${N} failed" +echo "STACK (thv Go+Squid proxy): ${pfail} / ${N} failed" From 5e7f935e018099204dd898979590e889004d2fdb Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 20 Jul 2026 12:03:56 +0100 Subject: [PATCH 05/10] Add squid-ingress tier to sampling comparison The direct-vs-stack comparison proves the ToolHive stack triggers the sampling flake but can't say whether the Squid ingress or the Go transparent proxy widens the tools/call-vs-GET-SSE race. Add a middle tier that hits the ingress Squid's published host port directly, dropping the Go proxy from the path, so a failing CI run localizes the culprit: DIRECT green + SQUID flaky => Squid; SQUID green + STACK bad => Go proxy. Switch the tally to the single tools-call-sampling scenario so three tiers stay affordable in CI. Co-Authored-By: Claude Opus 4.8 --- test/conformance/compare-sampling.sh | 85 ++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 23 deletions(-) diff --git a/test/conformance/compare-sampling.sh b/test/conformance/compare-sampling.sh index 6ac48263e6..a28f74560e 100755 --- a/test/conformance/compare-sampling.sh +++ b/test/conformance/compare-sampling.sh @@ -1,17 +1,28 @@ #!/bin/bash -# Diagnostic: does tools-call-sampling flake THROUGH the ToolHive stack -# (Go transparent proxy -> Squid ingress -> container) but not DIRECT -# (client -> container)? Runs the conformance suite N times each way and -# tallies the tools-call-sampling result. Reuses the mcp-conformance-server:ci -# image built by run-conformance.sh (run this step after it). +# Diagnostic: WHERE does tools-call-sampling flake? The reference server sends +# its server->client sampling/createMessage on the client's standalone GET SSE +# stream, which races the tools/call POST. Direct it never flakes; through the +# ToolHive stack it does. This tallies the sampling result across three tiers to +# localize which stack element widens the race: +# +# DIRECT : client -> container (no ToolHive) +# SQUID : client -> Squid ingress -> container (no Go proxy) +# STACK : client -> Go transparent proxy -> Squid -> container (full) +# +# DIRECT green + SQUID flaky -> the Squid ingress is the trigger. +# DIRECT green + SQUID green + STACK bad -> the Go transparent proxy is the trigger. +# all flaky -> the container/network setup itself. +# +# Reuses the mcp-conformance-server:ci image built by run-conformance.sh (run +# this step after it). # # Env: CONFORMANCE_VERSION (default 0.1.16), THV_BINARY (default thv), -# SAMPLING_COMPARE_N (default 4). +# SAMPLING_COMPARE_N (default 6). set -uo pipefail CONFORMANCE_VERSION="${CONFORMANCE_VERSION:-0.1.16}" THV_BINARY="${THV_BINARY:-thv}" -N="${SAMPLING_COMPARE_N:-4}" +N="${SAMPLING_COMPARE_N:-6}" IMAGE="mcp-conformance-server:ci" DIRECT_NAME="conf-cmp-direct" PROXY_NAME="conf-cmp-proxy" @@ -42,23 +53,31 @@ wait_ready() { # $1=url return 1 } -run_suite() { # $1=url -> echoes PASS or FAIL for tools-call-sampling - out="$(npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ - --url "$1" --suite active -o /tmp/cmp-results 2>&1 || true)" - if echo "$out" | grep -qE "✓ tools-call-sampling"; then echo PASS - elif echo "$out" | grep -qE "✗ tools-call-sampling"; then echo FAIL - else echo "UNKNOWN"; fi +# Runs the sampling scenario $N times against $2, tallies failures into the +# variable named by $1. Echoes per-iteration results. +tally() { # $1=label $2=url -> echoes "