fix(http): surface the server's error body on a failed tool call

Closed
ED
1mo ago
+164-4

Problem

When an HTTP call fails with a non-2xx status, the http protocols discard the server's response body — where servers put the actual reason ({ "error": "..." }) — and surface only the status code.

  • HttpCommunicationProtocol.callTool (axios) re-threw the raw AxiosError, whose .message is the generic "Request failed with status code 403". The body sits on error.response.data and never reaches .message. It's also dropped entirely when the error crosses a serialization boundary (e.g. JSON.stringify inside an isolated-vm tool runner like @utcp/code-mode), because Error.message is non-enumerable and AxiosError doesn't serialize its response. In a code-mode agent a server's 403/400 reaches the model as {}.
  • StreamableHttpCommunicationProtocol / SseCommunicationProtocol (fetch) threw HTTP ${status}: ${statusText} during manual discovery (registerManual) without ever reading the body, so a refused discovery surfaced as "HTTP 403: Forbidden" in errors[].

Fix

  • callTool normalizes a failed HTTP call into an Error that folds the status + server body into .message AND attaches enumerable status / data fields, so the reason survives both .message readers and structured serialization. Non-HTTP errors (network, timeout) pass through unchanged.
  • streamable_http + sse read the response body before throwing during discovery and fold it into the message (falling back to statusText when empty). Their callTool paths are stubs (no HTTP call yet), so discovery is the only real failure surface today.

No public signature changes.

Tests

  • A /forbidden route (403 + JSON body) + a callTool test asserting the thrown error carries the body in .message, exposes status/data, and round-trips through JSON.stringify (the sandbox case).
  • A GET /forbidden-discovery route (403 + text body) + tests asserting both streamable and sse registerManual surface the body in errors[].

bun test packages/http/tests/ → 85 pass, 0 fail. build:http clean (DTS included) once core is built first.