| name | field-test |
| description | Exercise tools, resources, and prompts against a live HTTP server via MCP JSON-RPC over curl. Starts the server, surfaces the catalog, runs real and adversarial inputs, measures every call (bytes, token estimate, wall-clock) and weighs the catalog, and produces a tight report with concrete findings and numbered follow-up options. Use after adding or modifying definitions, or when the user asks to test, try out, or verify their MCP surface.
|
| metadata | {"author":"cyanheads","version":"2.12","audience":"external","type":"debug"} |
Context
Unit tests (add-test skill) verify handler logic with mocked context. Field testing exercises the real HTTP transport with real JSON-RPC: starts the server, calls initialize, surfaces the catalog, runs inputs, and checks what a client actually sees. It catches what unit tests miss — awkward input shapes, unhelpful errors, missing format output, drift between structuredContent and content[], edge-case surprises.
Actively call the tools. Don't read code and guess.
Transport coverage
This skill drives an HTTP server because curl + JSON-RPC is the most reliable harness for shell-based agents. The same handlers run on both transports — only the framing differs — so HTTP exercises the full functional surface. Both HTTP session modes are covered: a durable Mcp-Session-Id session, and the sessionless initialization a MCP_SESSION_MODE=stateless server performs.
Stdio coverage is a boot check only — run this before Step 1. Run bun run rebuild && bun run start:stdio, confirm the startup logs look clean (banner, expected tool/resource counts, no errors/warnings, no missing-config gripes), then kill it. Pino logs go to stderr in stdio mode (stdout is reserved for JSON-RPC), so they print straight to the terminal when you run interactively. No need to call tools over stdio — the HTTP pass already covered handler behavior.
Steps
1. Start the server
Generate a 10-character alphanumeric ID (e.g. 9DJ73-K103L) and write the helper to /tmp/<project-name>-field-test-<ID>.sh. Use that exact path in every subsequent Bash call. Two agents in the same project tree must pick different IDs — that's what keeps their helper files, server logs, and call scratch from colliding.
The helper itself is stateless — every function takes the IDs it needs (server pid, url, port, MCP sid, server log path) as positional args. mcp_start prints them; the agent threads them through every later call. No env vars, no shared state files.
cat > /tmp/<project-name>-field-test-9DJ73-K103L.sh <<'HELPER_EOF'
mcp_start() {
local dir="${1:-$PWD}"
local timeout="${2:-30}"
local build_log; build_log=$(mktemp /tmp/mcp-field-test-build.XXXXXX)
echo "building $dir ..." >&2
if ! (cd "$dir" && bun run rebuild) >"$build_log" 2>&1; then
echo "BUILD FAILED — last 30 lines of $build_log:" >&2
tail -30 "$build_log" >&2
return 1
fi
rm -f "$build_log"
local server_log; server_log=$(mktemp /tmp/mcp-field-test-server.XXXXXX)
echo "starting server ..." >&2
(cd "$dir" && bun run start:http) >"$server_log" 2>&1 &
local pid=$!
local line=""
local waited=0
while [ "$waited" -lt "$((timeout * 4))" ]; do
line=$(grep -Eo 'listening at http://[^" ]+/mcp' "$server_log" | head -1)
[ -n "$line" ] && break
if ! kill -0 "$pid" 2>/dev/null; then
echo "server exited during startup — last 30 lines of $server_log:" >&2
tail -30 "$server_log" >&2
rm -f "$server_log"
return 1
fi
sleep 0.25
waited=$((waited + 1))
done
if [ -z "$line" ]; then
echo "server failed to start within ${timeout}s — last 30 lines of $server_log:" >&2
tail -30 "$server_log" >&2
kill "$pid" 2>/dev/null
rm -f "$server_log"
return 1
fi
local url="${line#listening at }"
local port; port=$(echo "$url" | sed -E 's|.*:([0-9]+)/.*|\1|')
echo "ready pid=$pid url=$url port=$port log=$server_log"
}
_mcp_init_fail() {
local msg="$1"; local body_file="$2"; local hdr="$3"
echo "init failed — $msg" >&2
echo "--- response body ---" >&2
if [ -s "$body_file" ]; then cat "$body_file" >&2; else echo "(empty)" >&2; fi
echo "--- response headers ---" >&2
if [ -s "$hdr" ]; then cat "$hdr" >&2; else echo "(none)" >&2; fi
rm -f "$hdr" "$body_file"
return 1
}
mcp_init() {
local url="$1"
[ -z "$url" ] && { echo "usage: mcp_init <url>" >&2; return 1; }
local want="${MCP_FIELD_TEST_PROTOCOL:-2025-11-25}"
local hdr; hdr=$(mktemp)
local body_file; body_file=$(mktemp)
local code curl_rc
code=$(curl -sS -D "$hdr" -o "$body_file" -w '%{http_code}' -X POST "$url" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"$want\",\"capabilities\":{},\"clientInfo\":{\"name\":\"field-test\",\"version\":\"1.0.0\"}}}")
curl_rc=$?
if [ "$curl_rc" -ne 0 ] || [ -z "$code" ] || [ "$code" = "000" ]; then
_mcp_init_fail "transport failure — curl exit $curl_rc, http_code '${code:-none}'; nothing listening at $url" "$body_file" "$hdr"
return 1
fi
[ "$code" -ge 400 ] && { _mcp_init_fail "HTTP $code" "$body_file" "$hdr"; return 1; }
local payload; payload=$(sed -n 's/^data: //p' "$body_file")
[ -z "$payload" ] && payload=$(cat "$body_file")
local reply; reply=$(printf '%s\n' "$payload" | grep -E '"(result|error)"' | head -1)
[ -z "$reply" ] && reply="$payload"
if printf '%s' "$reply" | grep -q '"error"'; then
_mcp_init_fail "server returned a JSON-RPC error" "$body_file" "$hdr"
return 1
fi
if ! printf '%s' "$reply" | grep -q '"result"'; then
_mcp_init_fail "HTTP $code but no JSON-RPC result in the body" "$body_file" "$hdr"
return 1
fi
local got; got=$(printf '%s' "$reply" | grep -o '"protocolVersion":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -z "$got" ]; then
_mcp_init_fail "initialize result declares no protocolVersion" "$body_file" "$hdr"
return 1
fi
local instr; instr=$(printf '%s' "$reply" | jq -r '.result.instructions // "" | utf8bytelength' 2>/dev/null || echo 0)
local sid; sid=$(grep -i '^mcp-session-id:' "$hdr" | awk '{print $2}' | tr -d '\r\n')
local init_headers=(-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "MCP-Protocol-Version: $got")
[ -n "$sid" ] && init_headers+=(-H "Mcp-Session-Id: $sid")
curl -sS -X POST "$url" "${init_headers[@]}" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null
rm -f "$hdr" "$body_file"
echo "ready sid=$sid protocol=$got requested=$want instructions=${instr}B (HTTP $code)"
}
_mcp_measure() {
local method="$1"; local params="$2"; local reply="$3"; local code="$4"; local secs="$5"
local total; total=$(printf '%s' "$reply" | wc -c | tr -d ' ')
local ms; ms=$(awk -v s="$secs" 'BEGIN { printf "%d", s * 1000 }')
local label="$method"
local split=""
case "$method" in
tools/call)
local name; name=$(printf '%s' "$params" | jq -r '.name // empty' 2>/dev/null)
[ -n "$name" ] && label="$method $name"
split=$(printf '%s' "$reply" | jq -r '
(.result // {}) as $r
| ([$r.content[]? | select(.type == "text") | .text] | join("") | utf8bytelength) as $c
| (if $r.structuredContent == null then "none" else ($r.structuredContent | tojson | utf8bytelength | tostring) end) as $s
| "content \($c) · structured \($s)"' 2>/dev/null)
;;
resources/read)
split=$(printf '%s' "$reply" | jq -r '
"text \([.result.contents[]? | .text // ""] | join("") | utf8bytelength)"' 2>/dev/null)
;;
esac
local tok; tok=$(awk -v b="$total" 'BEGIN { if (b >= 1000) printf "~%.1fk", b / 4000; else printf "~%d", b / 4 }')
echo "⏱ $label · HTTP $code · ${total} B${split:+ ($split)} · $tok tok · ${ms} ms" >&2
}
mcp_call() {
local url="$1"; local sid="$2"; local method="$3"; local params="${4:-}"; local protocol="${5:-}"
[ -z "$url" ] || [ -z "$method" ] && { echo "usage: mcp_call <url> <sid> <method> [params] [protocol]" >&2; return 1; }
local body