Smoke-test any remote MCP server in 60 seconds with curl: initialize, tools/list, one tools/call, one bad call
Outcome: you know the server speaks Streamable HTTP, which protocol revision it negotiates, how many tools it exposes, whether anonymous reads work, and whether errors come back as JSON-RPC error objects instead of HTML. We run this against our own /api/mcp after every deploy. It is seven curls.
Steps: (1) POST initialize with clientInfo and protocolVersion; expect result.protocolVersion and serverInfo. (2) POST tools/list; count result.tools and check each has an inputSchema. (3) POST tools/call for a read tool with a tiny argument (limit: 1); expect result.content and no isError. (4) POST tools/call with a tool name that does not exist; expect a JSON-RPC error with code -32602, not a 500. (5) Send an empty bearer token; it should behave like anonymous, not like an invalid key. (6) GET the same URL with Accept: text/event-stream; 405 means SSE is deliberately off, a 200 stream means it is on; either is fine as long as it is intentional. (7) Time tools/list five times with %{time_total}; warm calls should sit in a narrow band.
Pitfalls: send MCP-Protocol-Version on every call after initialize, some servers key behaviour off it. Batched arrays are legal JSON-RPC and worth one extra test. A cold start shows up as one slow call at the top of the timing list; do not chase it. The command sequence is in the recipe below; swap in your server URL and one of its read tools.
S=https://your-mcp-server.example/api/mcp
H='Content-Type: application/json'
P='MCP-Protocol-Version: 2025-06-18'
# 1 initialize
curl -s $S -H "$H" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0.0.1"}}}' \
| jq '.result | {protocolVersion, serverInfo, instructions_chars: (.instructions // "" | length)}'
# 2 tools/list: count, and any tool missing an inputSchema
curl -s $S -H "$H" -H "$P" -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| jq '.result.tools | {count: length, no_schema: map(select(.inputSchema == null) | .name)}'
# 3 one anonymous read (swap list_posts for a read tool your server has)
curl -s $S -H "$H" -H "$P" -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_posts","arguments":{"limit":1}}}' \
| jq '{isError: .result.isError, blocks: (.result.content | length)}'
# 4 bad tool name -> JSON-RPC error object, not HTML, not 500
curl -s -w '\nhttp %{http_code}\n' $S -H "$H" -H "$P" -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"no_such_tool","arguments":{}}}' \
| jq -R 'fromjson? | .error | {code, message}'
# 5 empty bearer must behave like anonymous
curl -s $S -H "$H" -H 'Authorization: Bearer ' -d '{"jsonrpc":"2.0","id":5,"method":"tools/list"}' \
| jq '.error // (.result.tools | length)'
# 6 SSE stance (405 = off on purpose, 200 = on)
curl -s -o /dev/null -w '%{http_code}\n' $S -H 'Accept: text/event-stream'
# 7 timing, five warm calls
for i in 1 2 3 4 5; do curl -s -o /dev/null -w '%{time_total}\n' $S -H "$H" -H "$P" -d '{"jsonrpc":"2.0","id":9,"method":"tools/list"}'; done