Skip to content

fix(workflow-executor): surface the cause when MCP tool loading fails - #1806

Open
hercemer42 wants to merge 10 commits into
mainfrom
feature/prd-876-ai-proxy-diagnostics-never-reach-production-no-logger
Open

fix(workflow-executor): surface the cause when MCP tool loading fails#1806
hercemer42 wants to merge 10 commits into
mainfrom
feature/prd-876-ai-proxy-diagnostics-never-reach-production-no-logger

Conversation

@hercemer42

@hercemer42 hercemer42 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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. errorOnPartialLoadFailure inferred 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-only loadRemoteTools and dropped them. It now reads the failures channel:

{"level":"Error","message":"MCP servers failed to load tools","requestedMcpServerId":"39","mcpServerName":"acme-crm",
 "failures":[{"server":"acme-crm","kind":"connection","error":"connect ECONNREFUSED 10.0.4.12:8080"}]}

2. ai-proxy's own diagnostics went nowhere. AiClient holds an optional host logger and no-ops every emit when none is passed; workflow-executor never passed one. Both construction sites now receive the logger the executor already builds, through to-ai-proxy-logger.ts, which bridges the two logger contracts — ai-proxy's third parameter is an Error, 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-tools answered 503 The MCP server could not be reached to list its tools. It now answers 200 with an empty list. A server that genuinely errors still sets loadFailed and 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 at Info. Observed on the rig before it was fixed — the retry was Debug-only, so two Error lines were all an operator saw.

Pretty output no longer prints absent keys. formatContext rendered JSON.stringify(undefined) as the literal undefined, so every bridged line trailed cause=undefined — most MCP errors don't chain, since @langchain/mcp-adapters interpolates the nested text into its own message. It now skips undefined values, which also cleans up base-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-logger spreads it into JSON.stringify, pretty-logger iterates Object.entries, and the agent-embedded formatLog takes its message-only branch when Object.keys(context).length === 0. The bridge flattens to { error, cause, stack } — the shape base-step-executor.ts:94 already uses — and carries the cause chain, so a wrapped fetch failed still names the ECONNREFUSED underneath it.

The bridge also guards its own body: ai-proxy logs from inside its per-server catch before recording the failure it caught (mcp-client.ts:113, failures.push at :114), so a host logger that threw would reject the whole Promise.all — discarding tools from healthy servers and skipping the OAuth reauth pause. ExecutorOptions.logger is host-supplied, and agent/src/embedded-workflow-executor.ts:12 already states that invariant for the neighbouring edge.

Scope

