WFDASH-WEBAPP/src/lib/server/mcp-upstream-fetch.test.ts
Dave e7672ab7c9 feat(mcp): implement structured MCP error handling for upstream fetches
- 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.
2026-08-15 22:32:11 +02:00

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);
});
});