Browse documentation
MCP troubleshooting
The failure modes that produce no error message at all, and how to tell them apart.
MCP failures tend to be quiet. A client that cannot complete the handshake usually shows an empty tool list rather than an error, so the first job is always to find out which step failed. Drive the lifecycle by hand and the answer is immediate.
The one command that tells you everything
curl -sD- https://api.unlob.com/mcp \
-H "x-api-key: $UNLOB_API_KEY" \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"curl","version":"0"}}}'
Read the status line first:
| Response | What it is |
|---|---|
200 with a result | The server is fine. The problem is client-side — read on. |
401 | Key missing, wrong, revoked, or too new to have propagated |
403 | Host not allowed — see below |
404 | Wrong URL. It is /mcp, with no trailing slash and no /v1. |
429 | Rate limited. Check for retry-after. |
| Connection refused / timeout | Network, proxy or DNS — not MCP |
The client shows zero tools and no error
The classic. Almost always the handshake, not the tools.
Check the accept header. Streamable HTTP requires a client to accept both
application/json and text/event-stream. A client naming only one is out of spec and can
fail on a response it did not expect. Most real clients get this right; hand-rolled ones
often do not.
Check the notification step. After initialize, the client sends
notifications/initialized. It is a notification, so the correct answer is 202 with
an empty body:
curl -sD- https://api.unlob.com/mcp \
-H "x-api-key: $UNLOB_API_KEY" -H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# → HTTP/1.1 202 Accepted, and NO body
If a server answers 200 {} here, a conforming client parse-fails — {} is not a JSON-RPC
message — retries, gives up, and registers zero tools, with nothing logged anywhere.
Meanwhile initialize and tools/list both look perfect when you try them by hand, which
is why this failure survives casual testing. This API is tested against exactly that
scenario, in all three legal shapes of the notification frame; if you are pointing a client
at your own MCP server and seeing an empty tool list, check this first.
Check the key reached the server. Some clients drop custom headers on the
initialize request specifically. A 401 on the curl above with the same key proves it is
the key; a 200 proves it is the client.
403 on every request from a real hostname
The transport carries a DNS-rebinding guard that admits localhost only by default. Behind
an ingress, every request arrives with the public Host header and is refused — while
/search on the same host keeps working perfectly, so it looks like MCP specifically is
broken.
This is only your problem if you are self-hosting. On api.unlob.com the public
authority is configured.
If you run your own: name the public hostname in UNLOB_PUBLIC_BASE_URL, or list several
in UNLOB_MCP_ALLOWED_HOSTS. Verify against the real hostname, not localhost — localhost
is exactly the case that works either way.
405 on GET or DELETE
Not a fault. This deployment runs the transport stateless, so there is no server-initiated
event stream to open and no session to delete. The 405 carries Allow: POST, which is
the server telling you its shape.
A client that requires the GET stream will not work here. Every tool is request/response, so in practice none do.
initialize succeeds, tools/call fails
Look at where the failure is in the frame:
{ "error": { "code": -32601 } } // unknown tool name
{ "error": { "code": -32602 } } // bad arguments — check the tool's inputSchema
{ "result": { "isError": true, … } } // the tool ran and failed, or you are refused
An error object is a protocol error: the request could not be routed. A result with
isError: true is a tool error: the request was fine, the work was not. A suspended
account and an exhausted quota both arrive this second way — deliberately, because a
transport status on an established session reads to a client as the server having gone
away.
Read the text in the content block; it says which.
Arguments are rejected that the schema says are fine
IN-list filters (langs, tld, topic, content_type, exclude_site, community,
authority, fields) accept either a JSON array or a comma-separated string over MCP:
{ "langs": ["en", "de"] }
{ "langs": "en,de" }
Both work. Over REST, only the comma form — a URL query string cannot carry an array.
Tool descriptions look out of date
They are generated from the running server. Fetch what it actually reports:
curl -s https://api.unlob.com/mcp/tools.json | jq '.tools[].name'
That needs no key, and it is the same data tools/list returns. If it disagrees with
something on this site, the site is wrong — tell us.
Everything works over curl, not in the client
Narrow it to the client:
npx @modelcontextprotocol/inspector
Point the Inspector at https://api.unlob.com/mcp with your key as a header. If the
Inspector lists eleven tools, the server and your key are both fine, and the problem is in
the other client’s configuration — see its page.
Next
- MCP overview — the lifecycle in full
- Connect a client
- Errors