Component
Core (@keyvaluesystems/agent-opfor-core) — MCP baseline scanner / OpenAI-compatible LLM client
Description
What happened:
Running opfor run against an MCP target with an invalid attackerLlm.model (routed through an OpenAI-compatible gateway, e.g. LiteLLM) intermittently crashes the whole run with a raw, unhandled TypeError: fetch failed instead of the clean Error: LLM HTTP 400: ... message the gateway actually returned.
Observed stack trace:
Error: LLM HTTP 400: {"error":{"message":"litellm.BadRequestError: You passed in model=Openai. There are no healthy deployments for this model. ...
vs., intermittently, on the same config:
TypeError: fetch failed
at node:internal/deps/undici/undici:13510:13
at async chatCompletionJsonContent (...)
at async judgeToolResponse (...)
at async scanToolDescriptions (...)
at async runAll (...)
Root cause (two compounding issues):
-
chatCompletionJsonContent (core/src/llm/openaiCompatible.ts) retries up to 3 times on HTTP 400 (stripping json_object mode, then temperature) to work around providers that reject those params. It has no way to distinguish "this param isn't supported" from "this model doesn't exist" — an invalid model returns 400 on every attempt regardless of these params. Each retry fires immediately, back-to-back, without ever draining the previous (discarded) response body. An unconsumed fetch response body holds its connection open; issuing several rapid requests to the same host without draining prior bodies can exhaust the connection pool and throw an opaque TypeError: fetch failed that completely masks the real, informative LLM HTTP 400: ... error the function is designed to produce.
-
Independently, nothing between chatCompletionJsonContent and the CLI's top-level handler catches this failure. judgeToolResponse (core/src/run/judge.ts) calls it with no try/catch, and both scanResources and scanToolDescriptions in core/src/execute/baselineScanner.ts call judgeToolResponse for every resource/tool with no try/catch either — so any transient failure during the MCP pre-flight baseline scans (which run before the real evaluator suite even starts) takes down the entire run/report instead of failing just that one scan.
Steps to reproduce:
- Configure an MCP target with a suite selected (e.g.
owasp-mcp-top10).
- Set
attackerLlm to an openai-compatible provider pointed at a gateway (e.g. LiteLLM), with an invalid model name.
- Run
opfor run --config <path>.
- Usually see a clean
Error: LLM HTTP 400: .... Intermittently (network/connection-timing dependent), see an unhandled TypeError: fetch failed with a full stack trace instead, and the whole run aborts rather than reporting the scan as errored.
Impact:
- Misleading/unhelpful error message for a very common misconfiguration (wrong model name).
- A single transient network hiccup during baseline scans aborts the entire assessment rather than degrading gracefully.
Fix:
- Drain each discarded response body in
chatCompletionJsonContent's 400-retry path before firing the next request.
- Wrap
judgeToolResponse calls in scanResources and scanToolDescriptions so a judge/LLM failure produces an ERROR-verdict result (consistent with the existing mcpErrorJudge pattern already used elsewhere in baselineScanner.ts) and the scan continues, instead of crashing the whole run.
Component
Core (
@keyvaluesystems/agent-opfor-core) — MCP baseline scanner / OpenAI-compatible LLM clientDescription
What happened:
Running
opfor runagainst an MCP target with an invalidattackerLlm.model(routed through an OpenAI-compatible gateway, e.g. LiteLLM) intermittently crashes the whole run with a raw, unhandledTypeError: fetch failedinstead of the cleanError: LLM HTTP 400: ...message the gateway actually returned.Observed stack trace:
vs., intermittently, on the same config:
Root cause (two compounding issues):
chatCompletionJsonContent(core/src/llm/openaiCompatible.ts) retries up to 3 times on HTTP 400 (strippingjson_objectmode, thentemperature) to work around providers that reject those params. It has no way to distinguish "this param isn't supported" from "this model doesn't exist" — an invalid model returns 400 on every attempt regardless of these params. Each retry fires immediately, back-to-back, without ever draining the previous (discarded) response body. An unconsumedfetchresponse body holds its connection open; issuing several rapid requests to the same host without draining prior bodies can exhaust the connection pool and throw an opaqueTypeError: fetch failedthat completely masks the real, informativeLLM HTTP 400: ...error the function is designed to produce.Independently, nothing between
chatCompletionJsonContentand the CLI's top-level handler catches this failure.judgeToolResponse(core/src/run/judge.ts) calls it with no try/catch, and bothscanResourcesandscanToolDescriptionsincore/src/execute/baselineScanner.tscalljudgeToolResponsefor every resource/tool with no try/catch either — so any transient failure during the MCP pre-flight baseline scans (which run before the real evaluator suite even starts) takes down the entire run/report instead of failing just that one scan.Steps to reproduce:
owasp-mcp-top10).attackerLlmto anopenai-compatibleprovider pointed at a gateway (e.g. LiteLLM), with an invalidmodelname.opfor run --config <path>.Error: LLM HTTP 400: .... Intermittently (network/connection-timing dependent), see an unhandledTypeError: fetch failedwith a full stack trace instead, and the whole run aborts rather than reporting the scan as errored.Impact:
Fix:
chatCompletionJsonContent's 400-retry path before firing the next request.judgeToolResponsecalls inscanResourcesandscanToolDescriptionsso a judge/LLM failure produces anERROR-verdict result (consistent with the existingmcpErrorJudgepattern already used elsewhere inbaselineScanner.ts) and the scan continues, instead of crashing the whole run.