unlob Docs
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:

ResponseWhat it is
200 with a resultThe server is fine. The problem is client-side — read on.
401Key missing, wrong, revoked, or too new to have propagated
403Host not allowed — see below
404Wrong URL. It is /mcp, with no trailing slash and no /v1.
429Rate limited. Check for retry-after.
Connection refused / timeoutNetwork, 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