WFDASH-WEBAPP/src/lib/server/mcp-upstream-fetch.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

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