- Introduced structured error handling for MCP upstream fetches, distinguishing between network errors (`-32010`) and upstream errors (`-32011`). This allows the agent to provide more informative feedback based on the type of failure encountered. - Updated the `fetchUpstreamJson` function to throw `McpUpstreamError` with detailed error data, including hints and next actions for specific tools. - Enhanced the `TOOL_HINTS` mapping to provide tool-specific guidance for handling errors, improving the agent's ability to respond to various failure scenarios. - Added comprehensive unit tests to validate the new error handling logic and ensure correct behavior across different error cases. - Updated documentation in `docs/agents/hermes-skill.md` to include a new section on structured MCP errors, detailing the error codes and their meanings.
257 lines
8.3 KiB
TypeScript
257 lines
8.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
fetchUpstreamJson,
|
|
McpUpstreamError,
|
|
MCP_ERR_NETWORK,
|
|
MCP_ERR_UPSTREAM,
|
|
} from "@/lib/server/mcp-upstream-fetch";
|
|
|
|
/**
|
|
* We hit the real `fetch` global but route it through a mock that
|
|
* lives in the test file. That way each test controls the response
|
|
* shape, the status, and (for the failure path) the network-level
|
|
* error code on `err.cause`.
|
|
*
|
|
* We don't use vi.mock('@/lib/server/mcp-upstream-fetch') because
|
|
* the helper is the SUT — we want to exercise it end-to-end.
|
|
*/
|
|
|
|
const ORIGINAL_FETCH = global.fetch;
|
|
|
|
describe("fetchUpstreamJson", () => {
|
|
beforeEach(() => {
|
|
// Reset any prior stub
|
|
global.fetch = ORIGINAL_FETCH;
|
|
});
|
|
afterEach(() => {
|
|
global.fetch = ORIGINAL_FETCH;
|
|
});
|
|
|
|
it("returns the parsed JSON body on 200", async () => {
|
|
global.fetch = vi.fn(async () =>
|
|
new Response(JSON.stringify({ ok: true, id: "abc12345xyz6789" }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
) as unknown as typeof fetch;
|
|
|
|
const result = await fetchUpstreamJson(
|
|
"http://localhost:4001/api/v1/quotes/abc/submit",
|
|
{ method: "POST" },
|
|
"quotes.submit",
|
|
);
|
|
|
|
expect(result).toEqual({ ok: true, id: "abc12345xyz6789" });
|
|
});
|
|
|
|
it("throws McpUpstreamError(-32010) with cause=ECONNREFUSED on connect refusal", async () => {
|
|
// Node/undici: a connect-refused surfaces as a TypeError whose
|
|
// `cause` is a SystemError with code ECONNREFUSED.
|
|
const networkError = Object.assign(new TypeError("fetch failed"), {
|
|
cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:4001"), {
|
|
code: "ECONNREFUSED",
|
|
errno: -61,
|
|
syscall: "connect",
|
|
}),
|
|
});
|
|
global.fetch = vi.fn(async () => {
|
|
throw networkError;
|
|
}) as unknown as typeof fetch;
|
|
|
|
await expect(
|
|
fetchUpstreamJson(
|
|
"http://localhost:4001/api/v1/quotes/abc/submit",
|
|
{ method: "POST" },
|
|
"quotes.submit",
|
|
),
|
|
).rejects.toMatchObject({
|
|
name: "McpUpstreamError",
|
|
code: MCP_ERR_NETWORK,
|
|
message: "Upstream fetch failed",
|
|
data: {
|
|
tool: "quotes.submit",
|
|
url: "http://localhost:4001/api/v1/quotes/abc/submit",
|
|
method: "POST",
|
|
status: null,
|
|
cause: "ECONNREFUSED",
|
|
causeMessage: "connect ECONNREFUSED 127.0.0.1:4001",
|
|
upstreamBody: null,
|
|
truncated: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("throws McpUpstreamError(-32011) with status=422 and parsed upstreamBody on validation failure", async () => {
|
|
global.fetch = vi.fn(async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
error: "2 of 5 lines have selling_price = 0",
|
|
code: "unpriced_items",
|
|
offendingItemIds: ["abc12345xyz6789", "def12345xyz6789"],
|
|
}),
|
|
{ status: 422, headers: { "content-type": "application/json" } },
|
|
),
|
|
) as unknown as typeof fetch;
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await fetchUpstreamJson(
|
|
"http://localhost:4001/api/v1/quotes/abc/submit",
|
|
{ method: "POST" },
|
|
"quotes.submit",
|
|
);
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
expect(caught).toBeInstanceOf(McpUpstreamError);
|
|
const e = caught as McpUpstreamError;
|
|
expect(e.code).toBe(MCP_ERR_UPSTREAM);
|
|
expect(e.data.status).toBe(422);
|
|
expect(e.data.tool).toBe("quotes.submit");
|
|
expect(e.data.upstreamBody).toEqual({
|
|
error: "2 of 5 lines have selling_price = 0",
|
|
code: "unpriced_items",
|
|
offendingItemIds: ["abc12345xyz6789", "def12345xyz6789"],
|
|
});
|
|
expect(e.data.truncated).toBe(false);
|
|
});
|
|
|
|
it("falls back to raw text when upstreamBody is not JSON", async () => {
|
|
global.fetch = vi.fn(async () =>
|
|
new Response("<html>502 Bad Gateway</html>", {
|
|
status: 502,
|
|
headers: { "content-type": "text/html" },
|
|
}),
|
|
) as unknown as typeof fetch;
|
|
|
|
const caught = await fetchUpstreamJson(
|
|
"http://localhost:4001/api/v1/quotes/abc/submit",
|
|
{ method: "POST" },
|
|
"quotes.submit",
|
|
).catch((e) => e);
|
|
expect(caught).toBeInstanceOf(McpUpstreamError);
|
|
const e = caught as McpUpstreamError;
|
|
expect(e.code).toBe(MCP_ERR_UPSTREAM);
|
|
expect(e.data.status).toBe(502);
|
|
expect(e.data.upstreamBody).toBe("<html>502 Bad Gateway</html>");
|
|
});
|
|
|
|
it("truncates the upstreamBody when it exceeds the 8 KB cap", async () => {
|
|
const huge = "x".repeat(16 * 1024); // 16 KB, well over the cap
|
|
global.fetch = vi.fn(async () =>
|
|
new Response(JSON.stringify({ error: huge }), {
|
|
status: 500,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
) as unknown as typeof fetch;
|
|
|
|
const caught = await fetchUpstreamJson(
|
|
"http://localhost:4001/api/v1/quotes/abc/submit",
|
|
{ method: "POST" },
|
|
"quotes.submit",
|
|
).catch((e) => e);
|
|
expect(caught).toBeInstanceOf(McpUpstreamError);
|
|
const e = caught as McpUpstreamError;
|
|
expect(e.data.truncated).toBe(true);
|
|
const body = e.data.upstreamBody as string;
|
|
expect(body.length).toBe(8 * 1024);
|
|
});
|
|
|
|
it("returns the raw Response when parseBody=false", async () => {
|
|
global.fetch = vi.fn(async () =>
|
|
new Response(new ArrayBuffer(8), {
|
|
status: 200,
|
|
headers: { "content-type": "application/pdf" },
|
|
}),
|
|
) as unknown as typeof fetch;
|
|
|
|
const result = await fetchUpstreamJson(
|
|
"http://localhost:4001/api/v1/quotes/abc/pdf",
|
|
{ method: "POST" },
|
|
"quotes.get_pdf",
|
|
{ parseBody: false },
|
|
);
|
|
expect(result).toBeInstanceOf(Response);
|
|
expect((result as Response).headers.get("content-type")).toBe(
|
|
"application/pdf",
|
|
);
|
|
});
|
|
|
|
it("respects the per-tool hint override", async () => {
|
|
global.fetch = vi.fn(async () => {
|
|
throw Object.assign(new TypeError("fetch failed"), {
|
|
cause: Object.assign(new Error("connect ECONNREFUSED"), {
|
|
code: "ECONNREFUSED",
|
|
}),
|
|
});
|
|
}) as unknown as typeof fetch;
|
|
|
|
const caught = await fetchUpstreamJson(
|
|
"http://localhost:4001/api/v1/quotes/abc/submit",
|
|
{ method: "POST" },
|
|
"quotes.submit",
|
|
{
|
|
hint: "Submission cannot fail with ECONNREFUSED — the server is the issue.",
|
|
nextActions: ["Restart the dev server"],
|
|
},
|
|
).catch((e) => e);
|
|
expect(caught).toBeInstanceOf(McpUpstreamError);
|
|
const e = caught as McpUpstreamError;
|
|
expect(e.data.hint).toBe(
|
|
"Submission cannot fail with ECONNREFUSED — the server is the issue.",
|
|
);
|
|
expect(e.data.nextActions).toEqual(["Restart the dev server"]);
|
|
});
|
|
|
|
it("forwards the bearer token from AGENT_API_KEY by default", async () => {
|
|
const originalKey = process.env.AGENT_API_KEY;
|
|
process.env.AGENT_API_KEY = "test-key-123";
|
|
try {
|
|
const fetchSpy = vi.fn(async () =>
|
|
new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
global.fetch = fetchSpy as unknown as typeof fetch;
|
|
|
|
await fetchUpstreamJson(
|
|
"http://localhost:4001/api/v1/quotes/abc/submit",
|
|
{ method: "POST" },
|
|
"quotes.submit",
|
|
);
|
|
|
|
const calls = fetchSpy.mock.calls as unknown as Array<
|
|
[unknown, RequestInit | undefined]
|
|
>;
|
|
const initHeaders = calls[0]?.[1]?.headers;
|
|
const callHeaders: Headers =
|
|
initHeaders instanceof Headers
|
|
? initHeaders
|
|
: new Headers(initHeaders as HeadersInit);
|
|
expect(callHeaders.get("authorization")).toBe("Bearer test-key-123");
|
|
} finally {
|
|
process.env.AGENT_API_KEY = originalKey;
|
|
}
|
|
});
|
|
|
|
it("classifies UND_ERR_SOCKET as a socket-level hint", async () => {
|
|
global.fetch = vi.fn(async () => {
|
|
throw Object.assign(new TypeError("fetch failed"), {
|
|
cause: Object.assign(new Error("socket hang up"), {
|
|
code: "UND_ERR_SOCKET",
|
|
}),
|
|
});
|
|
}) as unknown as typeof fetch;
|
|
|
|
const caught = await fetchUpstreamJson(
|
|
"http://localhost:4001/api/v1/quotes/abc/submit",
|
|
{ method: "POST" },
|
|
"quotes.submit",
|
|
).catch((e) => e);
|
|
expect(caught).toBeInstanceOf(McpUpstreamError);
|
|
const e = caught as McpUpstreamError;
|
|
expect(e.data.cause).toBe("UND_ERR_SOCKET");
|
|
expect(e.data.hint).toMatch(/socket was closed/i);
|
|
});
|
|
});
|