Skip to content

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

Open
edujuan wants to merge 3 commits into
universal-tool-calling-protocol:mainfrom
edujuan:fix/http-surface-error-body
Open

fix(http): surface the server's error body on a failed tool call#26
edujuan wants to merge 3 commits into
universal-tool-calling-protocol:mainfrom
edujuan:fix/http-surface-error-body

Conversation

@edujuan

@edujuan edujuan commented Jun 24, 2026

Copy link
Copy Markdown
Member

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.

edujuan added 2 commits June 24, 2026 15:32
On a non-2xx response, axios throws an AxiosError whose `.message` is the
generic "Request failed with status code 403" — the response body, where
servers put the actual reason (e.g. { "error": "..." }), lives on
`error.response.data` and is lost to every caller that only reads `.message`.
It is 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`. The caller is left with just a status code and no explanation.

callTool now normalizes a failed HTTP call into an Error that folds the status
and server body into the 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.

Adds a /forbidden test route (403 + JSON body) and a callTool test asserting
the thrown error carries the body in its message and round-trips through
JSON.stringify with status + data intact.
…iscovery

The same body-swallowing pattern existed in the fetch-based streamable_http and
sse protocols: on a non-2xx during manual discovery (registerManual) they threw
`HTTP ${status}: ${statusText}` without ever reading the response body, so a
server that refuses discovery with a descriptive 403/400 surfaced only as
"HTTP 403: Forbidden" in the returned errors[].

Both now read the body before throwing and fold it into the message (falling
back to statusText when the body is empty). Their callTool paths are stubs
(no HTTP call yet), so discovery is the only real failure surface today.

Adds a GET /forbidden-discovery route (403 + text body) and tests asserting
both protocols' registerManual surfaces the body, not just the status code.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found and verified against the latest diff

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/http/src/http_communication_protocol.ts Outdated
`_normalizeToolError` preferred `data.error` / `data.message` / `data.detail`
unconditionally, but some APIs nest an OBJECT there (e.g.
{ error: { code, reason } }). Using it directly interpolated as
"[object Object]", hiding the real detail. Now only a STRING candidate is
used; a non-string falls through to JSON.stringify(data) so the structure
shows. The raw object is still preserved on the error's `data` field.

Adds a /forbidden-object route (422 + nested object body) and a callTool
test asserting the message contains the structured detail (not
"[object Object]") and `data` holds the original object.

Issue identified by cubic.
@edujuan

edujuan commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

@cubic-dev-ai the P1 (object-valued error field → [object Object]) is fixed in e360c81 — string-only candidate selection with a JSON.stringify fallback, plus a regression test. Please re-review.

@cubic-dev-ai

cubic-dev-ai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai the P1 (object-valued error field → [object Object]) is fixed in e360c81 — string-only candidate selection with a JSON.stringify fallback, plus a regression test. Please re-review.

@edujuan I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 4 files

Re-trigger cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant