{{ message }}
Repository navigation
Expand file tree
/
Copy pathtest-api-e2e.sh
More file actions
executable file
·1555 lines (1354 loc) · 63.2 KB
/
Copy pathtest-api-e2e.sh
File metadata and controls
executable file
·1555 lines (1354 loc) · 63.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
# Note: Not using 'set -e' to allow tests to continue even if some fail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
MCPPROXY_BINARY="./mcpproxy"
CONFIG_TEMPLATE="./test/e2e-config.template.json"
CONFIG_FILE="./test/e2e-config.json"
LISTEN_PORT="${LISTEN_PORT:-8081}"
# Support both HTTP and HTTPS modes
# Default to HTTP for E2E tests since the template config has TLS disabled
USE_HTTPS="${USE_HTTPS:-false}"
if [ "$USE_HTTPS" = "true" ]; then
BASE_URL="https://localhost:${LISTEN_PORT}"
# Check for CA certificate in test-data directory (E2E config uses ./test-data as data_dir)
if [ -f "./test-data/certs/ca.pem" ]; then
CURL_CA_OPTS="--cacert ./test-data/certs/ca.pem"
elif [ -f "./certs/ca.pem" ]; then
CURL_CA_OPTS="--cacert ./certs/ca.pem"
else
CURL_CA_OPTS=""
fi
else
BASE_URL="http://localhost:${LISTEN_PORT}"
CURL_CA_OPTS=""
fi
API_BASE="${BASE_URL}/api/v1"
TEST_DATA_DIR="./test-data"
MCPPROXY_PID=""
TEST_RESULTS_FILE="/tmp/mcpproxy_e2e_results.json"
API_KEY=""
# Test counters
TESTS_RUN=0
TESTS_PASSED=0
TESTS_FAILED=0
TESTS_SKIPPED=0
echo -e "${GREEN}MCPProxy API E2E Tests${NC}"
echo "=============================="
echo -e "${YELLOW}Using everything server for testing${NC}"
echo ""
# T011a: enumerate a process's descendants (recursively), by PID, so
# cleanup() can reap exactly what THIS run started/spawned instead of
# pattern-matching every "mcpproxy"/"launcher-server" process on the
# machine. A blanket `pkill -f` would also kill the user's own tray-managed
# core, or another worktree's parallel test run, whenever their command
# line happens to match the same substring.
# Review round 4 (finding F3): extracted to its own file so it has a test of
# its own (descendant-pids.test.sh) rather than living only where an
# end-to-end run could reach it.
# shellcheck source=./descendant-pids.sh
source "$SCRIPT_DIR/descendant-pids.sh"
# Review round 4 (finding F2): snapshot each descendant's command name
# alongside its PID (as "pid:comm" lines), so the reap loop can verify a
# captured PID still refers to the SAME process before SIGKILLing it — a
# bare `kill -0` only proves the PID is alive, not that it wasn't reused by
# an unrelated process in the window between snapshot and reap.
snapshot_orphans_with_comm() {
local parent="$1"
local pid comm
for pid in $(descendant_pids "$parent"); do
comm=$(ps -o comm= -p "$pid" 2>/dev/null | tr -d ' ')
[ -n "$comm" ] && echo "${pid}:${comm}"
done
}
# Review round 4 (finding F5): cleanup() is invoked both from the EXIT trap
# and, below, from explicit INT/TERM handlers — guard against running the
# body twice (an INT/TERM handler exits explicitly after calling cleanup,
# which would otherwise also re-fire the EXIT trap).
CLEANUP_DONE=0
# Cleanup function
cleanup() {
if [ "$CLEANUP_DONE" = "1" ]; then
return
fi
CLEANUP_DONE=1
echo -e "\n${YELLOW}Cleaning up...${NC}"
# Snapshot descendants (e.g. the launcher-test fixture, spawned by
# mcpproxy itself) BEFORE we touch the parent — once mcpproxy exits,
# orphaned children are reparented and this parentage link is lost.
# Review round 4 (finding F1, partial): a single snapshot only covers the
# instant before we send the kill signal — a child spawned during
# mcpproxy's own graceful-shutdown window (below) would otherwise never
# be captured, since once mcpproxy exits its children are reparented and
# pgrep -P can no longer find them via this PID. We keep re-snapshotting
# on every poll of the wait loop, while mcpproxy is confirmed still
# alive, to shrink that window to (effectively) zero. This cannot cover
# the case where mcpproxy has ALREADY exited (crash/OOM) by the time
# cleanup() first runs — its children are reparented before we ever get
# a PID to snapshot, and there is no way to recover that parentage link
# without reintroducing a system-wide pattern match, which T011a
# deliberately removed (see comment above) because it can kill unrelated
# mcpproxy instances. Accepted as a known, narrow residual gap.
local orphan_candidates=""
if [ ! -z "$MCPPROXY_PID" ]; then
orphan_candidates=$(snapshot_orphans_with_comm "$MCPPROXY_PID")
fi
# Kill mcpproxy if running
if [ ! -z "$MCPPROXY_PID" ]; then
echo "Stopping mcpproxy (PID: $MCPPROXY_PID)"
kill $MCPPROXY_PID 2>/dev/null || true
# Wait for graceful shutdown with timeout
local count=0
while [ $count -lt 10 ]; do
if ! kill -0 $MCPPROXY_PID 2>/dev/null; then
echo "Process stopped gracefully"
break
fi
# F1: re-snapshot while mcpproxy is still confirmed alive (see
# comment above the initial snapshot).
orphan_candidates="$orphan_candidates
$(snapshot_orphans_with_comm "$MCPPROXY_PID")"
sleep 1
count=$((count + 1))
done
# Force kill if still running
if kill -0 $MCPPROXY_PID 2>/dev/null; then
echo "Force killing process"
kill -9 $MCPPROXY_PID 2>/dev/null || true
sleep 1
fi
fi
orphan_candidates=$(echo "$orphan_candidates" | sort -u)
# T011a: reap only the specific PIDs we saw as children of OUR
# mcpproxy process (e.g. the launcher-test fixture, if our
# launcher-lifecycle test failed before mcpproxy's own graceful
# shutdown reap path could run). Never a system-wide pattern match —
# that would also kill unrelated mcpproxy instances (the user's tray
# core, another worktree's E2E run, etc.). A PID we captured is only
# killed if it is still alive AND its command name still matches what
# we recorded at snapshot time (review round 4, finding F2: `kill -0`
# alone proves liveness, not identity — in the 1-12+s window between
# snapshot and this reap loop, a captured PID could exit and be reused
# by an unrelated process, which a bare `kill -0` check would then have
# us SIGKILL).
local entry pid comm current_comm
for entry in $orphan_candidates; do
[ -z "$entry" ] && continue
pid="${entry%%:*}"
comm="${entry#*:}"
[ -z "$pid" ] && continue
if kill -0 "$pid" 2>/dev/null; then
current_comm=$(ps -o comm= -p "$pid" 2>/dev/null | tr -d ' ')
if [ -n "$current_comm" ] && [ "$current_comm" = "$comm" ]; then
echo "Reaping orphaned child process (PID: $pid, $comm)"
kill -9 "$pid" 2>/dev/null || true
else
echo "Skipping PID $pid: process identity changed since snapshot ($comm -> $current_comm), likely PID reuse"
fi
fi
done
sleep 1
# Review round 4 (finding F4): the audit_log sub-test's own instance and
# scratch dirs/log are normally torn down on its own fall-through path
# further down this script — but an early exit (Ctrl-C, a stray `exit`)
# while that sub-test is running would otherwise leak AUDIT_PID and its
# data/log/jsonl directories, since this trap never referenced them.
# These variables are declared later in the script; referencing them
# here before that point is safe (bash treats an unset variable as
# empty in this non-`set -u` script), and re-running this block after
# the sub-test's own cleanup already ran is a harmless no-op (the PID is
# already dead, the directories already removed).
if [ -n "${AUDIT_PID:-}" ] && kill -0 "$AUDIT_PID" 2>/dev/null; then
echo "Stopping audit-log instance (PID: $AUDIT_PID)"
kill "$AUDIT_PID" 2>/dev/null || true
sleep 1
kill -9 "$AUDIT_PID" 2>/dev/null || true
fi
if [ -n "${AUDIT_DATA_DIR:-}" ] && [ -d "$AUDIT_DATA_DIR" ]; then
rm -rf "$AUDIT_DATA_DIR"
fi
if [ -n "${AUDIT_JSONL_DIR:-}" ] && [ -d "$AUDIT_JSONL_DIR" ]; then
rm -rf "$AUDIT_JSONL_DIR"
fi
if [ -n "${AUDIT_SERVER_LOG:-}" ]; then
rm -f "$AUDIT_SERVER_LOG"
fi
# Clean up test data
if [ -d "$TEST_DATA_DIR" ]; then
rm -rf "$TEST_DATA_DIR"
fi
# Clean up test results
rm -f "$TEST_RESULTS_FILE"
# T113: the script overwrites the tracked test/e2e-config.json as scratch
# (fresh copy from the template + port substitution, and the audit_log
# sub-test below adds its own scratch config next to it) — restore the
# tracked file so the repo is left clean after every run, pass or fail.
git checkout -- "$CONFIG_FILE" 2>/dev/null || true
echo "Cleanup complete"
}
# Set up cleanup trap.
# Review round 4 (finding F5): verified empirically (process-group-wide
# signals, matching what a terminal actually delivers on Ctrl-C) that the
# plain `trap cleanup EXIT` already ran cleanup correctly for both a real
# Ctrl-C and a plain SIGTERM — the foreground child (sleep/curl) dies from
# the same signal, bash's `wait` unblocks, and an untrapped terminating
# signal still fires the EXIT trap on the way out. The narrower gap this
# closes: a supervisor/orchestrator (not a terminal) that signals only this
# script's own PID rather than its process group, in which case delivery is
# deferred until the current foreground command finishes on its own either
# way (per bash's documented trap semantics) — but explicit handlers make
# the resulting exit code deliberate (130/143) instead of whatever bash's
# default disposition produces, and this is the near-universal idiom for
# these scripts regardless. `trap - EXIT` inside each handler removes the
# EXIT trap before the explicit `exit`, so cleanup() (idempotent via
# CLEANUP_DONE regardless) runs exactly once.
trap 'trap - EXIT; cleanup; exit 130' INT
trap 'trap - EXIT; cleanup; exit 143' TERM
trap cleanup EXIT
# Helper functions
log_test() {
echo -e "${BLUE}[TEST]${NC} $1"
TESTS_RUN=$((TESTS_RUN + 1))
}
log_pass() {
echo -e "${GREEN}[PASS]${NC} $1"
TESTS_PASSED=$((TESTS_PASSED + 1))
}
log_fail() {
echo -e "${RED}[FAIL]${NC} $1"
TESTS_FAILED=$((TESTS_FAILED + 1))
}
# log_skip: non-blocking outcome for tests whose subject is an external,
# third-party dependency (e.g. the live public MCP registry). A release-blocking
# gate must not be held hostage by a third party's uptime, so a confirmed outage
# of the *external* service is recorded as skipped, NOT failed. Reachable-but-wrong
# responses still hard-fail via log_fail, preserving proxy-regression coverage.
log_skip() {
echo -e "${YELLOW}[SKIP]${NC} $1"
TESTS_SKIPPED=$((TESTS_SKIPPED + 1))
}
# Extract the API key from the CONFIG FILE the server was started with.
# Optional $1 overrides the config path (used by scripts/test-extract-api-key.sh).
#
# SEC-01: the auto-generated key is deliberately no longer written to the server
# log - it is a root credential and the log is a plaintext file at rest. The
# config file is where mcpproxy persists it (the same source
# scripts/dev-server-edition.sh reads), so that is what we read.
#
# The write-back happens during startup, so callers poll: an empty API_KEY here
# means "not written yet", not "failed".
extract_api_key() {
local config_file="${1:-${CONFIG_FILE:-}}"
if [ -n "$config_file" ] && [ -f "$config_file" ]; then
API_KEY=$(jq -r '.api_key // empty' "$config_file" 2>/dev/null)
if [ ! -z "$API_KEY" ]; then
echo "Extracted API key: ${API_KEY:0:8}..."
fi
fi
}
# Wait for server to be ready
wait_for_server() {
local max_attempts=30
local attempt=1
echo "Waiting for server to be ready..."
while [ $attempt -le $max_attempts ]; do
# First extract API key from logs if available
extract_api_key
# Build curl command with CA certificate if it exists, otherwise use insecure for initial check
local curl_cmd="curl -s -f --max-time 5"
if [ "$USE_HTTPS" = "true" ]; then
if [ -f "./test-data/certs/ca.pem" ]; then
curl_cmd="$curl_cmd --cacert ./test-data/certs/ca.pem"
elif [ -f "./certs/ca.pem" ]; then
curl_cmd="$curl_cmd --cacert ./certs/ca.pem"
else
# For initial startup, use insecure until certificates are generated
curl_cmd="$curl_cmd -k"
fi
fi
if [ ! -z "$API_KEY" ]; then
curl_cmd="$curl_cmd -H \"X-API-Key: $API_KEY\""
fi
curl_cmd="$curl_cmd \"${BASE_URL}/api/v1/servers\""
if eval $curl_cmd > /dev/null 2>&1; then
echo "Server is ready!"
# Update CURL_CA_OPTS for subsequent tests if certificates now exist
if [ "$USE_HTTPS" = "true" ]; then
if [ -f "./test-data/certs/ca.pem" ]; then
CURL_CA_OPTS="--cacert ./test-data/certs/ca.pem"
elif [ -f "./certs/ca.pem" ]; then
CURL_CA_OPTS="--cacert ./certs/ca.pem"
fi
fi
return 0
fi
echo "Attempt $attempt/$max_attempts - server not ready yet"
sleep 1
attempt=$((attempt + 1))
done
echo "Server failed to start within $max_attempts seconds"
return 1
}
# Wait for the launcher-test server (spec 046) to reach healthy.
# Distinct from wait_for_everything_server because it specifically
# verifies the new "mcpproxy spawned and connected to an HTTP MCP
# server it owns" path rather than a stdio upstream.
wait_for_launcher_test_server() {
local max_attempts=30
local attempt=1
echo "Waiting for launcher-test server to be connected..."
while [ $attempt -le $max_attempts ]; do
local curl_cmd="curl -s --max-time 5 $CURL_CA_OPTS"
if [ ! -z "$API_KEY" ]; then
curl_cmd="$curl_cmd -H \"X-API-Key: $API_KEY\""
fi
curl_cmd="$curl_cmd \"${API_BASE}/servers\""
local response=$(eval $curl_cmd 2>/dev/null)
local connected=$(echo "$response" | jq -r '.data.servers[] | select(.name=="launcher-test") | .connected // false' 2>/dev/null)
if [ "$connected" = "true" ]; then
echo "launcher-test server is connected"
sleep 1
return 0
fi
echo "Attempt $attempt/$max_attempts - launcher-test connected: $connected"
sleep 2
attempt=$((attempt + 1))
done
echo "launcher-test server failed to connect within $max_attempts attempts"
return 1
}
# Wait for everything server to connect and be indexed
wait_for_everything_server() {
local max_attempts=30
local attempt=1
echo "Waiting for everything server to connect and be indexed..."
while [ $attempt -le $max_attempts ]; do
# Check if everything server is connected
local curl_cmd="curl -s --max-time 5 $CURL_CA_OPTS"
if [ ! -z "$API_KEY" ]; then
curl_cmd="$curl_cmd -H \"X-API-Key: $API_KEY\""
fi
curl_cmd="$curl_cmd \"${API_BASE}/servers\""
local response=$(eval $curl_cmd 2>/dev/null)
local connected=$(echo "$response" | jq -r '.data.servers[0].connected // false' 2>/dev/null)
local enabled=$(echo "$response" | jq -r '.data.servers[0].enabled // false' 2>/dev/null)
if [ "$connected" = "true" ]; then
echo "Everything server is connected!"
# Wait a bit more for indexing to complete
sleep 3
return 0
fi
echo "Attempt $attempt/$max_attempts - connected: $connected, enabled: $enabled"
sleep 2
attempt=$((attempt + 1))
done
echo "Everything server failed to connect within $max_attempts attempts"
return 1
}
# Test helper function
test_api() {
local test_name="$1"
local method="$2"
local url="$3"
local expected_status="$4"
local data="$5"
local extra_checks="$6"
log_test "$test_name"
# Clear previous test results
rm -f "$TEST_RESULTS_FILE"
local curl_args=("-s" "-w" "%{http_code}" "-o" "$TEST_RESULTS_FILE" "--max-time" "10")
# Add CA certificate for HTTPS if needed
if [ ! -z "$CURL_CA_OPTS" ]; then
curl_args+=($CURL_CA_OPTS)
fi
# Add API key header if available
if [ ! -z "$API_KEY" ]; then
curl_args+=("-H" "X-API-Key: $API_KEY")
fi
if [ "$method" = "POST" ]; then
curl_args+=("-X" "POST" "-H" "Content-Type: application/json")
if [ ! -z "$data" ]; then
curl_args+=("-d" "$data")
fi
fi
curl_args+=("$url")
local status_code=$(curl "${curl_args[@]}")
if [ "$status_code" = "$expected_status" ]; then
if [ ! -z "$extra_checks" ]; then
if eval "$extra_checks"; then
log_pass "$test_name"
return 0
else
log_fail "$test_name - extra checks failed"
return 1
fi
else
log_pass "$test_name"
return 0
fi
else
if [ "$status_code" = "000" ]; then
log_fail "$test_name - Connection failed (timeout or refused)"
echo "Note: Server may be down or not responding. Check server logs."
else
log_fail "$test_name - Expected status $expected_status, got $status_code"
fi
if [ -f "$TEST_RESULTS_FILE" ] && [ -s "$TEST_RESULTS_FILE" ]; then
echo "Response body:"
cat "$TEST_RESULTS_FILE"
echo
fi
return 1
fi
}
# SSE test helper
test_sse() {
local test_name="$1"
log_test "$test_name"
# Test SSE endpoint by connecting and reading first few events
# Use Perl for cross-platform timeout (macOS doesn't have timeout command)
local curl_cmd="curl -s -N $CURL_CA_OPTS"
if [ ! -z "$API_KEY" ]; then
curl_cmd="$curl_cmd -H \"X-API-Key: $API_KEY\""
fi
curl_cmd="$curl_cmd \"${BASE_URL}/events\""
# Run with 5 second timeout using Perl
perl -e 'alarm 5; exec @ARGV' sh -c "$curl_cmd" | head -n 10 > "$TEST_RESULTS_FILE" 2>/dev/null || true
if [ -s "$TEST_RESULTS_FILE" ] && grep -q "data:" "$TEST_RESULTS_FILE"; then
log_pass "$test_name"
return 0
else
log_fail "$test_name - No SSE events received"
return 1
fi
}
# Enhanced SSE test with query parameter
test_sse_with_query_param() {
local test_name="$1"
log_test "$test_name"
# Test SSE endpoint with API key as query parameter
local sse_url="${BASE_URL}/events"
if [ ! -z "$API_KEY" ]; then
sse_url="${sse_url}?apikey=${API_KEY}"
fi
# Use Perl for cross-platform timeout (macOS doesn't have timeout command)
perl -e 'alarm 5; exec @ARGV' curl -s -N $CURL_CA_OPTS "$sse_url" | head -n 10 > "$TEST_RESULTS_FILE" 2>/dev/null || true
if [ -s "$TEST_RESULTS_FILE" ] && grep -q "data:" "$TEST_RESULTS_FILE"; then
log_pass "$test_name"
return 0
else
log_fail "$test_name - No SSE events received with query parameter"
return 1
fi
}
# Test SSE connection establishment
test_sse_connection() {
local test_name="$1"
log_test "$test_name"
# Test that SSE endpoint establishes proper connection headers
local curl_cmd="curl -s -I --max-time 3 $CURL_CA_OPTS"
if [ ! -z "$API_KEY" ]; then
curl_cmd="$curl_cmd -H \"X-API-Key: $API_KEY\""
fi
curl_cmd="$curl_cmd \"${BASE_URL}/events\""
eval "$curl_cmd" > "$TEST_RESULTS_FILE" 2>/dev/null || true
if [ -s "$TEST_RESULTS_FILE" ] && grep -q "text/event-stream" "$TEST_RESULTS_FILE" && grep -q "Cache-Control: no-cache" "$TEST_RESULTS_FILE"; then
log_pass "$test_name"
return 0
else
log_fail "$test_name - Improper SSE headers"
echo "Headers received:"
cat "$TEST_RESULTS_FILE"
return 1
fi
}
# Test SSE authentication failure
test_sse_auth_failure() {
local test_name="$1"
log_test "$test_name"
# Test SSE with wrong API key (if API key is configured)
if [ -z "$API_KEY" ]; then
log_pass "$test_name (skipped - no API key configured)"
return 0
fi
local status_code=$(curl -s --max-time 5 -w "%{http_code}" -o /dev/null $CURL_CA_OPTS -H "X-API-Key: wrong-api-key" "${BASE_URL}/events")
if [ "$status_code" = "401" ]; then
log_pass "$test_name"
return 0
else
log_fail "$test_name - Expected 401, got $status_code"
return 1
fi
}
# Spec-046 lifecycle test: verifies that mcpproxy spawned the launcher
# fixture, drove its lifecycle via the REST API, and reaped it cleanly.
#
# Each assertion is a separate test row so a partial failure shows up as
# one failed sub-step rather than a single opaque test fail.
test_launcher_lifecycle() {
log_test "Launcher lifecycle: tools/list call reaches launched HTTP MCP server"
local curl_cmd="curl -s --max-time 10 $CURL_CA_OPTS"
if [ ! -z "$API_KEY" ]; then
curl_cmd="$curl_cmd -H \"X-API-Key: $API_KEY\""
fi
local tools_response
tools_response=$(eval "$curl_cmd \"${API_BASE}/servers/launcher-test/tools\"")
if echo "$tools_response" | jq -e '.success == true and any(.data.tools[]; .name == "ping")' >/dev/null 2>&1; then
log_pass "tools/list returned the fixture's ping tool"
else
log_fail "tools/list missing ping tool. Response: $tools_response"
fi
# Step 2: the child should be a real OS process. pgrep over the
# fixture argv signature lets us detect it without knowing the PID
# mcpproxy assigned. Use `pgrep -f` for arg-line matching.
log_test "Launcher lifecycle: child process is running (pgrep)"
local before_pid
before_pid=$(pgrep -f 'launcher-server.*--port 39933' | head -1)
if [ -n "$before_pid" ]; then
log_pass "child running (pid=$before_pid)"
else
log_fail "no launcher-server process found via pgrep"
fi
# Step 3: restart -> child must be a NEW pid afterwards.
log_test "Launcher lifecycle: POST /restart reaps + respawns child with new PID"
eval "$curl_cmd -X POST \"${API_BASE}/servers/launcher-test/restart\"" >/dev/null
sleep 4
local after_pid
after_pid=$(pgrep -f 'launcher-server.*--port 39933' | head -1)
if [ -z "$after_pid" ]; then
log_fail "child gone after restart — should have respawned"
elif [ "$after_pid" = "$before_pid" ]; then
log_fail "child PID unchanged after restart (was $before_pid, still $after_pid)"
else
log_pass "child respawned (was=$before_pid, now=$after_pid)"
fi
# Step 4: disable -> child must be gone.
log_test "Launcher lifecycle: POST /disable reaps the child"
eval "$curl_cmd -X POST \"${API_BASE}/servers/launcher-test/disable\"" >/dev/null
# Give the launcher up to 8s to deliver SIGTERM + wait for exit.
local waited=0
while [ $waited -lt 8 ]; do
if ! pgrep -f 'launcher-server.*--port 39933' >/dev/null 2>&1; then
break
fi
sleep 1
waited=$((waited + 1))
done
if pgrep -f 'launcher-server.*--port 39933' >/dev/null 2>&1; then
local stragglers
stragglers=$(pgrep -f 'launcher-server.*--port 39933' | tr '\n' ' ')
log_fail "child still alive ${waited}s after disable (pids: $stragglers)"
else
log_pass "child reaped within ${waited}s of disable"
fi
# Step 5: re-enable + reconnect, then verify a fresh PID appears.
log_test "Launcher lifecycle: POST /enable respawns child"
eval "$curl_cmd -X POST \"${API_BASE}/servers/launcher-test/enable\"" >/dev/null
if wait_for_launcher_test_server; then
local reenabled_pid
reenabled_pid=$(pgrep -f 'launcher-server.*--port 39933' | head -1)
if [ -n "$reenabled_pid" ] && [ "$reenabled_pid" != "$after_pid" ]; then
log_pass "child respawned after enable (pid=$reenabled_pid, different from $after_pid)"
else
log_fail "expected a new PID after enable; got '$reenabled_pid' (previous '$after_pid')"
fi
else
log_fail "launcher-test never reconnected after enable"
fi
# Step 6: per-server log should contain the launcher banner.
log_test "Launcher lifecycle: per-server log captures child output"
local logs_response
logs_response=$(eval "$curl_cmd \"${API_BASE}/servers/launcher-test/logs?tail=200\"")
if echo "$logs_response" | jq -r '.data.logs[]?' 2>/dev/null | grep -qE '\[launcher\] starting|\[launcher-server\] listening'; then
log_pass "per-server log contains launcher banner / child stdout"
else
log_fail "per-server log missing launcher banner or child stdout"
fi
}
# Prerequisites check
echo -e "${YELLOW}Checking prerequisites...${NC}"
# Check if mcpproxy binary exists
if [ ! -f "$MCPPROXY_BINARY" ]; then
echo -e "${RED}Error: mcpproxy binary not found at $MCPPROXY_BINARY${NC}"
echo "Please run: go build -o mcpproxy ./cmd/mcpproxy"
exit 1
fi
# Check if config template exists
if [ ! -f "$CONFIG_TEMPLATE" ]; then
echo -e "${RED}Error: Config template not found at $CONFIG_TEMPLATE${NC}"
exit 1
fi
# Check if jq is available for JSON parsing
if ! command -v jq &> /dev/null; then
echo -e "${RED}Error: jq is required for JSON parsing${NC}"
echo "Please install jq: brew install jq (macOS) or apt-get install jq (Ubuntu)"
exit 1
fi
# Check if npx is available (needed for everything server)
if ! command -v npx &> /dev/null; then
echo -e "${RED}Error: npx is required for @modelcontextprotocol/server-everything${NC}"
echo "Please install Node.js and npm"
exit 1
fi
# Build the launcher-test fixture. This is a tiny HTTP MCP server used
# by the spec-046 launcher-lifecycle test below. We rebuild every run
# so the fixture stays in lockstep with the e2e harness.
LAUNCHER_FIXTURE="./test/launcher-server/launcher-server"
echo -e "${YELLOW}Building launcher-test fixture (./test/launcher-server)...${NC}"
if ! go build -o "$LAUNCHER_FIXTURE" ./test/launcher-server >/tmp/launcher-fixture-build.log 2>&1; then
echo -e "${RED}Error: failed to build launcher-test fixture${NC}"
cat /tmp/launcher-fixture-build.log
exit 1
fi
if [ ! -x "$LAUNCHER_FIXTURE" ]; then
echo -e "${RED}Error: launcher-test fixture not executable at $LAUNCHER_FIXTURE${NC}"
exit 1
fi
echo -e "${GREEN}Prerequisites check passed${NC}"
echo ""
# Start mcpproxy server
echo -e "${YELLOW}Starting mcpproxy server...${NC}"
# Create test data directory
mkdir -p "$TEST_DATA_DIR"
# Copy fresh config from template to ensure clean state
echo "Copying fresh config from template..."
cp "$CONFIG_TEMPLATE" "$CONFIG_FILE"
# Substitute LISTEN_PORT in config file if not using default 8081
if [ "$LISTEN_PORT" != "8081" ]; then
sed -i '' "s/:8081/:${LISTEN_PORT}/g" "$CONFIG_FILE"
echo "Updated listen port to :${LISTEN_PORT}"
fi
# Spec 110 (catalog popularity signal, FR-011 kill switch): E2E must not
# depend on api.github.com (SC-005) — no outbound GitHub fetch, no rate-limit
# flakiness, no network dependency for a suite that otherwise runs fully
# offline against the local server under test.
export MCPPROXY_CATALOG_POPULARITY=false
# Start server in background
$MCPPROXY_BINARY serve --config="$CONFIG_FILE" --log-level=info > "/tmp/mcpproxy_e2e.log" 2>&1 &
MCPPROXY_PID=$!
echo "Started mcpproxy with PID: $MCPPROXY_PID"
echo "Log file: /tmp/mcpproxy_e2e.log"
# Wait for server to be ready
if ! wait_for_server; then
echo -e "${RED}Failed to start server${NC}"
echo "Server logs:"
cat "/tmp/mcpproxy_e2e.log"
exit 1
fi
# Wait for everything server to connect
if ! wait_for_everything_server; then
echo -e "${RED}Everything server failed to connect${NC}"
echo "Server logs:"
tail -50 "/tmp/mcpproxy_e2e.log"
exit 1
fi
# Wait for the launcher-test server (spec 046). Failing this hard would
# mask other regressions in the suite, so we just warn and let the
# launcher tests below decide whether to fail.
if ! wait_for_launcher_test_server; then
echo -e "${YELLOW}Warning: launcher-test server never connected — launcher lifecycle test will fail loudly below.${NC}"
fi
echo ""
echo -e "${YELLOW}Running API tests...${NC}"
echo ""
# Test 0: Get info (version and update information)
test_api "GET /api/v1/info" "GET" "${API_BASE}/info" "200" "" \
"jq -e '.success == true and .data.version != null and .data.version != \"\" and .data.listen_addr != null and .data.endpoints.http != null and .data.endpoints.socket != null' < '$TEST_RESULTS_FILE' >/dev/null"
# Test 1: Get servers list
test_api "GET /api/v1/servers" "GET" "${API_BASE}/servers" "200" "" \
"jq -e '.success == true and (.data.servers | length) > 0' < '$TEST_RESULTS_FILE' >/dev/null"
# Test 2: Get specific server tools
test_api "GET /api/v1/servers/everything/tools" "GET" "${API_BASE}/servers/everything/tools" "200" "" \
"jq -e '.success == true and (.data.tools | length) > 0' < '$TEST_RESULTS_FILE' >/dev/null"
# Test 2b: Global tools overview (spec 050, issue #437) — consolidated listing + stats
test_api "GET /api/v1/tools" "GET" "${API_BASE}/tools" "200" "" \
"jq -e '.success == true and (.data.tools | type == \"array\") and (.data.tools | length) > 0 and (.data.stats | has(\"total\") and has(\"enabled\") and has(\"disabled\") and has(\"pending_approval\")) and (.data.stats.total == (.data.tools | length))' < '$TEST_RESULTS_FILE' >/dev/null"
# Test 3: Search tools
test_api "GET /api/v1/index/search?q=echo" "GET" "${API_BASE}/index/search?q=echo" "200" "" \
"jq -e '.success == true and (.data.results | length) > 0' < '$TEST_RESULTS_FILE' >/dev/null"
# Test 4: Search tools with limit
test_api "GET /api/v1/index/search?q=tool&limit=5" "GET" "${API_BASE}/index/search?q=tool&limit=5" "200" "" \
"jq -e '.success == true and (.data.results | length) <= 5' < '$TEST_RESULTS_FILE' >/dev/null"
# Test 5: Get server logs
test_api "GET /api/v1/servers/everything/logs" "GET" "${API_BASE}/servers/everything/logs?tail=10" "200" "" \
"jq -e '.success == true and (.data.logs | type) == \"array\"' < '$TEST_RESULTS_FILE' >/dev/null"
# Test 6: Disable server
test_api "POST /api/v1/servers/everything/disable" "POST" "${API_BASE}/servers/everything/disable" "200" "" \
"jq -e '.success == true and .data.success == true' < '$TEST_RESULTS_FILE' >/dev/null"
# Test 7: Enable server
test_api "POST /api/v1/servers/everything/enable" "POST" "${API_BASE}/servers/everything/enable" "200" "" \
"jq -e '.success == true and .data.success == true' < '$TEST_RESULTS_FILE' >/dev/null"
# Give server time to update config after enable
sleep 2
# Test 8: Restart server
test_api "POST /api/v1/servers/everything/restart" "POST" "${API_BASE}/servers/everything/restart" "200" "" \
"jq -e '.success == true and .data.success == true' < '$TEST_RESULTS_FILE' >/dev/null"
# Test 9: SSE Events (Header authentication)
test_sse "GET /events (SSE with header auth)"
# Test 10: SSE Events (Query parameter authentication)
test_sse_with_query_param "GET /events (SSE with query param auth)"
# Test 11: SSE Connection headers
test_sse_connection "GET /events (SSE connection headers)"
# Test 12: SSE Authentication failure
test_sse_auth_failure "GET /events (SSE auth failure)"
# Test 13: Error handling - invalid server
# Note: 404 is returned for nonexistent servers (more appropriate than 500)
test_api "GET /api/v1/servers/nonexistent/tools" "GET" "${API_BASE}/servers/nonexistent/tools" "404" ""
# Test 14: Error handling - invalid search query
test_api "GET /api/v1/index/search (missing query)" "GET" "${API_BASE}/index/search" "400" ""
# Test 15: Error handling - invalid server action
test_api "POST /api/v1/servers/nonexistent/enable" "POST" "${API_BASE}/servers/nonexistent/enable" "500" ""
# Wait for everything server to reconnect after restart
echo ""
echo -e "${YELLOW}Waiting for everything server to reconnect after restart...${NC}"
if wait_for_everything_server; then
echo -e "${GREEN}Everything server reconnected successfully${NC}"
else
echo -e "${YELLOW}Warning: Everything server didn't reconnect, but tests can continue${NC}"
fi
# Test 16: Verify server is working after restart
test_api "GET /api/v1/servers (after restart)" "GET" "${API_BASE}/servers" "200" "" \
"jq -e '.success == true and (.data.servers | length) > 0' < '$TEST_RESULTS_FILE' >/dev/null"
# Spec-046 launcher lifecycle (six sub-assertions). Runs late in the
# suite so an earlier failure that takes down mcpproxy short-circuits
# here too rather than producing confusing isolated failures.
echo ""
echo -e "${YELLOW}Running launcher lifecycle test (spec 046)...${NC}"
test_launcher_lifecycle
# Test 17: Test concurrent requests
echo ""
log_test "Concurrent API requests"
# Start concurrent requests
curl_base="curl -s --max-time 10"
if [ ! -z "$API_KEY" ]; then
curl_base="$curl_base -H \"X-API-Key: $API_KEY\""
fi
eval "$curl_base \"${API_BASE}/servers\"" > /dev/null &
PID1=$!
eval "$curl_base \"${API_BASE}/index/search?q=test\"" > /dev/null &
PID2=$!
eval "$curl_base \"${API_BASE}/servers/everything/tools\"" > /dev/null &
PID3=$!
# Wait for all requests with timeout
success=true
for pid in $PID1 $PID2 $PID3; do
if ! wait $pid; then
success=false
fi
done
if [ "$success" = true ]; then
log_pass "Concurrent API requests"
else
log_fail "Concurrent API requests"
fi
# Test 18: Get config
test_api "GET /api/v1/config" "GET" "${API_BASE}/config" "200" "" \
"jq -e '.success == true and .data.config != null' < '$TEST_RESULTS_FILE' >/dev/null"
# Test 19: Get diagnostics
test_api "GET /api/v1/diagnostics" "GET" "${API_BASE}/diagnostics" "200" "" \
"jq -e '.success == true and .data.total_issues != null' < '$TEST_RESULTS_FILE' >/dev/null"
# Test 20: Get tool call history
test_api "GET /api/v1/tool-calls" "GET" "${API_BASE}/tool-calls?limit=10" "200" "" \
"jq -e '.success == true and .data.tool_calls != null' < '$TEST_RESULTS_FILE' >/dev/null"
# Test 21: Execute a tool call via MCP (to create history)
echo ""
echo -e "${YELLOW}Executing a tool call to create history for replay test...${NC}"
TOOL_CALL_ID=""
# Make a tool call using the echo_tool from everything server
$MCPPROXY_BINARY -d "$TEST_DATA_DIR" call tool-read --tool-name="everything:echo_tool" --json_args='{"message":"test replay"}' > /dev/null 2>&1 || true
sleep 2 # Wait for call to be recorded
# Test 22: Get tool call history again (should have at least one call)
# DISABLED: This test is flaky because tool call history tracking may not work via CLI
# test_api "GET /api/v1/tool-calls (with history)" "GET" "${API_BASE}/tool-calls?limit=100" "200" "" \
# "jq -e '.success == true and (.data.tool_calls | length) > 0' < '$TEST_RESULTS_FILE' >/dev/null"
echo -e "${YELLOW}Skipping GET /api/v1/tool-calls (with history) - test disabled${NC}"
# Extract a tool call ID for replay test
if [ -f "$TEST_RESULTS_FILE" ]; then
TOOL_CALL_ID=$(jq -r '.data.tool_calls[0].id // empty' < "$TEST_RESULTS_FILE" 2>/dev/null)
fi
# Test 23: Replay tool call (if we have an ID)
if [ ! -z "$TOOL_CALL_ID" ]; then
echo ""
echo -e "${YELLOW}Testing replay with tool call ID: $TOOL_CALL_ID${NC}"
# Replay with modified arguments
REPLAY_DATA='{"arguments":{"message":"replayed message"}}'
test_api "POST /api/v1/tool-calls/$TOOL_CALL_ID/replay" "POST" "${API_BASE}/tool-calls/${TOOL_CALL_ID}/replay" "200" "$REPLAY_DATA" \
"jq -e '.success == true and .data.new_call_id != null and .data.replayed_from == \"'$TOOL_CALL_ID'\"' < '$TEST_RESULTS_FILE' >/dev/null"
else
echo -e "${YELLOW}Skipping replay test - no tool call ID available${NC}"
# Still count it as a test for consistency
log_test "POST /api/v1/tool-calls/{id}/replay"
log_pass "POST /api/v1/tool-calls/{id}/replay (skipped - no history)"
fi
# Test 24: Error handling - replay nonexistent tool call
test_api "POST /api/v1/tool-calls/nonexistent/replay" "POST" "${API_BASE}/tool-calls/nonexistent-id-12345/replay" "500" '{"arguments":{}}'
# Test 25: List registries (Phase 7)
log_test "GET /api/v1/registries"
RESPONSE=$(curl -s --max-time 10 $CURL_CA_OPTS -H "X-API-Key: $API_KEY" "${API_BASE}/registries")
echo "$RESPONSE" > "$TEST_RESULTS_FILE"
if echo "$RESPONSE" | jq -e '.success == true and .data.registries != null and .data.total > 0' >/dev/null; then
log_pass "GET /api/v1/registries - Response has registries array and total count"
else
log_fail "GET /api/v1/registries - Expected registries data structure" \
"jq -e '.success == true and .data.registries != null and .data.total > 0' < '$TEST_RESULTS_FILE' >/dev/null"
fi
# Tests 26/27 proxy to the LIVE public "official" MCP registry. When that
# third-party service is slow/down we log_skip instead of log_fail — a
# release-blocking gate must not depend on a third party's uptime. But a
# success:false is ONLY treated as an outage when there is transport-level
# evidence: curl itself failed/timed out (rc != 0 or empty body), or the proxy's
# error envelope names an upstream fetch/transport failure. A success:false whose
# .error is anything else is a real proxy-side regression (same writeError path
# the handler uses for its own bugs) and must hard-fail — otherwise the gate
# would silently skip an API regression it exists to catch.
registry_is_outage() {
# $1 = curl rc, $2 = response body
local rc="$1" body="$2"
if [ "$rc" -ne 0 ] || [ -z "$body" ]; then
return 0
fi
if echo "$body" | jq -e '.success == false' >/dev/null 2>&1; then
local err
err=$(echo "$body" | jq -r '.error // ""' 2>/dev/null)
if echo "$err" | grep -qiE 'timeout|timed out|deadline exceeded|connection refused|no such host|no route to host|network is unreachable|i/o timeout|tls handshake|EOF|temporarily|unreachable|dial tcp|refused|reset by peer|502|503|504|bad gateway|gateway timeout|upstream|server misbehaving'; then
return 0
fi
fi
return 1
}
# Test 26: Search registry servers (Phase 7)
log_test "GET /api/v1/registries/{id}/servers"
RESPONSE=$(curl -s --max-time 10 $CURL_CA_OPTS -H "X-API-Key: $API_KEY" "${API_BASE}/registries/official/servers?limit=5")
CURL_RC=$?
echo "$RESPONSE" > "$TEST_RESULTS_FILE"
if echo "$RESPONSE" | jq -e '.success == true and .data.servers != null and .data.registry_id == "official"' >/dev/null 2>&1; then
log_pass "GET /api/v1/registries/{id}/servers - Response has servers array and registry_id"
elif registry_is_outage "$CURL_RC" "$RESPONSE"; then
log_skip "GET /api/v1/registries/{id}/servers - external 'official' registry unreachable (curl rc=$CURL_RC); not a proxy regression"
else
log_fail "GET /api/v1/registries/{id}/servers - Expected server search results" \
"jq -e '.success == true and .data.servers != null and .data.registry_id == \"official\"' < '$TEST_RESULTS_FILE' >/dev/null"
fi
# Test 27: Search registry servers with query (Phase 7) — same external dependency.
log_test "GET /api/v1/registries/{id}/servers with query parameter"
RESPONSE=$(curl -s --max-time 10 $CURL_CA_OPTS -H "X-API-Key: $API_KEY" "${API_BASE}/registries/official/servers?q=github&limit=3")
CURL_RC=$?
echo "$RESPONSE" > "$TEST_RESULTS_FILE"
if echo "$RESPONSE" | jq -e '.success == true and .data.servers != null and .data.query == "github"' >/dev/null 2>&1; then
log_pass "GET /api/v1/registries/{id}/servers?q=github - Response has query field"
elif registry_is_outage "$CURL_RC" "$RESPONSE"; then
log_skip "GET /api/v1/registries/{id}/servers?q=github - external 'official' registry unreachable (curl rc=$CURL_RC); not a proxy regression"
else
log_fail "GET /api/v1/registries/{id}/servers?q=github - Expected query parameter in response" \
"jq -e '.success == true and .data.servers != null and .data.query == \"github\"' < '$TEST_RESULTS_FILE' >/dev/null"
fi
# ============================================================================
# CLI E2E Tests: upstream add / upstream remove commands
# ============================================================================
echo ""
echo -e "${YELLOW}Testing CLI upstream add/remove commands...${NC}"
echo ""
# Test 41: Add HTTP server via CLI
log_test "CLI: upstream add HTTP server"
RESPONSE=$($MCPPROXY_BINARY -d "$TEST_DATA_DIR" upstream add cli-http-test "https://example.com/mcp" 2>&1)
if echo "$RESPONSE" | grep -qi "Added server\|success\|cli-http-test\|quarantined"; then
You can’t perform that action at this time.
