Files
next-tjwater-agent/src/product/mcp-query-retry.ts
T
jiang b8f7edb3ee
Generic Container CI/CD / test-build-publish (push) Successful in 1m53s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m53s
feat(agent): improve investigation contracts and runtime readiness
2026-11-07 19:43:57 +08:00

44 lines
2.6 KiB
TypeScript

import { setTimeout as delay } from "node:timers/promises";
import type { Tool } from "@modelcontextprotocol/client";
import { PRODUCT_MCP_READ_TOOLS } from "../runtime/product-mcp-tools.js";
const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
const transientCodes = new Set(["SOURCE_UNAVAILABLE", "QUERY_TIMEOUT", "RATE_LIMIT", "RATE_LIMITED", "REQUEST_TIMEOUT"]);
const networkCodes = new Set(["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN", "ENOTFOUND", "EPIPE", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT"]);
export const transientMcpHttpStatus = (status: unknown) => status === 429 || status === 502 || status === 503 || status === 504;
export function mayRetryMcpRead(tool: Tool): boolean {
// The calculation waiter already owns four bounded status attempts. Do not
// multiply its polling budget, or trust readOnlyHint as an independent grant.
return tool.name !== "list_calculation_jobs" && (PRODUCT_MCP_READ_TOOLS as readonly string[]).includes(tool.name) &&
tool.annotations?.readOnlyHint === true && tool.annotations.destructiveHint !== true;
}
export function mcpToolFailure(result: unknown): Record<string, unknown> | undefined {
if (!record(result) || result.isError !== true || !record(result.structuredContent) || !record(result.structuredContent.error)) return;
return result.structuredContent.error;
}
export function isMcpAuthenticationFailure(value: unknown): boolean {
if (!record(value)) return false;
const status = value.status ?? value.statusCode;
return typeof status === "number" ? status === 401 : value.code === "UNAUTHORIZED" || value.code === "AUTH_REQUIRED";
}
export function isTransientMcpFailure(value: unknown): boolean {
if (!record(value) || value.retryable === false) return false;
const status = value.status ?? value.statusCode;
if (typeof status === "number") return transientMcpHttpStatus(status);
if (typeof value.code === "string" && (transientCodes.has(value.code) || networkCodes.has(value.code))) return true;
if (value.name === "TimeoutError" || value.code === -32001) return true;
if (value.name === "TypeError" && typeof value.message === "string" && /fetch|network|connection|connect|load failed|socket/i.test(value.message)) return true;
return record(value.cause) && typeof value.cause.code === "string" && networkCodes.has(value.cause.code);
}
export async function waitForMcpRetry(attempt: number, signal: AbortSignal): Promise<void> {
signal.throwIfAborted();
try { await delay(attempt === 0 ? 100 : 300, undefined, { signal }); }
catch (cause) { signal.throwIfAborted(); throw cause; }
}