Skip to main content
A tool call fails in one of two shapes, and an AI agent has to tell them apart before it parses anything. A call the Vexlo server answered with an error returns a JSON object: a kind to branch on, plus the server’s own code and message where there is one. A call whose arguments the MCP server’s own schema rejected returns one line of plain text instead. Both arrive as a tool result with isError set, and only the first is JSON, so branch on whether the text starts with {: anything else is the MCP layer reporting the call, not the Vexlo server reporting an error. This page covers the JSON shape first and the text shape under rejected arguments.

Shape decides error from data, not the status code

Whether a response is an error is decided by whether its body parses as the API’s error envelope, {"error":{"code","message"}}, never by the HTTP status alone. 400, 401, 403, 404, 422, 429, and 500 all use the envelope. It matters for one route. A server under disk pressure answers GET /v1/health with {"status":"degraded","mode":"shape_only",...} and HTTP 503. That body is not the error envelope, so vexlo_check_health returns it as its answer: a degraded server is a fact about the server, not a failed call. A body that is not valid JSON, or that matches neither the envelope nor the shape the tool expects, is malformed_response, with the HTTP status. A request that throws before any response exists, a timeout at the 30-second tool deadline included, is network_error.

The nine kinds

A code the MCP server does not recognise still reaches the agent: code and message are preserved and kind falls back on the status, so a newer server is less specific here rather than unusable. Every message above is the Vexlo server’s own, with one exception. On malformed_response the MCP server writes the sentence itself, one of response body is not valid JSON (status N), response body matched neither the error envelope nor the expected shape (status N), or could not read the response body (status N): <reason>. A message describing the response body is the client speaking, so do not report it as something the server said. A 404 says nothing about the token’s team-set. A trace that does not exist and a trace on a team this token cannot see produce the same answer, by design, so “not found” is never evidence about the team-set. vexlo_check_coverage and the scope_note on find’s tools are what answer a question about it. Several API codes cannot be reached from these eleven tools. malformed_batch, service_scope_mismatch, environment_scope_mismatch, invalid_policy, and body_too_large belong to the ingest routes, which no tool calls. service_required is unreachable because both function tools require service. conflicting_filters is unreachable because naming an environment and unset at once is not representable in the argument schema. too_many_paths and forbidden_role are the two the table marks unreachable.

Rejected arguments

An argument the schema rejects never reaches the handler, and the answer is not the JSON object above. It is an ordinary tool result whose one text block is a sentence:
There is no JSON-RPC error member and no kind. Every such text opens MCP error -32602: Input validation error: Invalid arguments for tool <name>: and ends with the argument at fault, so it is readable without parsing. Each bound the three tool pages state rejects this way, and these are the endings: The last row is what an environment that is neither {"named": [...]} nor {"unset": true} returns, both keys at once included.

Two argument errors, captured

A trace id the server cannot parse is invalid_request, and the message says what the format is:
A path that is not a valid step path is the same kind:
A path that decodes but matches no step is not an error at all: it comes back in unknown_paths, which values covers.

A token revoked mid-session

Startup reads the token once. Revoking it while the process runs does not stop the process; the next call that uses it returns:
Restarting with a fresh token is the fix, and startup will refuse at once if the new one is wrong.

Rate limits and the one retry

The server’s read limit is per token, and conventions has the numbers. This process does one thing on its own about them: when a 429 carries Retry-After of 2 seconds or less, it waits that long and retries the call once, silently. A Retry-After that small means a burst was grazed, and the extra round trip costs less than an error the agent has to reason about. Above 2 seconds, or when the retry comes back 429 as well, the error surfaces immediately. There is no second retry and no growing backoff:
That one came from 120 vexlo_list_tags calls sent back to back in one session: 60 returned their result and 60 returned this, each of the 60 having already been retried once. A 429 with no Retry-After header reports retryAfterSeconds: 60, the cap the server applies to it. X-RateLimit-* headers are copied onto an error result when the response carried them and ignored on success: the process keeps no pacing state between calls. Under no-auth mode the server skips read rate limiting entirely, since there is no token to key a bucket on, so none of this fires on a local development server.