Skip to content
29 changes: 29 additions & 0 deletions packages/http/src/_text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// packages/http/src/_text.ts

/**
* Truncate `text` to at most `maxCodePoints` Unicode code points, appending
* an ellipsis when anything was cut.
*
* Works by code point rather than UTF-16 unit so a surrogate pair on the
* boundary is dropped whole instead of leaving a lone surrogate. Walks the
* string with an iterator and stops at the cap, so a multi-megabyte error
* body costs O(cap) work and allocation rather than an array of every
* character in the body.
*/
export function truncateByCodePoint(text: string, maxCodePoints: number): string {
// A string of N UTF-16 units has at most N code points, so short strings
// need no walk at all.
if (text.length <= maxCodePoints) {
return text;
}
let kept = '';
let count = 0;
for (const codePoint of text) {
if (count === maxCodePoints) {
return `${kept}…`;
}
kept += codePoint;
count += 1;
}
return kept;
}
66 changes: 65 additions & 1 deletion packages/http/src/http_communication_protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { IUtcpClient } from '@utcp/sdk';
import { HttpCallTemplateSchema, HttpCallTemplate } from './http_call_template';
import { OpenApiConverter } from './openapi_converter';
import { ensureSecureUrl, safeRequestWithRedirects, assertNoCrlf } from './_security';
import { truncateByCodePoint } from './_text';

/**
* HTTP communication protocol implementation for UTCP client.
Expand Down Expand Up @@ -222,10 +223,73 @@ export class HttpCommunicationProtocol implements CommunicationProtocol {
return response.data;
} catch (error: any) {
this._logError(`Error calling HTTP tool '${toolName}':`, error);
throw error;
throw this._normalizeToolError(toolName, error);
}
}

/**
* Turn a raw axios error into one that actually carries the server's response.
*
* On a non-2xx, axios throws an `AxiosError` whose `.message` is the generic
* `"Request failed with status code 403"` — the response BODY (where servers
* put the real reason, e.g. `{ "error": "..." }`) sits on `error.response.data`
* and is otherwise lost to every caller that only reads `.message`. That body
* is also dropped entirely when the error is serialized across a boundary
* (e.g. JSON.stringify in an isolated-vm tool runner), because `Error.message`
* is non-enumerable and `AxiosError` doesn't serialize its `response`.
*
* This folds the status + 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.
*/
private _normalizeToolError(toolName: string, error: any): Error {
if (!axios.isAxiosError(error) || !error.response) {
return error instanceof Error ? error : new Error(String(error));
}
const status = error.response.status;
const data = error.response.data;
// Prefer a string `error` / `message` / `detail` field from the body; some
// APIs nest an OBJECT there (e.g. { error: { code, reason } }), so only use
// the candidate when it's actually a string — otherwise fall through to the
// full JSON so the real structure shows instead of "[object Object]".
// The first of `error` / `message` / `detail` that is present decides: a
// non-blank string is the reason; anything else (an object, say) is a
// structured error, so return undefined to fall through to the full JSON
// rather than skipping ahead to a lower-priority generic string.
const stringField = (...candidates: unknown[]): string | undefined => {
for (const c of candidates) {
if (c === undefined || c === null) continue;
if (typeof c === 'string') {
if (c.trim()) return c;
continue;
}
return undefined;
}
return undefined;
};
// A blank body carries no reason: fall back to the status text (then
// axios's own message) rather than emitting a message that ends in a
// bare colon. Bound the detail so a multi-megabyte error page (an HTML
// stack trace, say) does not end up in messages and logs in full.
const MAX_DETAIL_CHARS = 2000;
const rawDetail =
typeof data === 'string'
? data.trim() || error.response.statusText || error.message
: data == null
? error.message
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
: (stringField(data.error, data.message, data.detail) ?? JSON.stringify(data));
// Truncate by code point, not UTF-16 unit, so a multi-byte character on
// the boundary is dropped whole rather than leaving a lone surrogate.
const detail = truncateByCodePoint(rawDetail, MAX_DETAIL_CHARS);
const normalized = new Error(
`HTTP ${status} calling tool '${toolName}': ${detail}`,
) as Error & { status: number; data: unknown };
// Enumerable so they survive JSON.stringify out of an isolated-vm runner.
normalized.status = status;
normalized.data = data;
return normalized;
}

/**
* Executes a tool call through this transport streamingly.
* For standard HTTP, this typically means fetching the full response and yielding it as a single chunk.
Expand Down
11 changes: 10 additions & 1 deletion packages/http/src/sse_communication_protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { OAuth2UserAuth } from '@utcp/sdk';
import { IUtcpClient } from '@utcp/sdk';
import { SseCallTemplate, SseCallTemplateSchema } from './sse_call_template';
import { ensureSecureUrl, assertNoCrlf } from './_security';
import { truncateByCodePoint } from './_text';

/**
* A single parsed Server-Sent Event.
Expand Down Expand Up @@ -215,7 +216,15 @@ export class SseCommunicationProtocol implements CommunicationProtocol {
});

if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
// Read the body before throwing — servers put the real reason there
// (e.g. { "error": "..." }); discarding it leaves callers with only a
// status code. Fall back to statusText when the body is empty.
const body = await response.text().catch(() => '');
// Truncate like the streaming path does, so a huge error page does
// not land in errors[] and logs in full. By code point, so a
// multi-byte character on the boundary cannot leave a lone surrogate.
const detail = truncateByCodePoint(body.trim(), 200) || response.statusText;
throw new Error(`HTTP ${response.status}: ${detail}`);
}

// Discovery returns the manual as a plain JSON document.
Expand Down
11 changes: 10 additions & 1 deletion packages/http/src/streamable_http_communication_protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { OAuth2UserAuth } from '@utcp/sdk';
import { IUtcpClient } from '@utcp/sdk';
import { StreamableHttpCallTemplate, StreamableHttpCallTemplateSchema } from './streamable_http_call_template';
import { ensureSecureUrl, assertNoCrlf } from './_security';
import { truncateByCodePoint } from './_text';

/**
* REQUIRED
Expand Down Expand Up @@ -168,7 +169,15 @@ export class StreamableHttpCommunicationProtocol implements CommunicationProtoco
});

if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
// Read the body before throwing: servers put the real reason there
// (e.g. { "error": "..." }); discarding it leaves callers with only a
// status code. Fall back to statusText when the body is empty.
const body = await response.text().catch(() => '');
// Truncate like the streaming path does, so a huge error page does
// not land in errors[] and logs in full. By code point, so a
// multi-byte character on the boundary cannot leave a lone surrogate.
const detail = truncateByCodePoint(body.trim(), 200) || response.statusText;
throw new Error(`HTTP ${response.status}: ${detail}`);
}

const responseText = await response.text();
Expand Down
196 changes: 195 additions & 1 deletion packages/http/tests/http_communication_protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
import express, { Express } from 'express';
import { Server } from 'http';
// Import from package index to trigger auto-registration
import { HttpCommunicationProtocol, HttpCallTemplate } from "@utcp/http";
import {
HttpCommunicationProtocol,
HttpCallTemplate,
StreamableHttpCommunicationProtocol,
SseCommunicationProtocol,
} from "@utcp/http";
import { ApiKeyAuth, BasicAuth, OAuth2Auth } from "@utcp/sdk";
import { IUtcpClient } from "@utcp/sdk";

Expand Down Expand Up @@ -62,6 +67,46 @@ beforeAll(async () => {
res.status(500).json({ error: "Internal Server Error" });
});

// Returns a non-2xx with a descriptive JSON body, like a real API does when it
// refuses a call. Used to prove callTool surfaces that body, not just the status.
app.post("/forbidden", (req, res) => {
res.status(403).json({ error: "You are not allowed to do that, and here is exactly why." });
});

// GET variant for the streamable/sse discovery (registerManual) error-body test.
app.get("/forbidden-discovery", (req, res) => {
res.status(403).send("discovery refused: tenant is not provisioned for streaming");
});

// Some APIs nest an OBJECT under `error` — the message must show its JSON,
// not "[object Object]".
app.post("/forbidden-object", (req, res) => {
res.status(422).json({ error: { code: "INVALID_FIELD", reason: "value out of range" } });
});

// A refusal with no body at all: the message must still carry a reason.
app.post("/forbidden-empty", (req, res) => {
res.status(403).end();
});

// A refusal with a huge body (think an HTML stack trace): messages stay bounded.
app.post("/forbidden-huge", (req, res) => {
res.status(403).type("text/plain").send("x".repeat(5000));
});
app.get("/forbidden-huge", (req, res) => {
res.status(403).type("text/plain").send("x".repeat(5000));
});

// An emoji sitting exactly on the truncation boundary of the message.
app.post("/forbidden-emoji-boundary", (req, res) => {
res.status(403).type("text/plain").send("x".repeat(1999) + "😀" + "y".repeat(50));
});

// A structured `error` next to a generic `message`: the structure must win.
app.post("/forbidden-object-then-message", (req, res) => {
res.status(422).json({ error: { code: "INVALID_FIELD", reason: "value out of range" }, message: "Request failed" });
});

await new Promise<void>((resolve) => {
server = app.listen(0, () => {
serverPort = (server.address() as any).port;
Expand Down Expand Up @@ -188,5 +233,154 @@ describe("HttpCommunicationProtocol", () => {
const result = await protocol.callTool(mockClient, "test.tool", {}, callTemplate);
expect(result.result).toBe("success");
});

test("should surface the server's error body, not just the status code", async () => {
const callTemplate: HttpCallTemplate = {
name: "forbidden_server",
call_template_type: "http",
url: `http://localhost:${serverPort}/forbidden`,
http_method: "POST",
};

let thrown: any;
try {
await protocol.callTool(mockClient, "test.forbidden", {}, callTemplate);
} catch (e) {
thrown = e;
}

expect(thrown).toBeInstanceOf(Error);
// The reason the server sent (not just "status code 403") must be present.
expect(thrown.message).toContain("You are not allowed to do that, and here is exactly why.");
expect(thrown.message).toContain("403");
// Enumerable status/data survive structured serialization out of a sandbox.
expect(thrown.status).toBe(403);
expect(thrown.data).toEqual({ error: "You are not allowed to do that, and here is exactly why." });
const roundTripped = JSON.parse(JSON.stringify(thrown));
expect(roundTripped.status).toBe(403);
expect(roundTripped.data).toEqual({ error: "You are not allowed to do that, and here is exactly why." });
});

test("should JSON-stringify an object-valued error field, not '[object Object]'", async () => {
const callTemplate: HttpCallTemplate = {
name: "forbidden_object_server",
call_template_type: "http",
url: `http://localhost:${serverPort}/forbidden-object`,
http_method: "POST",
};

let thrown: any;
try {
await protocol.callTool(mockClient, "test.forbidden_object", {}, callTemplate);
} catch (e) {
thrown = e;
}

expect(thrown).toBeInstanceOf(Error);
expect(thrown.message).not.toContain("[object Object]");
// The structured detail survives in the message...
expect(thrown.message).toContain("INVALID_FIELD");
expect(thrown.message).toContain("value out of range");
// ...and the raw object is preserved on `data`.
expect(thrown.status).toBe(422);
expect(thrown.data).toEqual({ error: { code: "INVALID_FIELD", reason: "value out of range" } });
});
});
});

// The streamable/sse protocols' callTool paths are stubs; the only real fetch
// that can fail with a body is in registerManual (discovery). These prove that
// failure surfaces the server's body, not just "HTTP 403: Forbidden".
describe("discovery error body (streamable_http + sse)", () => {
test("StreamableHttpCommunicationProtocol.registerManual surfaces the server body", async () => {
const protocol = new StreamableHttpCommunicationProtocol();
const result = await protocol.registerManual(mockClient, {
name: "forbidden_stream",
call_template_type: "streamable_http",
url: `http://localhost:${serverPort}/forbidden-discovery`,
http_method: "GET",
} as any);

expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0]).toContain("discovery refused: tenant is not provisioned for streaming");
expect(result.errors[0]).toContain("403");
});

test("SseCommunicationProtocol.registerManual surfaces the server body", async () => {
const protocol = new SseCommunicationProtocol();
const result = await protocol.registerManual(mockClient, {
name: "forbidden_sse",
call_template_type: "sse",
url: `http://localhost:${serverPort}/forbidden-discovery`,
} as any);

expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0]).toContain("discovery refused: tenant is not provisioned for streaming");
expect(result.errors[0]).toContain("403");
});
});

describe("error body edge cases", () => {
const callForbidden = async (path: string) => {
const protocol = new HttpCommunicationProtocol();
try {
await protocol.callTool(mockClient, "test.forbidden", {}, {
name: "forbidden_server",
call_template_type: "http",
url: `http://localhost:${serverPort}${path}`,
http_method: "POST",
} as any);
} catch (e) {
return e as any;
}
throw new Error("expected the call to fail");
};

test("a blank error body falls back to the status text instead of a bare colon", async () => {
const thrown = await callForbidden("/forbidden-empty");
expect(thrown).toBeInstanceOf(Error);
expect(thrown.status).toBe(403);
expect(thrown.message).toContain("403");
expect(thrown.message).toContain("Forbidden");
expect(thrown.message.trimEnd().endsWith(":")).toBe(false);
});

test("a structured error field wins over a lower-priority generic message", async () => {
const thrown = await callForbidden("/forbidden-object-then-message");
expect(thrown.status).toBe(422);
expect(thrown.message).toContain("INVALID_FIELD");
expect(thrown.message).toContain("value out of range");
expect(thrown.message).not.toContain("[object Object]");
});

test("truncation never splits a surrogate pair", async () => {
const thrown = await callForbidden("/forbidden-emoji-boundary");
expect(thrown.status).toBe(403);
const loneSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
expect(loneSurrogate.test(thrown.message)).toBe(false);
expect(thrown.message).toContain("😀");
expect(thrown.message).toContain("…");
});

test("a huge error body is truncated in the message but kept in full on data", async () => {
const thrown = await callForbidden("/forbidden-huge");
expect(thrown.status).toBe(403);
expect(thrown.message.length).toBeLessThan(2500);
expect(thrown.data).toHaveLength(5000);
});

test("a huge discovery error body is truncated in errors[]", async () => {
const protocol = new StreamableHttpCommunicationProtocol();
const result = await protocol.registerManual(mockClient, {
name: "huge_stream",
call_template_type: "streamable_http",
url: `http://localhost:${serverPort}/forbidden-huge`,
http_method: "GET",
} as any);
expect(result.success).toBe(false);
expect(result.errors[0]).toContain("403");
expect(result.errors[0].length).toBeLessThan(400);
});
});