mcp-endpoint-testing
Test MCP server endpoints across all transports — stdio (Python client), HTTP/StreamableHTTP (curl), SSE (curl with event-stream extractor). Transport detection, common failure patterns, and verificat
Files4
View on GitHubSKILL.mdskills/devops/mcp-endpoint-testing/SKILL.md
GitHubMCP Endpoint Testing
Test MCP server endpoints across all transport types. Use when onboarding a new server, diagnosing a connection failure, or verifying health during a fleet audit.
Transport Detection
Before testing, identify which transport the server uses:
| Signal | Transport |
|---|---|
Config has command: + args: |
Stdio — test with Python client |
Config has url: |
HTTP — test with curl |
curl -v returns content-type: text/event-stream |
SSE — use SSE extractor pattern |
curl -v returns content-type: application/json |
StreamableHTTP — plain JSON response |
POST /mcp returns 404 but server process exists |
SSE (SSE does NOT respond to bare POST on /mcp) |
Quick transport probe (single curl command)
curl -v -s \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1.0"}}}' \
http://localhost:PORT/mcp 2>&1 | grep -E "(content-type:|event:|HTTP/)"
HTTP/1.1 200 OK+content-type: application/json→ StreamableHTTPHTTP/1.1 200 OK+content-type: text/event-stream+event: message→ SSEHTTP/1.1 404→ likely SSE on a different path or port- Connection refused / timeout → server process is DOWN
Testing Stdio MCP Servers (Python)
For stdio-based servers (command + args in config.yaml):
import asyncio
from mcp import StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp import ClientSession
async def test_server():
params = StdioServerParameters(
command="python",
args=["-m", "app.main"],
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
# Initialize
await session.initialize()
# List tools
result = await session.list_tools()
print(f"Tools ({len(result.tools)}):")
for t in result.tools:
print(f" {t.name}: {t.description[:80]}")
# Call a tool
health = await session.call_tool("health_check", {})
print("Health:", health.content[:300])
asyncio.run(test_server())
Pre-warming for uvx/npx servers
First-run uvx/npx servers download 70MB+ of dependencies and may exceed connect_timeout. Pre-warm before testing:
uvx server-name --help 2>/dev/null || npx -y server-name --help 2>/dev/null
Testing HTTP/StreamableHTTP MCP Servers (curl)
For servers that return content-type: application/json:
# Initialize
curl -s http://localhost:PORT/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
# List tools
curl -s http://localhost:PORT/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","method":"tools/list","id":2,"params":{}}}'
# Call a tool
curl -s http://localhost:PORT/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"tool_name","arguments":{}}}'
Key signals: initialize returns protocolVersion + serverInfo. tools/list returns tool array without error. tools/call returns content.
Testing SSE MCP Servers (curl with event-stream extractor)
SSE servers return content-type: text/event-stream and wrap JSON-RPC in:
event: message
data: {"jsonrpc":"2.0","id":1,"result":{...}}
Piping raw SSE output to json.loads() fails because the event: / data: lines are not valid JSON. Always extract the JSON payload with grep "^data: " | sed 's/^data: //':
Initialize + detect transport
curl -v -s \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' \
http://localhost:8888/mcp 2>&1 | grep -E "(content-type:|event:)"
Expected SSE output: content-type: text/event-stream + event: message
List tools from an SSE MCP server
response=$(curl -s \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","method":"tools/list","id":2,"params":{}}' \
http://localhost:8888/mcp)
echo "$response" | grep "^data: " | sed 's/^data: //' | python -c "
import json, sys
data = json.load(sys.stdin)
tools = data.get('result', {}).get('tools', [])
print(f'Tools count: {len(tools)}')
for t in tools:
print(f' - {t[\"name\"]}: {t.get(\"description\", \"\")[:80]}')
"
Call a tool on an SSE MCP server
response=$(curl -s \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"tool_name","arguments":{}}}' \
http://localhost:8888/mcp)
echo "$response" | grep "^data: " | sed 's/^data: //' | python -c "
import json, sys
data = json.load(sys.stdin)
content = data.get('result', {}).get('content', [])
for c in content:
print(c.get('text', str(c))[:500])
"
Key signals: grep "^data: " | sed 's/^data: //' extracts valid JSON. tools/list returns expected tools. tools/call returns meaningful content.
SSE session flow (for manual testing)
SSE servers use a two-step connection pattern:
GET /mcp(SSE stream) → receive session_id from initial eventPOST /messages?session_id=<id>for each JSON-RPC request
Some SSE servers (like Open Coscientist) accept POST directly on /mcp and return SSE responses inline — these are hybrid implementations. Detect by testing POST directly first.
Testing WebSocket / BiDi Endpoints (Firefox remote agent, browser bridges)
Some servers expose a raw WebSocket protocol instead of HTTP JSON-RPC — notably Firefox's WebDriver BiDi remote agent (started with --remote-debugging-port) and MCP bridge servers that connect to it. Standard HTTP MCP probes do NOT apply; the liveness probe is a WebSocket upgrade handshake.
Identification signals (Firefox 129+, CDP removed)
| Probe | Response | Meaning |
|---|---|---|
GET / |
200 HTML "httpd.js is up and serving requests!" | BiDi remote agent ALIVE (built on Mozilla's httpd.js — this is NOT a ghost/foreign server) |
GET /json/version |
404 | CDP endpoints removed in Firefox 129 — any CDP-based detection/readiness loop is dead |
GET /session (no WS upgrade) |
400 | endpoint present; needs a real WebSocket handshake |
ws://host:PORT/session upgrade |
101 | definitive BiDi-alive signal |
BiDi liveness probe (Python)
import asyncio, websockets
async def bidi_alive(port=9239):
try:
async with websockets.connect(f'ws://127.0.0.1:{port}/session', open_timeout=8):
return True
except Exception:
return False
⚠️ open_timeout, not timeout — websockets 14.0 renamed the open-handshake kwarg timeout → open_timeout; 15.0 removed the old name. Passing timeout= sweeps it into **kwargs → loop.create_connection(factory, **kwargs) → TypeError: BaseEventLoop.create_connection() got an unexpected keyword argument 'timeout'. When that call sits in try/except Exception: return False, detection fails silently forever ("Cannot detect Firefox on BiDi:PORT or CDP:PORT" while Firefox listens on the exact configured port). Always probe directly before trusting a negative detection result.
Post-connect failure modes
'NoneType' object is not iterablein list/flatten — Firefox'sbrowsingContext.getTreereturns"children": null(key present, null value) for leaf contexts;ctx.get("children", [])returnsNone(default only applies to a MISSING key) → crash. Use(ctx.get("children") or []).session.new→ "Maximum number of active sessions" — one active BiDi session per browser process; hard-killing the client (taskkill /F) leaves the slot held. Reconnecting the client is NOT enough — relaunch a fresh browser (its launcher'skill_orphansclears the port first).- Kill-and-respawn hot-load — Hermes' MCP client auto-reconnects a killed stdio server (exponential backoff): patch the server source on disk,
taskkill /F /PID <server>, and the respawned instance loads the fix — no Hermes restart. Also purges duplicate instances (venv python + hermes-runtime python can both host copies of the same server).
Full case study (ultimate-firefox-mcp bridge, Aug 2026): references/websocket-bidi-endpoint-probing.md
Diagnosing Tool Calls That Hang or Kill the Server
Symptom signature: initialize + list_tools succeed, a specific tool call then hangs forever or the server process dies mid-call (McpError: Connection closed), but the SAME code completes in seconds when run standalone. This is the classic FastMCP worker-thread context failure: mcp.server.fastmcp runs sync tool functions via anyio.to_thread, and heavy lazy-import libraries (OpenBB, pandas, numba) are not thread-safe on first use (extension auto-build / type generation deadlocks or hard-crashes the process).
Isolation ladder (cheap → expensive)
- In-process direct call — import the server module and call the tool function in the MAIN thread, no MCP round-trip:
import importlib.util spec = importlib.util.spec_from_file_location("srv", r"E:/path/to/server.py") m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m) print(m.market_summary()[:400]) # works fast → bug is MCP/thread context, not the data layer- Returns JSON quickly → the data layer is fine; the hang lives in the FastMCP worker thread.
- Hangs/errors → the tool body itself is at fault; debug the data call chain directly.
- Distinguish tool-vs-tool: if a DIFFERENT tool using the same stack also hangs, it's the shared context/library, not that tool's logic.
- Clean-env Windows note: when testing with
env -i, Windows Python needsUSERPROFILE(plusHOMEDRIVE/HOMEPATH/APPDATA/LOCALAPPDATA/SYSTEMROOT) —HOMEalone is not enough. Omitting them yields a misleading"Could not determine home directory."error or a hang in libraries that resolve config dirs. - Workarounds, in order: pre-warm the lazy library at module import (main thread) before
mcp.run(); wrap the tool body in a dedicatedthreading.Threadwith a join timeout; pinfastmcp/mcpto a known-good combo; escalate a confirmed library thread-safety defect with a repro.
Full worked example (OpenBB trading-signals: provider default-routing hang + column-oriented to_dict() extraction bug + worker-thread hang): see references/fastmcp-thread-context-hang.md.
Verification Checklist
- Transport correctly identified (stdio vs StreamableHTTP vs SSE)
- initialize returns
protocolVersion+serverInfowithout error - tools/list returns expected tools (count and names match docs)
- At least one tool call returns meaningful content (not an error)
- Server process survives multiple sequential tool calls
- Server process exits cleanly on disconnect
- For SSE: JSON payload successfully extracted with grep+sed extractor
- For stdio: Python test script runs without import errors
Pitfalls
- SSE raw pipe to json.loads() fails — always use
grep "^data: " | sed 's/^data: //'to extract JSON from SSE framing before piping to Python. - uvx/npx first-run timeout — pre-warm with
--helpbefore testing or increaseconnect_timeoutto 120s. - Same
Acceptheader for both transports — both StreamableHTTP and SSE requireAccept: application/json, text/event-stream. The response content-type tells them apart. - POST on /mcp returns 404 for SSE servers — some SSE servers require POST on a separate
/messages?session_id=<id>endpoint. Check raw response headers first before assuming the server is dead. - Connection refused vs 404 — connection refused means no process is listening on the port. A 404 means the process IS running but the path is wrong. Very different fixes.
- Stdio servers inherit filtered env — only PATH, HOME, USER, LANG, LC_ALL, TERM, SHELL, TMPDIR + XDG_* are passed. If your test works in terminal but Hermes fails, missing env vars are the most likely cause. Pass them explicitly under
env:. - Windows
whichlies about.exefiles — Usecommand -v <binary>(bash built-in) notwhich <binary>. env -iclean tests on Windows missUSERPROFILE— Windows Python resolves the home dir fromUSERPROFILE, notHOME. A clean-env test (env -i PATH=... HOME=...) fails with"Could not determine home directory."or hangs in libs that resolve config dirs. PassUSERPROFILE/HOMEDRIVE/HOMEPATH/APPDATA/LOCALAPPDATA/SYSTEMROOTexplicitly.
Related Skills
native-mcp— MCP client config reference, transport types, Hermes integrationmcp-fleet-audit— Fleet-wide health checks, dormant server discoverymcp-server-onboarding— Full workflow for finding, fixing, and wiring serversbuilding-mcp-servers— Building MCP servers from scratchmcp-server-wiring— Wiring existing Python modules as MCP tools
References
- Open Coscientist SSE Discovery — Real-world case study: identifying SSE transport on a running MCP server, troubleshooting the raw pipe to json.loads() failure, and the grep+sed extractor pattern.
- FastMCP Thread-Context Hang — Real-world case study: OpenBB trading-signals server whose tools hang/die over MCP stdio but work in-process; isolation technique, two OpenBB SDK quirks (provider default routing, column-oriented
to_dict()), and the Windows clean-env pitfall. - WebSocket/BiDi Endpoint Probing — Real-world case study: ultimate-firefox-mcp bridge's week-long "Cannot detect Firefox" — websockets 15
timeout→open_timeoutAPI break swallowed byexcept Exception,children: nullflatten crash, BiDi session-slot exhaustion, and the kill-and-respawn hot-load pattern.