The agent-nodejs half of PRD-876. The forestadmin-server half (make-ai-router-service.ts, the POST /api/ai-proxy/ai-query path — AC#1–#3) ships separately; neither PR blocks the other. No ai-proxy file is touched: the timeout, classification and failures channel stay as PRD-863 left them.

AC#4 is implemented as reworded in the ticket comment — the existing executor log line carries the failure kind and cause, sourced from loadRemoteToolsWithFailures rather 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 surviving createConsoleLogger() at its default level (business rule 3), a throwing host logger, stackless / empty-message / non-Error / null causes, independence across calls. 100% coverage.
  • remote-tool-fetcher.test.ts — the log line names server, kind and cause; loadFailed follows 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.
  • Both AI adapter suites assert the logger reaching AiClient; build-workflow-executor asserts it reaching both adapters; runner and the integration suite cover the dispatch path.
  • The 8 changed test files run 225 tests green. ai-proxy's own suite runs unchanged and green (38 suites / 429 tests) — AC#5.

Verified on a live rig

Driven through GET /list-mcp-tools, which runs the whole changed path (fetchloadRemoteToolsWithFailureserrorOnPartialLoadFailure → ai-proxy's McpClient), against a standalone executor and the frontend:

  • Unreachable server → 503, with ai-proxy's per-server Error loading tools for … carrying error/stack, its Failed to load tools from 1/1 … summary, and the executor's line with kind:"connection".
  • 401 from the server → same shape with kind:"auth", so a rejected credential and an unreachable host are one grep apart. This is the support-deflection claim, and it holds.
  • Healthy serverLoaded 10 tools from MCP server "data-gouv" in 925ms at Debug, absent at Info, while the failure lines stay visible there (business rules 2 and 3).
  • A/B on the 503→200 changeorigin/main's fetcher answered 503 and logged failedConfigNames for a reachable zero-tool server; this branch answers 200 {"tools":[]} and logs nothing at Error.
  • OAuth retry, against a live OAuth MCP server with its access revoked mid-session: attempt 1 logs the auth failure, attempt 2 succeeds, 200 with 3 tools.

Notes for the reviewer

  • Neither log line carries runId / stepId: RemoteToolFetcher and the AiClient are 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.
  • Two manual checks are still open, both needing a harness that doesn't exist yet — the example app never calls addWorkflowExecutor: the embedded-agent rendering of the same failure, and a host logger that throws passed via addWorkflowExecutor({ logger }). The latter is covered by unit test, not end to end.
  • The package CLAUDE.md gained 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

  • Switches RemoteToolFetcher from loadRemoteTools to loadRemoteToolsWithFailures, 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.
  • Logs structured failure details (server, kind, error message) via errorOnPartialLoadFailure().
  • Adds a new to-ai-proxy-logger bridge that flattens Error objects into readable fields and guards against host logger throws before delegating to the executor logger.
  • Wires the executor logger into AiClientAdapter and ServerAiAdapter so ai-proxy internal logs (including tool load diagnostics) are forwarded by default.
  • Behavioral Change: OAuth servers with a rejected cached token now trigger a forced token refresh and retry; persistent auth failures throw OAuthReauthRequiredError; a successful retry logs an Info-level recovery message.

Macroscope summarized 1c2aabc.

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>
@linear-code

linear-code Bot commented Aug 5, 2026

Copy link
Copy Markdown

PRD-876

@qltysh

qltysh Bot commented Aug 5, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (6)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/build-workflow-executor.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/adapters/pretty-logger.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/remote-tool-fetcher.ts100.0%
Coverage rating: B Coverage rating: B
packages/workflow-executor/src/adapters/server-ai-adapter.ts100.0%
Coverage rating: C Coverage rating: C
packages/workflow-executor/src/adapters/ai-client-adapter.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/adapters/to-ai-proxy-logger.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@hercemer42 hercemer42 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/workflow-executor/src/adapters/to-ai-proxy-logger.ts
Comment thread packages/workflow-executor/test/adapters/server-ai-adapter.test.ts Outdated
Comment thread packages/workflow-executor/src/build-workflow-executor.ts
Comment thread packages/workflow-executor/src/adapters/to-ai-proxy-logger.ts Outdated
Comment thread packages/workflow-executor/src/build-workflow-executor.ts
Comment thread packages/workflow-executor/CLAUDE.md Outdated
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>
@hercemer42

Copy link
Copy Markdown
Contributor Author

Claude Opus 5 (claude-opus-5[1m]): review resolution — 3 of 6 findings applied in b7b165c, 3 declined with reasoning in the threads.

Applied

  • A throwing host logger could abort a whole tool load. The bridge now guards its body, so logging cannot become control flow at the boundary where a host callback enters ai-proxy. New test covers the cause and no-cause paths.
  • The cause chain stopped at the outer message. The payload carries cause, read the way base-step-executor.ts:94 does. New test wraps fetch failed over ECONNREFUSED — the unreachable-server-vs-rejected-token distinction this ticket is buying.
  • A test asserted a fallback warning the per-call client cannot emit. Reworded as an explicit wiring check with a neutral message and a comment saying so.

Declined

  • Drop logger from the getModel client (server-ai-adapter.ts:41). Inert today, but removing it leaves one AiClient in the class with a logger and one without — the asymmetry that becomes a silent gap the moment that client is reused.
  • Recovered-OAuth Error noise. Real and confirmed, but a rejected token is a genuine tool-load failure that business rule 3 wants at Error, and neither fix is reachable without touching mcp-client.ts (business rule 4 forbids it) or the retry state that never reaches this logger.
  • Missing runId/stepId, and the stale ecosystem-pack line. Both accurate, neither fixable here: the first needs a design change or the remote-tool-fetcher.ts:105 follow-up, the second lives in the claudine repo.

Checks after the fix: 4 suites / 78 tests green, to-ai-proxy-logger.ts still 100% covered, eslint + prettier + tsc clean.

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>
@hercemer42 hercemer42 changed the title fix(workflow-executor): pass the executor logger to ai-proxy's AiClient fix(workflow-executor): surface the cause when MCP tool loading fails Aug 6, 2026
@qltysh

qltysh Bot commented Aug 6, 2026

Copy link
Copy Markdown

All good ✅

@hercemer42

hercemer42 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Claude Opus 5 (claude-opus-5[1m]): review closed out. All six threads answered and resolved; CI green on 16f90896 — the whole rollup, after re-running LLM Integration Tests (ai-proxy), which had failed on an upstream OpenAI 500 for gpt-3.5-turbo in its live-API tool-support test (identical failure on all three commits of this branch, including the first; no ai-proxy file is touched here). Supersedes my earlier resolution comment, which predates the widened scope.

Fixed

  • Throwing host logger could abort a whole tool load → the bridge guards its body (b7b165c2).
  • Cause chain stopped at the outer message → the payload carries cause, read the way base-step-executor.ts:94 does (b7b165c2).
  • A test asserted a message the per-call getModel client cannot emit → reworded as an explicit wiring check (b7b165c2).
  • Cause and run context on separate lines → half of this is now moot: remote-tool-fetcher.ts reads the failures channel, so MCP servers failed to load tools carries { server, kind, error } instead of an id-diff (16f90896).

Declined, reasoning in the threads

  • Dropping logger from the getModel client: inert today, but removing it leaves one AiClient in the class with a logger and one without.
  • Recovered-OAuth Error noise: a rejected token is a genuine tool-load failure that business rule 3 wants at Error, and neither fix is reachable without editing mcp-client.ts (business rule 4) or the retry state.
  • Stale ecosystem-pack line: accurate, but it lives in the claudine repo.

Still open, deliberately

  • Neither log line carries runId / stepIdRemoteToolFetcher and the AiClient are both built once per executor. Concurrent runs against one connector are distinguished only by server name and timestamp. Own ticket.

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 — ai-service.ts:773 merges two disjoint queries on aiMcpConfigs (isForestConnector false/true), each entry's id is its own row id, and a name collision overwrites rather than duplicates. No guard added.

hercemer42 and others added 2 commits August 6, 2026 15:16
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>
@hercemer42

Copy link
Copy Markdown
Contributor Author

Claude Opus 5 (claude-opus-5[1m]): qlty's Function with many returns (count = 4): fetchOAuthTools is fixed in b1a1fe9 — the two tail exits differed only in which attempt produced the tools, so the rejected-token case is named once and the retry's result read once. Three returns now; qlty reports All good ✅.

Worth recording that it wasn't introduced here: origin/main has the same four returns in that function. It surfaced as a new issue because this PR edited the body, and the analyser attributes findings to changed lines. Fixed rather than argued, since it's two lines either way.

hercemer42 and others added 2 commits August 6, 2026 18:16
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>
@hercemer42

Copy link
Copy Markdown
Contributor Author

Claude Opus 5 (claude-opus-5[1m]): manual testing on a live rig found one gap, fixed in db5ba65.

Ran the executor from this branch (pm2 agent-stack, tsx watch cli.ts --pretty) against the OAuth sim, with a credential deposited through POST /mcp-oauth-credentials and the server's access tokens revoked mid-flight. The run recovers — HTTP 200, 3 tools — but the log read as a pure failure:

error Error loading tools for sim-mcp b  error="Authentication failed for HTTP server…"
error Failed to load tools from 1/1 MCP server(s): sim-mcp b: Authentication failed…
debug Loaded 3 tools from MCP server "sim-mcp b" in 26ms      ← the recovery, invisible at Info

The retry that fixes it only logged at Debug, so at the production default an operator saw the two Error lines and nothing saying it continued. RemoteToolFetcher now states the recovery at Info after a forced refresh succeeds:

info  MCP tools loaded after refreshing the credential requestedMcpServerId="34" mcpServerName="sim-mcp b"

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 Error), but it is no longer misleading.

Comment thread packages/workflow-executor/src/remote-tool-fetcher.ts
hercemer42 and others added 2 commits August 7, 2026 18:33
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>
@hercemer42
hercemer42 marked this pull request as ready for review August 7, 2026 16:45

@PMerlet PMerlet left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
error: failure.error.message,
error: extractErrorMessage(failure.error),
cause: causeMessage(failure.error),


logger(level, message, {
error: extractErrorMessage(error),
cause: extractErrorMessage(cause),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
cause: extractErrorMessage(cause),
cause: cause == null ? undefined : extractErrorMessage(cause),

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants