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 8 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 |
| const reloadWithFreshAuth = async (): Promise<RemoteTool[]> => { | ||
| const attempt = await attemptLoad(true); | ||
| if (attempt.hasAuthFailure) throw new OAuthReauthRequiredError(mcpServerId); | ||
| this.errorOnPartialLoadFailure(scoped, attempt.tools, mcpServerId, mcpServerName); | ||
| if (hasAuthFailure(attempt.failures)) throw new OAuthReauthRequiredError(mcpServerId); | ||
| this.errorOnPartialLoadFailure(attempt.failures, mcpServerId, mcpServerName); | ||
| // The rejected credential was already logged at Error. Without this the default level shows | ||
| // the failure and never says it recovered. | ||
| this.logger('Info', 'MCP tools loaded after refreshing the credential', { | ||
| requestedMcpServerId: mcpServerId, | ||
| mcpServerName, | ||
| }); | ||
|
|
||
| return attempt.tools; | ||
| }; |
There was a problem hiding this comment.
🟠 High src/remote-tool-fetcher.ts:113
fetchOAuthTools discards non-auth failures from the forced-refresh retry: when the cached token is rejected and the retry then fails with a connection error, loadFailed is set to undefined, so the caller gets a 200 with an empty tool list instead of the intended 503. The retry's failure is logged but never propagated as loadFailed, and the success log "tools loaded after refreshing" is emitted even though the retry produced no tools. Consider preserving the retry's failure state by setting loadFailed from reloadWithFreshAuth instead of hardcoding undefined.
const reloadWithFreshAuth = async (): Promise<RemoteTool[]> => {
const attempt = await attemptLoad(true);
if (hasAuthFailure(attempt.failures)) throw new OAuthReauthRequiredError(mcpServerId);
- this.errorOnPartialLoadFailure(attempt.failures, mcpServerId, mcpServerName);
+ if (attempt.failures.length > 0) {
+ this.errorOnPartialLoadFailure(attempt.failures, mcpServerId, mcpServerName);
+ throw new Error(`MCP server failed to load after credential refresh (mcpServerId="${mcpServerId}")`);
+ }
// The rejected credential was already logged at Error. Without this the default level shows
// the failure and never says it recovered.
this.logger('Info', 'MCP tools loaded after refreshing the credential', {
requestedMcpServerId: mcpServerId,
mcpServerName,
});
return attempt.tools;
};🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/remote-tool-fetcher.ts around lines 113-125:
`fetchOAuthTools` discards non-auth failures from the forced-refresh retry: when the cached token is rejected and the retry then fails with a connection error, `loadFailed` is set to `undefined`, so the caller gets a 200 with an empty tool list instead of the intended 503. The retry's failure is logged but never propagated as `loadFailed`, and the success log "tools loaded after refreshing" is emitted even though the retry produced no tools. Consider preserving the retry's failure state by setting `loadFailed` from `reloadWithFreshAuth` instead of hardcoding `undefined`.

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 change worth a reviewer's eye
Reading the failures channel removes a false positive the id-diff could not avoid: a healthy server that exposes no tools 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.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; a healthy server exposing no tools is not a failure.AiClient;build-workflow-executorasserts it reaching both adapters;runnerand the integration suite cover the dispatch path.Notes for the reviewer
Loaded N tools … in Ymsline isDebug, so it needsLOG_LEVEL=debug; failure lines are visible at the defaultInfo(business rules 2 and 3).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.createConsoleLogger().CLAUDE.mdLogging bullet gained one clause, per that file's own keep-current instruction.fixes PRD-876