fix(workflow-executor): surface the cause when MCP tool loading fails - #1806
fix(workflow-executor): surface the cause when MCP tool loading fails#1806hercemer42 wants to merge 10 commits into
Conversation
ai-proxy holds an optional host logger and no-ops every emit when none is
given, so a workflow MCP step that failed tool loading left no cause anywhere
in the customer's own logs: a revoked token, a resource never shared and an
unreachable server were indistinguishable.
The cause is flattened to { error, stack } rather than handed over as the log
context, because an Error's own properties are non-enumerable and would vanish
from the serialised line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (6)
🛟 Help
|
hercemer42
left a comment
There was a problem hiding this comment.
Automated validator pass (Claude Opus 5, claude-opus-5[1m]) — 6 inline findings: 2 Should fix, 4 Preferential. No approval implied; the spec check against PRD-876 conforms for this PR's half (AC#4, AC#5). Submitted rather than left pending so the findings are visible in the workflow.
ai-proxy logs from inside its per-server catch block before recording the failure it caught, so a host logger that threw would reject the whole Promise.all: tools from healthy servers discarded, and the OAuth reauth pause never reached. Guarding the bridge keeps logging out of control flow, the invariant the embedded executor's formatLog already states. Also carries the cause chain, so a wrapped `fetch failed` still names the ECONNREFUSED underneath it — the difference between an unreachable server and a rejected token. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude Opus 5 ( Applied
Declined
Checks after the fix: 4 suites / 78 tests green, Paused for a human call on the three declines before this leaves draft. |
The "MCP servers failed to load tools" line inferred failure by diffing config ids against loaded tool ids, so it could name the server but never why it failed — a revoked token, an unreachable host and a 15s timeout all logged identically. The providers already classify each failure and carry its error; the main load path was calling the tools-only method and dropping them. Reading the failures channel also removes a false positive the diff could not avoid: a healthy server exposing no tools contributed no ids, so it was reported as failed and the tool-listing endpoint answered 503 for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude Opus 5 ( Fixed
Declined, reasoning in the threads
Still open, deliberately
Checked and dismissed: whether the failures-channel switch could regress a config map holding two entries under one server id (a partial failure would now 503 where it used to return the working subset). It cannot happen — |
One comment described the state this PR ends ("ai-proxy emits its diagnostics
into a logger nobody passed"), which would read as false the moment it merged.
The rest restated their code or duplicated the source comment they sat next to.
Replaces the one what-comment that stood in for a missing Arrange step: the
no-logger case now builds its own adapter instead of reaching into the one
beforeEach made. Records the failures-channel rule as an invariant, since
inferring a load failure from absent tools is user-visible through the 503.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two exits differed only in which attempt produced the tools, so naming the rejected-token case and reading the retry's result once says the same thing with less branching — and clears the many-returns smell the analyser reports now that the function was touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude Opus 5 ( Worth recording that it wasn't introduced here: |
The README covered OpenTelemetry but never the logs, which are the first thing an operator reads when a step fails. Now that the line names the failing server and why it failed, say so — and what each failure kind means for the fix, since that is the difference between reconnecting a credential and chasing a firewall. LOG_LEVEL itself stays documented in .env.example, where the README already points for the full variable list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Callers build a fixed context shape and leave what they have nothing for undefined, so the CLI's human-readable output carried `cause=undefined` on every MCP failure and `stack=undefined` wherever the thrown value was not an Error. JSON output never showed them, since JSON.stringify omits them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A rejected cached credential is logged at Error by the provider, and the retry that fixes it only logged at Debug — so at the default level a run that recovered read as a pure failure, with nothing saying it continued. Observed on a live executor against a revoked access token. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude Opus 5 ( Ran the executor from this branch (pm2 The retry that fixes it only logged at This reverses my earlier decline on the recovered-OAuth thread. I argued there that "token rejected at 14:02, refreshed, continued" was information support wants — that was wrong: support only ever saw the rejection. The noise itself stays (the failure is real, and business rule 3 wants it at |
On the OAuth path the forced-refresh retry's outcome was discarded: a retry that failed to connect returned an empty tool list as a success, so the listing endpoint answered 200 instead of 503, and the recovery line claimed the credential refresh had worked. The reload hook is public API for the caller's own post-401 retry, so it keeps returning tools and reports the outcome through the enclosing scope. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PMerlet
left a comment
There was a problem hiding this comment.
Adversarial pass on the fetcher + logger bridge. The OAuth retry logic is sound (and fixes the loadFailed-left-undefined hole main had on the rejected-token path), tests assert full payloads, CI is green. Three inline points below: one contract gap worth addressing before merge (or an explicit follow-up ticket, since the fix lives in ai-proxy), two one-liners in files already touched.
| .map(([name]) => name); | ||
|
|
||
| if (failedConfigNames.length === 0) return false; | ||
| if (failures.length === 0) return false; |
There was a problem hiding this comment.
The failures channel is only populated by McpClient — in AiClient.loadRemoteToolsWithFailures, a provider without loadToolsWithFailures contributes failures: [], and ForestIntegrationClient doesn't implement it. Worse, its loadTools swallows an unsupported integrationName with a Warn and returns no tools.
So for a Forest connector whose integration name isn't recognized (version drift: the orchestrator advertises an integration this ai-proxy build doesn't support yet), the old id-diff answered loadFailed=true → 503 + Error log, while this now reads as a healthy empty server → 200 {"tools":[]}. That's a different case from the false positive this PR legitimately removes — and it's exactly the drift scenario where you'd want the failure surfaced. (Mitigation: the Warn is at least visible now that the logger is wired — but at Warn, and without the 503.)
Note the unit test that covers this (makeFailure('zendesk-prod', 'unknown', 'Unsupported integration: Zendesk')) mocks a failure the real ai-proxy can never emit for a Forest connector, so it encodes a contract the upstream implementation doesn't honor. The new CLAUDE.md invariant ("never infer failure from absent tools") codifies a rule with this known hole.
Suggested fix: give ForestIntegrationClient a loadToolsWithFailures that pushes a kind: 'unknown' failure from the default: branch of its switch — or a follow-up ticket, since this PR deliberately doesn't touch ai-proxy.
| failures: failures.map(failure => ({ | ||
| server: failure.server, | ||
| kind: failure.kind, | ||
| error: failure.error.message, |
There was a problem hiding this comment.
failure.error.message drops the cause chain: Node's fetch failed errors carry the actual reason (ECONNREFUSED, …) in .cause, and wrapped infra errors can have an empty .message — the exact cases extractErrorMessage/causeMessage in errors.ts exist for. The bridged ai-proxy line does name the cause, but this structured summary is the greppable line the PR body presents as the deliverable, and here it would say error: "fetch failed" alone.
| error: failure.error.message, | |
| error: extractErrorMessage(failure.error), | |
| cause: causeMessage(failure.error), |
|
|
||
| logger(level, message, { | ||
| error: extractErrorMessage(error), | ||
| cause: extractErrorMessage(cause), |
There was a problem hiding this comment.
extractErrorMessage only returns undefined for undefined; for null it falls through to String(null) → the string "null". So new Error('x', { cause: null }) emits cause="null" in pretty output — the same artifact this PR removes for undefined, in another guise (the pretty-logger filter also only tests !== undefined).
| cause: extractErrorMessage(cause), | |
| cause: cause == null ? undefined : extractErrorMessage(cause), |

What
A workflow MCP step whose tool load fails told the customer that it failed, never why. Two gaps, both fixed here.
1. The executor's own line had no cause.
errorOnPartialLoadFailureinferred failure by diffing config ids against loaded tool ids, so it could name the server but never the reason — a revoked token, an unreachable host and a 15s timeout all logged identically. The providers already classify each failure and carry its error, but the main load path called the tools-onlyloadRemoteToolsand dropped them. It now reads the failures channel:2. ai-proxy's own diagnostics went nowhere.
AiClientholds an optional host logger and no-ops every emit when none is passed;workflow-executornever passed one. Both construction sites now receive the logger the executor already builds, throughto-ai-proxy-logger.ts, which bridges the two logger contracts — ai-proxy's third parameter is anError, the executor's is a context object.Behaviour changes worth a reviewer's eye
A healthy server exposing no tools is no longer a failure. Reading the failures channel removes a false positive the id-diff could not avoid: such a server contributed no ids, so it was reported as failed and
GET /list-mcp-toolsanswered503 The MCP server could not be reached to list its tools. It now answers200with an empty list. A server that genuinely errors still setsloadFailedand still 503s. The frontend counterpart is ForestAdmin/forestadmin#9892, which drops the matching "empty list means the load failed" inference; without it this change never reaches the user.A recovered OAuth load now says so. When a cached token is rejected and the forced refresh succeeds, the provider has already emitted its rejection at
Error, so at the default level the run read as a pure failure with nothing saying it continued. It now states the recovery atInfo. Observed on the rig before it was fixed — the retry wasDebug-only, so twoErrorlines were all an operator saw.Pretty output no longer prints absent keys.
formatContextrenderedJSON.stringify(undefined)as the literalundefined, so every bridged line trailedcause=undefined— most MCP errors don't chain, since@langchain/mcp-adaptersinterpolates the nested text into its own message. It now skips undefined values, which also cleans upbase-step-executor's existing lines.Why the cause is flattened rather than passed through
An
Error's own properties are non-enumerable, so handing it to the executor's logger as the context object drops the cause from the line that gets emitted. All three consumers agree:console-loggerspreads it intoJSON.stringify,pretty-loggeriteratesObject.entries, and the agent-embeddedformatLogtakes its message-only branch whenObject.keys(context).length === 0. The bridge flattens to{ error, cause, stack }— the shapebase-step-executor.ts:94already uses — and carries the cause chain, so a wrappedfetch failedstill names theECONNREFUSEDunderneath it.The bridge also guards its own body: ai-proxy logs from inside its per-server
catchbefore recording the failure it caught (mcp-client.ts:113,failures.pushat:114), so a host logger that threw would reject the wholePromise.all— discarding tools from healthy servers and skipping the OAuth reauth pause.ExecutorOptions.loggeris host-supplied, andagent/src/embedded-workflow-executor.ts:12already states that invariant for the neighbouring edge.Scope
The
agent-nodejshalf of PRD-876. Theforestadmin-serverhalf (make-ai-router-service.ts, thePOST /api/ai-proxy/ai-querypath — AC#1–#3) ships separately; neither PR blocks the other. No ai-proxy file is touched: the timeout, classification andfailureschannel stay as PRD-863 left them.AC#4 is implemented as reworded in the ticket comment — the existing executor log line carries the failure
kindand cause, sourced fromloadRemoteToolsWithFailuresrather than inferred from absent tools — and the logger threading it originally called for, which is what makes ai-proxy's other diagnostics (per-server load errors with stacks,Unsupported integration:, connection-cleanup failures) visible at all.Tests
to-ai-proxy-logger.test.ts— level/message pass-through for all four levels, cause flattening, the cause chain, the cause survivingcreateConsoleLogger()at its default level (business rule 3), a throwing host logger, stackless / empty-message / non-Error/nullcauses, independence across calls. 100% coverage.remote-tool-fetcher.test.ts— the log line names server, kind and cause;loadFailedfollows the reported failures, including when the post-refresh retry fails for a non-auth reason; a healthy server exposing no tools is not a failure; the recovery line fires only when the retry actually recovered.AiClient;build-workflow-executorasserts it reaching both adapters;runnerand the integration suite cover the dispatch path.Verified on a live rig
Driven through
GET /list-mcp-tools, which runs the whole changed path (fetch→loadRemoteToolsWithFailures→errorOnPartialLoadFailure→ ai-proxy'sMcpClient), against a standalone executor and the frontend:Error loading tools for …carryingerror/stack, itsFailed to load tools from 1/1 …summary, and the executor's line withkind:"connection".kind:"auth", so a rejected credential and an unreachable host are one grep apart. This is the support-deflection claim, and it holds.Loaded 10 tools from MCP server "data-gouv" in 925msatDebug, absent atInfo, while the failure lines stay visible there (business rules 2 and 3).origin/main's fetcher answered503and loggedfailedConfigNamesfor a reachable zero-tool server; this branch answers200 {"tools":[]}and logs nothing atError.Notes for the reviewer
runId/stepId:RemoteToolFetcherand theAiClientare both built once per executor with the process-level logger. Concurrent runs against the same connector are still distinguished only by server name and timestamp. Separate ticket.addWorkflowExecutor: the embedded-agent rendering of the same failure, and a host logger that throws passed viaaddWorkflowExecutor({ logger }). The latter is covered by unit test, not end to end.CLAUDE.mdgained the Logging clause and the failures-channel invariant, per that file's own keep-current instruction; the README gained a "When an MCP step fails to load its tools" section.fixes PRD-876
Note
Surface MCP tool load failure causes in workflow executor logs
RemoteToolFetcherfromloadRemoteToolstoloadRemoteToolsWithFailures, so failures are determined from provider-reported data rather than inferred from an absent tool list; an empty tool list from a healthy server is no longer treated as a failure.errorOnPartialLoadFailure().to-ai-proxy-loggerbridge that flattensErrorobjects into readable fields and guards against host logger throws before delegating to the executor logger.AiClientAdapterandServerAiAdapterso ai-proxy internal logs (including tool load diagnostics) are forwarded by default.OAuthReauthRequiredError; a successful retry logs an Info-level recovery message.Macroscope summarized 1c2aabc.