Skip to content

Commit 0e7f159

Browse files
electrolobzikclaude
andcommitted
test(e2e): add launcher-lifecycle gate driving a real HTTP MCP fixture
Closes the gap unit tests can't: proves a built mcpproxy binary actually spawns a self-launched HTTP MCP server, completes the MCP handshake, serves tools/list through the proxy, and reaps the child cleanly on disable / restart / shutdown. What's new: - test/launcher-server/main.go — minimal HTTP MCP fixture (~220 LOC). Implements initialize, tools/list (returns a "ping" tool), tools/call, plus 405-on-GET so mcp-go's StreamableHTTP transport falls back to POST-only. Exits cleanly on SIGTERM (5s shutdown). Heartbeat to stdout proves the per-server log pump works. - test/e2e-config.template.json — new "launcher-test" upstream ({protocol: http, command: ./test/launcher-server/launcher-server, url: http://127.0.0.1:39933/mcp, launcher_wait_timeout: 10s}). - scripts/test-api-e2e.sh: * Prereq step now `go build`s the fixture before booting mcpproxy. * New wait_for_launcher_test_server() polls /servers until launcher-test reports connected. * New test_launcher_lifecycle() runs six sub-assertions: 1. tools/list returns the fixture's "ping" tool through the proxy (proves Spawn -> WaitForURL -> connectHTTP -> MCP initialize -> ListTools end-to-end). 2. pgrep finds the child process by argv. 3. POST /servers/launcher-test/restart yields a different PID. 4. POST /servers/launcher-test/disable reaps the child within 8s. 5. POST /servers/launcher-test/enable respawns it with a fresh PID. 6. /servers/launcher-test/logs?tail=200 contains the launcher banner or fixture stdout. * cleanup() pkill's any leaked launcher-server processes so a failure mid-test doesn't taint the next run. .gitignore: ignore the built fixture binary (/test/launcher-server/launcher-server). Source stays tracked. Refs spec: specs/046-local-launcher-for-http-sse/plan.md (Phase 2 e2e follow-up that was listed as outstanding in the PR test plan). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ec628cc commit 0e7f159

4 files changed

Lines changed: 397 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*.so
66
*.dylib
77
/mcpproxy
8+
/test/launcher-server/launcher-server
89
__debug_bin*
910

1011
# Playwright MCP artifacts

