- 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.
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
/**
|
|
* Shared upstream-fetch helper for the MCP route.
|
|
*
|
|
* Background: every write tool in `src/app/api/mcp/route.ts` does
|
|
* `fetch(${req.nextUrl.origin}/api/v1/<resource>/<op>)` and then
|
|
* `if (!res.ok) throw new Error(...)`. The thrown error gets
|
|
* caught by the JSON-RPC handler and surfaced as a bare
|
|
* `{ code: -32000, message: "fetch failed" }` — the agent sees no
|
|
* status, no upstream body, no `err.cause` chain. That makes
|
|
* "is the upstream dead or did my input get rejected" impossible
|
|
* to distinguish without a server-side log hunt.
|
|
*
|
|
* This module turns the three failure modes into a typed
|
|
* `McpUpstreamError` with a stable JSON-RPC error envelope:
|
|
*
|
|
* -32010 — fetch failed to reach the upstream (network / DNS /
|
|
* TLS / connection refused / dev server down). The
|
|
* route never ran. `data.cause` is the underlying
|
|
* node/undici error code (`ECONNREFUSED`,
|
|
* `UND_ERR_SOCKET`, `ENOTFOUND`,
|
|
* `UND_ERR_CONNECT_TIMEOUT`, etc.) and `data.status`
|
|
* is null.
|
|
* -32011 — upstream responded with a non-2xx status. The route
|
|
* ran and rejected the input (or the upstream itself
|
|
* is sick). `data.status` is the HTTP status and
|
|
* `data.upstreamBody` is the route's actual response —
|
|
* hand that to the LLM verbatim.
|
|
*
|
|
* Code offset: -32001 is already used by the route's auth-handler
|
|
* catch (line 5944 of `src/app/api/mcp/route.ts`); -32002 is
|
|
* reserved for the generic-500 fallthrough. Using -32010/-32011
|
|
* stays in the server-error range but avoids the collision.
|
|
*
|
|
* `data.hint` and `data.nextActions` are supplied per tool by the
|
|
* MCP route's caller — these are the "what to do next" pointers
|
|
* the agent was missing.
|
|
*/
|
|
export const MCP_ERR_NETWORK = -32010;
|
|
export const MCP_ERR_UPSTREAM = -32011;
|
|
|
|
export interface McpUpstreamErrorData {
|
|
/** Tool name as the agent knows it (e.g. "quotes.submit"). */
|
|
tool: string;
|
|
/** The URL the MCP route tried to reach. Useful for the
|
|
* "is the dev server actually running on this port" check. */
|
|
url: string;
|
|
/** HTTP method. */
|
|
method: string;
|
|
/** Upstream status if the response came back, null otherwise. */
|
|
status: number | null;
|
|
/** Node-style error code from `err.cause.code` if the fetch
|
|
* itself failed (e.g. "ECONNREFUSED"). Null on -32002. */
|
|
cause: string | null;
|
|
/** `err.cause.message` from the underlying fetch failure.
|
|
* Usually "fetch failed" — unhelpful, but kept for the case
|
|
* where the underlying client surfaces more. */
|
|
causeMessage: string | null;
|
|
/** Parsed JSON body when the upstream returned a 4xx/5xx with
|
|
* JSON content. Falls back to the raw text on parse failure. */
|
|
upstreamBody: unknown | null;
|
|
/** Set to true when `upstreamBody` was truncated to fit the
|
|
* 8 KB cap. The original body is dropped after the cap since
|
|
* the JSON-RPC error could otherwise explode the agent's
|
|
* context. */
|
|
truncated: boolean;
|
|
/** Per-tool hint explaining the failure mode. */
|
|
hint: string;
|
|
/** Operator-side follow-up actions. */
|
|
nextActions: string[];
|
|
}
|
|
|
|
export class McpUpstreamError extends Error {
|
|
readonly code: typeof MCP_ERR_NETWORK | typeof MCP_ERR_UPSTREAM;
|
|
readonly data: McpUpstreamErrorData;
|
|
|
|
constructor(
|
|
code: typeof MCP_ERR_NETWORK | typeof MCP_ERR_UPSTREAM,
|
|
message: string,
|
|
data: McpUpstreamErrorData,
|
|
) {
|
|
super(message);
|
|
this.name = "McpUpstreamError";
|
|
this.code = code;
|
|
this.data = data;
|
|
}
|
|
}
|
|
|
|
export interface FetchUpstreamOptions {
|
|
/** When false, return the raw `Response` on success instead of
|
|
* the parsed JSON body. Use for endpoints that return binary
|
|
* (PDFs, images, etc.). Default true. */
|
|
parseBody?: boolean;
|
|
/** Override the default hint for this tool. */
|
|
hint?: string;
|
|
/** Override the default nextActions for this tool. */
|
|
nextActions?: string[];
|
|
/** Bearer token to forward upstream. Defaults to
|
|
* `process.env.AGENT_API_KEY ?? ""` — callers should rely on
|
|
* the default unless the tool has a different auth path. */
|
|
bearer?: string;
|
|
}
|
|
|
|
/** Cap to keep the JSON-RPC error envelope bounded. 8 KB is
|
|
* plenty for a structured 422 envelope and well under the
|
|
* agent's context-window budget. Larger bodies are truncated
|
|
* with `truncated: true` so the agent knows it didn't see
|
|
* everything. */
|
|
const UPSTREAM_BODY_MAX_BYTES = 8 * 1024;
|
|
|
|
const DEFAULT_HINT =
|
|
"The MCP server could not reach its own /api/v1 route. The upstream is unreachable.";
|
|
|
|
const DEFAULT_NEXT_ACTIONS = [
|
|
"Verify the dev/prod server is running and reachable on the expected port",
|
|
"If running behind a reverse proxy, check that X-Forwarded-* headers are configured",
|
|
"If status is 401, AGENT_API_KEY in the deployment env may have rotated",
|
|
];
|
|
|
|
const DEFAULT_UPSTREAM_HINT =
|
|
"The upstream route returned a non-2xx status. data.upstreamBody is the route's actual response — pass it to the LLM as the canonical answer.";
|
|
const DEFAULT_UPSTREAM_NEXT_ACTIONS = [
|
|
"Read `data.upstreamBody` — that's the route's actual response",
|
|
"Do NOT retry blindly; fix the input and resend",
|
|
"If status is 500 + upstreamBody.error mentions a select field, the PB schema needs a new value (see AGENTS.md)",
|
|
];
|
|
|
|
/**
|
|
* Wrap a node/undici fetch failure into a human-readable cause
|
|
* code. The wire-level exception is `TypeError: fetch failed` with
|
|
* `err.cause` carrying the underlying socket/DNS/TLS error. We
|
|
* surface the cause code (e.g. ECONNREFUSED) so the agent can
|
|
* decide whether to retry.
|
|
*/
|
|
function extractCause(err: unknown): {
|
|
cause: string | null;
|
|
causeMessage: string | null;
|
|
hint: string;
|
|
nextActions: string[];
|
|
} {
|
|
const e = err as { cause?: { code?: string; message?: string } };
|
|
const code = e?.cause?.code ?? null;
|
|
const msg = e?.cause?.message ?? null;
|
|
|
|
// Best-effort hints based on the most common undici / node error
|
|
// codes. The agent gets the most actionable next-step without
|
|
// having to translate "fetch failed" into context.
|
|
switch (code) {
|
|
case "ECONNREFUSED":
|
|
return {
|
|
cause: code,
|
|
causeMessage: msg,
|
|
hint: "Connection refused — the upstream /api/v1 route is unreachable. Likely the dev/prod server is not running on this port.",
|
|
nextActions: [
|
|
"Check that the dev server is running on the expected port (default 4001 in dev)",
|
|
"Verify req.nextUrl.origin resolves to the same host as the running server",
|
|
"If running in production, check the deployer's health check",
|
|
],
|
|
};
|
|
case "ENOTFOUND":
|
|
return {
|
|
cause: code,
|
|
causeMessage: msg,
|
|
hint: "DNS lookup failed — req.nextUrl.origin's hostname could not be resolved.",
|
|
nextActions: [
|
|
"Verify the hostname in req.nextUrl.origin is reachable from this process",
|
|
"Check container DNS / /etc/hosts in the deploy environment",
|
|
],
|
|
};
|
|
case "UND_ERR_SOCKET":
|
|
case "ECONNRESET":
|
|
return {
|
|
cause: code,
|
|
causeMessage: msg,
|
|
hint: "The upstream socket was closed mid-response. Likely a reverse-proxy timeout or a server-side crash.",
|
|
nextActions: [
|
|
"Check the upstream server logs for a crash or timeout",
|
|
"Look for an upstream timeout (nginx: 60s default; raise if this is a long-running route)",
|
|
],
|
|
};
|
|
case "UND_ERR_CONNECT_TIMEOUT":
|
|
case "ETIMEDOUT":
|
|
return {
|
|
cause: code,
|
|
causeMessage: msg,
|
|
hint: "Connection to the upstream timed out. The route may be slow or the server overloaded.",
|
|
nextActions: [
|
|
"Retry after a short delay",
|
|
"If persistent, check the upstream server's CPU / event-loop saturation",
|
|
],
|
|
};
|
|
default:
|
|
return {
|
|
cause: code,
|
|
causeMessage: msg,
|
|
hint: DEFAULT_HINT,
|
|
nextActions: DEFAULT_NEXT_ACTIONS,
|
|
};
|
|
}
|
|
}
|
|
|
|
/** Read the response body in a best-effort way: try JSON first,
|
|
* fall back to raw text, cap at UPSTREAM_BODY_MAX_BYTES so the
|
|
* error envelope stays bounded. */
|
|
async function readResponseBody(
|
|
res: Response,
|
|
): Promise<{ body: unknown; truncated: boolean }> {
|
|
// Read the raw bytes so we can both cap and try-JSON.
|
|
const buf = await res.text();
|
|
if (buf.length > UPSTREAM_BODY_MAX_BYTES) {
|
|
// Truncate without trying to parse — the JSON is likely
|
|
// broken at the cut anyway.
|
|
return {
|
|
body: buf.slice(0, UPSTREAM_BODY_MAX_BYTES),
|
|
truncated: true,
|
|
};
|
|
}
|
|
// Try JSON first; fall back to the raw text.
|
|
try {
|
|
return { body: JSON.parse(buf), truncated: false };
|
|
} catch {
|
|
return { body: buf, truncated: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run `fetch(url, init)` with the standard MCP write-tool pattern:
|
|
* - forward `AGENT_API_KEY` as bearer
|
|
* - check res.ok
|
|
* - parse JSON (or return the raw Response if `parseBody: false`)
|
|
* - on failure, throw an `McpUpstreamError` with the structured
|
|
* data envelope
|
|
*
|
|
* Callers should `catch (err) { if (err instanceof McpUpstreamError)
|
|
* ... }` at the JSON-RPC layer to translate to the JSON-RPC error
|
|
* envelope.
|
|
*/
|
|
export async function fetchUpstreamJson(
|
|
url: string,
|
|
init: RequestInit,
|
|
tool: string,
|
|
options: FetchUpstreamOptions = {},
|
|
): Promise<unknown> {
|
|
const {
|
|
parseBody = true,
|
|
hint,
|
|
nextActions,
|
|
bearer = process.env.AGENT_API_KEY ?? "",
|
|
} = options;
|
|
|
|
// Always forward the bearer — the MCP route is the agent's
|
|
// trust boundary, but the upstream /api/v1 routes also enforce
|
|
// a bearer for safety (defence in depth).
|
|
const headers = new Headers(init.headers);
|
|
if (bearer) {
|
|
headers.set("authorization", `Bearer ${bearer}`);
|
|
}
|
|
const upstreamInit: RequestInit = { ...init, headers };
|
|
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(url, upstreamInit);
|
|
} catch (err) {
|
|
const cause = extractCause(err);
|
|
throw new McpUpstreamError(MCP_ERR_NETWORK, "Upstream fetch failed", {
|
|
tool,
|
|
url,
|
|
method: upstreamInit.method ?? "GET",
|
|
status: null,
|
|
cause: cause.cause,
|
|
causeMessage: cause.causeMessage,
|
|
upstreamBody: null,
|
|
truncated: false,
|
|
hint: hint ?? cause.hint,
|
|
nextActions: nextActions ?? cause.nextActions,
|
|
});
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const { body, truncated } = await readResponseBody(res);
|
|
throw new McpUpstreamError(
|
|
MCP_ERR_UPSTREAM,
|
|
`Upstream returned ${res.status}`,
|
|
{
|
|
tool,
|
|
url,
|
|
method: upstreamInit.method ?? "GET",
|
|
status: res.status,
|
|
cause: null,
|
|
causeMessage: null,
|
|
upstreamBody: body,
|
|
truncated,
|
|
hint: hint ?? DEFAULT_UPSTREAM_HINT,
|
|
nextActions: nextActions ?? DEFAULT_UPSTREAM_NEXT_ACTIONS,
|
|
},
|
|
);
|
|
}
|
|
|
|
if (!parseBody) {
|
|
return res;
|
|
}
|
|
|
|
// 2xx — parse the JSON body (or return the raw text if it's
|
|
// not JSON, which is itself a hint that the route didn't shape
|
|
// its response per the MCP convention).
|
|
try {
|
|
return await res.json();
|
|
} catch {
|
|
return await res.text();
|
|
}
|
|
}
|