scripts/test-api-e2e.sh

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ cleanup() {
7777

7878
# Additional cleanup - find any remaining mcpproxy processes
7979
pkill -f "mcpproxy.*serve" 2>/dev/null || true
80+
# Reap the launcher-test fixture if our launcher-lifecycle test
81+
# failed before the shutdown reap path could run.
82+
pkill -f "launcher-server.*--port 39933" 2>/dev/null || true
8083
sleep 1
8184

8285
# Clean up test data
@@ -170,6 +173,41 @@ wait_for_server() {
170173
return 1
171174
}
172175

176+
# Wait for the launcher-test server (spec 046) to reach healthy.
177+
# Distinct from wait_for_everything_server because it specifically
178+
# verifies the new "mcpproxy spawned and connected to an HTTP MCP
179+
# server it owns" path rather than a stdio upstream.
180+
wait_for_launcher_test_server() {
181+
local max_attempts=30
182+
local attempt=1
183+
184+
echo "Waiting for launcher-test server to be connected..."
185+
186+
while [ $attempt -le $max_attempts ]; do
187+
local curl_cmd="curl -s --max-time 5 $CURL_CA_OPTS"
188+
if [ ! -z "$API_KEY" ]; then
189+
curl_cmd="$curl_cmd -H \"X-API-Key: $API_KEY\""
190+
fi
191+
curl_cmd="$curl_cmd \"${API_BASE}/servers\""
192+
193+
local response=$(eval $curl_cmd 2>/dev/null)
194+
local connected=$(echo "$response" | jq -r '.data.servers[] | select(.name=="launcher-test") | .connected // false' 2>/dev/null)
195+
196+
if [ "$connected" = "true" ]; then
197+
echo "launcher-test server is connected"
198+
sleep 1
199+
return 0
200+
fi
201+
202+
echo "Attempt $attempt/$max_attempts - launcher-test connected: $connected"
203+
sleep 2
204+
attempt=$((attempt + 1))
205+
done
206+
207+
echo "launcher-test server failed to connect within $max_attempts attempts"
208+
return 1
209+
}
210+
173211
# Wait for everything server to connect and be indexed
174212
wait_for_everything_server() {
175213
local max_attempts=30
@@ -366,6 +404,97 @@ test_sse_auth_failure() {
366404
fi
367405
}
368406

407+
# Spec-046 lifecycle test: verifies that mcpproxy spawned the launcher
408+
# fixture, drove its lifecycle via the REST API, and reaped it cleanly.
409+
#
410+
# Each assertion is a separate test row so a partial failure shows up as
411+
# one failed sub-step rather than a single opaque test fail.
412+
test_launcher_lifecycle() {
413+
log_test "Launcher lifecycle: tools/list call reaches launched HTTP MCP server"
414+
local curl_cmd="curl -s --max-time 10 $CURL_CA_OPTS"
415+
if [ ! -z "$API_KEY" ]; then
416+
curl_cmd="$curl_cmd -H \"X-API-Key: $API_KEY\""
417+
fi
418+
local tools_response
419+
tools_response=$(eval "$curl_cmd \"${API_BASE}/servers/launcher-test/tools\"")
420+
if echo "$tools_response" | jq -e '.success == true and any(.data.tools[]; .name == "ping")' >/dev/null 2>&1; then
421+
log_pass "tools/list returned the fixture's ping tool"
422+
else
423+
log_fail "tools/list missing ping tool. Response: $tools_response"
424+
fi
425+
426+
# Step 2: the child should be a real OS process. pgrep over the
427+
# fixture argv signature lets us detect it without knowing the PID
428+
# mcpproxy assigned. Use `pgrep -f` for arg-line matching.
429+
log_test "Launcher lifecycle: child process is running (pgrep)"
430+
local before_pid
431+
before_pid=$(pgrep -f 'launcher-server.*--port 39933' | head -1)
432+
if [ -n "$before_pid" ]; then
433+
log_pass "child running (pid=$before_pid)"
434+
else
435+
log_fail "no launcher-server process found via pgrep"
436+
fi
437+
438+
# Step 3: restart -> child must be a NEW pid afterwards.
439+
log_test "Launcher lifecycle: POST /restart reaps + respawns child with new PID"
440+
eval "$curl_cmd -X POST \"${API_BASE}/servers/launcher-test/restart\"" >/dev/null
441+
sleep 4
442+
local after_pid
443+
after_pid=$(pgrep -f 'launcher-server.*--port 39933' | head -1)
444+
if [ -z "$after_pid" ]; then
445+
log_fail "child gone after restart — should have respawned"
446+
elif [ "$after_pid" = "$before_pid" ]; then
447+
log_fail "child PID unchanged after restart (was $before_pid, still $after_pid)"
448+
else
449+
log_pass "child respawned (was=$before_pid, now=$after_pid)"
450+
fi
451+
452+
# Step 4: disable -> child must be gone.
453+
log_test "Launcher lifecycle: POST /disable reaps the child"
454+
eval "$curl_cmd -X POST \"${API_BASE}/servers/launcher-test/disable\"" >/dev/null
455+
# Give the launcher up to 8s to deliver SIGTERM + wait for exit.
456+
local waited=0
457+
while [ $waited -lt 8 ]; do
458+
if ! pgrep -f 'launcher-server.*--port 39933' >/dev/null 2>&1; then
459+
break
460+
fi
461+
sleep 1
462+
waited=$((waited + 1))
463+
done
464+
if pgrep -f 'launcher-server.*--port 39933' >/dev/null 2>&1; then
465+
local stragglers
466+
stragglers=$(pgrep -f 'launcher-server.*--port 39933' | tr '\n' ' ')
467+
log_fail "child still alive ${waited}s after disable (pids: $stragglers)"
468+
else
469+
log_pass "child reaped within ${waited}s of disable"
470+
fi
471+
472+
# Step 5: re-enable + reconnect, then verify a fresh PID appears.
473+
log_test "Launcher lifecycle: POST /enable respawns child"
474+
eval "$curl_cmd -X POST \"${API_BASE}/servers/launcher-test/enable\"" >/dev/null
475+
if wait_for_launcher_test_server; then
476+
local reenabled_pid
477+
reenabled_pid=$(pgrep -f 'launcher-server.*--port 39933' | head -1)
478+
if [ -n "$reenabled_pid" ] && [ "$reenabled_pid" != "$after_pid" ]; then
479+
log_pass "child respawned after enable (pid=$reenabled_pid, different from $after_pid)"
480+
else
481+
log_fail "expected a new PID after enable; got '$reenabled_pid' (previous '$after_pid')"
482+
fi
483+
else
484+
log_fail "launcher-test never reconnected after enable"
485+
fi
486+
487+
# Step 6: per-server log should contain the launcher banner.
488+
log_test "Launcher lifecycle: per-server log captures child output"
489+
local logs_response
490+
logs_response=$(eval "$curl_cmd \"${API_BASE}/servers/launcher-test/logs?tail=200\"")
491+
if echo "$logs_response" | jq -r '.data.logs[]?' 2>/dev/null | grep -qE '\[launcher\] starting|\[launcher-server\] listening'; then
492+
log_pass "per-server log contains launcher banner / child stdout"
493+
else
494+
log_fail "per-server log missing launcher banner or child stdout"
495+
fi
496+
}
497+
369498
# Prerequisites check
370499
echo -e "${YELLOW}Checking prerequisites...${NC}"
371500

@@ -396,6 +525,21 @@ if ! command -v npx &> /dev/null; then
396525
exit 1
397526
fi
398527

528+
# Build the launcher-test fixture. This is a tiny HTTP MCP server used
529+
# by the spec-046 launcher-lifecycle test below. We rebuild every run
530+
# so the fixture stays in lockstep with the e2e harness.
531+
LAUNCHER_FIXTURE="./test/launcher-server/launcher-server"
532+
echo -e "${YELLOW}Building launcher-test fixture (./test/launcher-server)...${NC}"
533+
if ! go build -o "$LAUNCHER_FIXTURE" ./test/launcher-server >/tmp/launcher-fixture-build.log 2>&1; then
534+
echo -e "${RED}Error: failed to build launcher-test fixture${NC}"
535+
cat /tmp/launcher-fixture-build.log
536+
exit 1
537+
fi
538+
if [ ! -x "$LAUNCHER_FIXTURE" ]; then
539+
echo -e "${RED}Error: launcher-test fixture not executable at $LAUNCHER_FIXTURE${NC}"
540+
exit 1
541+
fi
542+
399543
echo -e "${GREEN}Prerequisites check passed${NC}"
400544
echo ""
401545

@@ -438,6 +582,13 @@ if ! wait_for_everything_server; then
438582
exit 1
439583
fi
440584

585+
# Wait for the launcher-test server (spec 046). Failing this hard would
586+
# mask other regressions in the suite, so we just warn and let the
587+
# launcher tests below decide whether to fail.
588+
if ! wait_for_launcher_test_server; then
589+
echo -e "${YELLOW}Warning: launcher-test server never connected — launcher lifecycle test will fail loudly below.${NC}"
590+
fi
591+
441592
echo ""
442593
echo -e "${YELLOW}Running API tests...${NC}"
443594
echo ""
@@ -516,6 +667,13 @@ fi
516667
test_api "GET /api/v1/servers (after restart)" "GET" "${API_BASE}/servers" "200" "" \
517668
"jq -e '.success == true and (.data.servers | length) > 0' < '$TEST_RESULTS_FILE' >/dev/null"
518669

670+
# Spec-046 launcher lifecycle (six sub-assertions). Runs late in the
671+
# suite so an earlier failure that takes down mcpproxy short-circuits
672+
# here too rather than producing confusing isolated failures.
673+
echo ""
674+
echo -e "${YELLOW}Running launcher lifecycle test (spec 046)...${NC}"
675+
test_launcher_lifecycle
676+
519677
# Test 17: Test concurrent requests
520678
echo ""
521679
log_test "Concurrent API requests"

test/e2e-config.template.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@
2020
"quarantined": false,
2121
"created": "2025-01-01T00:00:00Z",
2222
"updated": "2025-09-23T10:13:46.357736+03:00"
23+
},
24+
{
25+
"name": "launcher-test",
26+
"protocol": "http",
27+
"url": "http://127.0.0.1:39933/mcp",
28+
"command": "./test/launcher-server/launcher-server",
29+
"args": ["--port", "39933", "--quiet"],
30+
"launcher_wait_timeout": "10s",
31+
"enabled": true,
32+
"quarantined": false,
33+
"created": "2026-05-11T00:00:00Z",
34+
"updated": "2026-05-11T00:00:00Z"
2335
}
2436
],
2537
"top_k": 10,

0 commit comments

Comments
 (0)