Skip to content

fix: retain MCP catalogs per connection; prepare 0.4.5 - #128

Merged
byapparov merged 5 commits into
mainfrom
fix/mcp-discovery-failure
Oct 6, 2026
Merged

byapparov merged 5 commits into
mainfrom
fix/mcp-discovery-failure

Conversation

@byapparov

@byapparov byapparov commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Closes #127, Closes #126

Intent

The CLI discards successful startup MCP discovery and lists tools again for the startup catalog and every model turn. A later discovery failure can silently remove persistence tools while the run continues successfully. Retain one catalog per connection, refresh it on tool-change notifications, and prepare CLI 0.4.5 for publication.

Expected Impact on Users

Healthy review runs keep their discovered tools without repeated network discovery. A failed notified refresh or closed connection produces an actionable error before the next model request. Release operators get version validation before build and a longer npm propagation window.

Expected Outcomes

  • A healthy three-turn review performs one discovery request and persists a fixture finding.
  • A successful notification updates the catalog; failed or hung refreshes stop the next turn with one terminal invocation error.
  • Reconnection and replacement use the new connection's definitions and bindings; an old response cannot overwrite them.
  • Tag/package mismatch and empty release versions fail before build; registry smoke verifies the installed binary version.

Implementation

  • Store startup definitions in a catalog keyed by the MCP client. Runtime tools and startup catalog entries read the same definitions. Remove repeated discovery and the separate discovery-failure set. One shared health derivation also gates optional prompts/resources after refresh failures and connection closure; the failure message explicitly instructs reconnecting.
  • Serialize notified refreshes, atomically publish successful results, and retain the last good definitions on failure. Derive failed health from the catalog error and block reads until a successful notification or reconnection. Preserve the original failure as the error cause.
  • Remove a disposed connection's catalog before closing it. Treat unexpected closure as a failure; discard late refreshes from replaced connections. Failed replacement cannot revive an old client.
  • Keep configured tool-call deadlines and the SDK's bounded default for notified refreshes; preserve initial connection discovery behavior.
  • Add real HTTP MCP/provider SSE fixtures, exercised against source and the compiled 0.4.5 binary, plus real SDK lifecycle cases.
  • Bump package and lockfile metadata to 0.4.5. Harden release gates, frozen installation, ten registry install attempts, binary/version validation, a separate smoke job without publication permissions, and immutable setup-node pinning.
  • Include build version/native-build environment variables in Turbo cache keys. Document exact-commit publication and executor promotion gates.

Upstream comparison

Checked pinned current source on 2026-10-06. Latest OpenCode MCP lifecycle retains discovered definitions, refreshes on tool-list changes, and removes definitions with disconnected clients. Pi MCP runtime likewise retains definitions and refreshes on notifications. Codex client catalog stores definitions and replaces them after successful explicit refresh. This implementation follows that catalog lifetime. Our intentional policy difference is to expose failed refreshes and stop subsequent catalog reads, following aictrl's fail-fast requirement.

Scope Caveat

The original discovery exception on execution 06c34a3f was not captured; its initiating trigger remains unproven. Silent tool loss is independently reproduced in unchanged CLI 0.4.4. Initially unavailable optional servers keep their existing behavior. This PR prepares 0.4.5; it does not publish packages or deploy an executor.

Test Plan

  • Red proof: old implementation makes five discovery requests across three turns and fails the retained-catalog cases.
  • Healthy cached turns, notified update/failure/timeout, persisted finding, and one terminal error against source and compiled 0.4.5.
  • Notification serialization, valid empty catalogs, recovery, replacement, late old responses, unexpected closure and reconnect using the real SDK.
  • Release shell gates, provider/cancellation/idle regressions, workspace tests, native build and typecheck.
  • GitHub CI and automated review on the new exact PR head.

Verification

  • Final workspace suite: 1,575 passed, 7 skipped, 0 failed across 132 files and 3 tasks.
  • Focused regression suite: 86 passed; isolated MCP modules: 23 passed.
  • Compiled 0.4.5 headless fixtures: 6 passed; native build passed across 3 tasks.
  • Workspace typecheck passed across 6 tasks; formatting and diff checks passed.
  • Earlier release workflow fixtures and frozen install passed. Both earlier review rounds were handled and persisted with verified read-back. The catalog-cache review has seven observations: two necessary fixes (shared optional-reader health and reconnect advice), with five intentional-policy/optional-hardening observations left unchanged to preserve minimal scope. Exact head 263afabd7687f5d3c3db8820941b5b6dde196f2a: CI verify and both CodeQL analyses passed. New-head automated review was explicitly skipped because the template's per-PR maximum is two executions (confirmed in production logs at 2026-10-06 07:15:48 UTC). The supplied catalog-cache review was subsequently delivered on this head. Follow-up fb096d9355181135cf0137cac511e10c3d88eb65 has the two review fixes. Its CI verify passed native build (3 tasks), typecheck (6 tasks) and workspace tests (1,575 passed, 7 skipped, 0 failed); both CodeQL analyses and the aggregate also passed. The supplied seven-finding review was responded to and all seven exact-ID verdicts were written and confirmed by read-back (0 failed, 0 unrecorded). Latest-push automatic re-review was explicitly skipped at the same cap; fresh re-review remains a release gate.

Risks and Rollout

A failed notified refresh blocks subsequent catalog reads until notification recovery or reconnection. This avoids silently sending fewer tools, while preserving definitions and connection cleanup. Generic CLI idle detection remains opt-in; application PR #5892 enables the existing five-minute stream guard. After approved merge, require green npm publication and independent installation/version verification before updating the executor Docker pin, then validate a sandbox review before production promotion.

Comment thread packages/cli/src/mcp/index.ts Outdated
// A catalog from startup does not guarantee tools on this turn. Stop
// before submitting a reduced toolset. Retain the client for disposal
// and a subsequent retry instead of silently deleting it (#127).
throw new Error(`MCP tool discovery failed for server "${clientName}". Check the MCP server and retry.`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MCP discovery throw hits all tools() callers, not just runs.

Suggested change
throw new Error(`MCP tool discovery failed for server "${clientName}". Check the MCP server and retry.`)
Verify every caller of MCP.tools()/MCP.toolEntries() handles the new rejection: either catch at the run-loop call site and rethrow as session_error (scoping the abort to headless runs), or wrap the listing/catalog/interactive call sites (cli/cmd/mcp.ts, cli/cmd/tool-catalog.ts, session/prompt.ts) with error handling that surfaces the message instead of crashing.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, packages/cli/src/mcp/index.ts:623-625):

Problem: MCP discovery throw hits all tools() callers, not just runs
Detail: discoverTools() now throws on any single server's listTools failure, so Promise.all rejects and the whole MCP.tools()/MCP.toolEntries() call fails for ALL servers, where it previously resolved with partial results (failed server skipped). The stated intent (#127) covers headless runs, and the new e2e test proves the `run` path converts this into session_error — but the module's importers also include non-run flows (cli/cmd/mcp.ts, cli/cmd/tool-catalog.ts, session/prompt.ts interactive loop, command/index.ts), and the usual co-change partners (src/cli/cmd/run.ts, src/cli/cmd/tool-catalog.ts, test/tool-catalog.test.ts) are NOT adapted in this PR. Any of those callers without a try/catch now crashes with an unhandled error where it previously degraded gracefully to partial output.
Suggested fix: Verify every caller of MCP.tools()/MCP.toolEntries() handles the new rejection: either catch at the run-loop call site and rethrow as session_error (scoping the abort to headless runs), or wrap the listing/catalog/interactive call sites (cli/cmd/mcp.ts, cli/cmd/tool-catalog.ts, session/prompt.ts) with error handling that surfaces the message instead of crashing.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

discoverTools() now throws on any single server's listTools failure, so Promise.all rejects and the whole MCP.tools()/MCP.toolEntries() call fails for ALL servers, where it previously resolved with partial results (failed server skipped). The stated intent (#127) covers headless runs, and the new e2e test proves the run path converts this into session_error — but the module's importers also include non-run flows (cli/cmd/mcp.ts, cli/cmd/tool-catalog.ts, session/prompt.ts interactive loop, command/index.ts), and the usual co-change partners (src/cli/cmd/run.ts, src/cli/cmd/tool-catalog.ts, test/tool-catalog.test.ts) are NOT adapted in this PR. Any of those callers without a try/catch now crashes with an unhandled error where it previously degraded gracefully to partial output.

        error: error instanceof Error ? error.message : String(error),
      })
      // A catalog from startup does not guarantee tools on this turn. Stop
      // before submitting a reduced toolset. Retain the client for disposal
      // and a subsequent retry instead of silently deleting it (#127).
      throw new Error(`MCP tool discovery failed for server "${clientName}". Check the MCP server and retry.`)
    })
  }

  export async function tools() {

Comment thread bun.lock
"packages/util": {
"name": "@aictrl/util",
"version": "1.2.16",
"version": "1.2.17",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Lock bumps @aictrl/util to 1.2.17 with no manifest change.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, bun.lock:213):

Problem: Lock bumps @aictrl/util to 1.2.17 with no manifest change
Detail: bun.lock bumps the @aictrl/util workspace entry 1.2.16→1.2.17, but no packages/util/package.json change appears in this PR. If that manifest still reads 1.2.16 on this branch, the newly-added `bun install --frozen-lockfile` in publish.yml (and RELEASING.md step 1) fails on manifest/lock mismatch and blocks the 0.4.5 release outright. Most likely this is benign catch-up (the cli lock entry jumps 0.3.3→0.4.5, so the lock was stale and util's manifest was presumably already 1.2.17 from an earlier PR) — but confirm the manifest reads 1.2.17 before tagging, since the diff alone cannot prove it.
Suggested fix: Verify packages/util/package.json on this branch reads "version": "1.2.17". If it does not, either bump it in this PR or revert the bun.lock util hunk so lock and manifest agree under --frozen-lockfile.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

bun.lock bumps the @aictrl/util workspace entry 1.2.16→1.2.17, but no packages/util/package.json change appears in this PR. If that manifest still reads 1.2.16 on this branch, the newly-added bun install --frozen-lockfile in publish.yml (and RELEASING.md step 1) fails on manifest/lock mismatch and blocks the 0.4.5 release outright. Most likely this is benign catch-up (the cli lock entry jumps 0.3.3→0.4.5, so the lock was stale and util's manifest was presumably already 1.2.17 from an earlier PR) — but confirm the manifest reads 1.2.17 before tagging, since the diff alone cannot prove it.

    },
    "packages/util": {
      "name": "@aictrl/util",
      "version": "1.2.17",
      "dependencies": {
        "zod": "catalog:",
      },

Comment thread packages/cli/src/mcp/index.ts Outdated
})
const mcpConfig = config[clientName]
const timeout =
(isMcpConfigured(mcpConfig) ? mcpConfig.timeout : undefined) ?? defaultTimeout ?? DEFAULT_TIMEOUT

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Discovery and tool-call timeout fallbacks diverge.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, packages/cli/src/mcp/index.ts:643-651):

Problem: Discovery and tool-call timeout fallbacks diverge
Detail: The same conceptual value is now computed three divergent ways: tools() pre-loop uses (per-server timeout) ?? defaultTimeout ?? DEFAULT_TIMEOUT; the tools() result loop re-derives entry?.timeout ?? defaultTimeout (no DEFAULT_TIMEOUT fallback) for the converted tools; toolEntries() inlines a third variant with cfg.experimental?.mcp_timeout. When neither a per-server timeout nor defaultTimeout is set, discovery is bounded by DEFAULT_TIMEOUT but each tool call gets timeout=undefined (SDK default / unbounded), so a hung server still stalls tools/call. The duplication also invites future drift between the three sites.
Suggested fix: Extract one helper, e.g. mcpTimeout(cfg, clientName) { const c = cfg.mcp?.[clientName]; return (isMcpConfigured(c) ? c.timeout : undefined) ?? cfg.experimental?.mcp_timeout ?? DEFAULT_TIMEOUT }, and use it at all three sites — including the per-tool timeout passed to convertMcpTool — so discovery and tool calls share the same bound.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The same conceptual value is now computed three divergent ways: tools() pre-loop uses (per-server timeout) ?? defaultTimeout ?? DEFAULT_TIMEOUT; the tools() result loop re-derives entry?.timeout ?? defaultTimeout (no DEFAULT_TIMEOUT fallback) for the converted tools; toolEntries() inlines a third variant with cfg.experimental?.mcp_timeout. When neither a per-server timeout nor defaultTimeout is set, discovery is bounded by DEFAULT_TIMEOUT but each tool call gets timeout=undefined (SDK default / unbounded), so a hung server still stalls tools/call. The duplication also invites future drift between the three sites.

        const mcpConfig = config[clientName]
        const timeout =
          (isMcpConfigured(mcpConfig) ? mcpConfig.timeout : undefined) ?? defaultTimeout ?? DEFAULT_TIMEOUT
        const toolsResult = await discoverTools(clientName, client, timeout)
        return { clientName, client, toolsResult }
      }),
    )

    for (const { clientName, client, toolsResult } of toolsResults) {
      const mcpConfig = config[clientName]
      const entry = isMcpConfigured(mcpConfig) ? mcpConfig : undefined
      const timeout = entry?.timeout ?? defaultTimeout

Comment thread packages/cli/src/mcp/index.ts Outdated
const mcpConfig = config[clientName]
const timeout =
(isMcpConfigured(mcpConfig) ? mcpConfig.timeout : undefined) ?? defaultTimeout ?? DEFAULT_TIMEOUT
const toolsResult = await discoverTools(clientName, client, timeout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Failed discovery leaves server status "connected" in state.

Suggested change
const toolsResult = await discoverTools(clientName, client, timeout)
Before throwing in discoverTools (or at the tools()/toolEntries() call sites), still write s.status[clientName] = { status: "failed", error: message } while keeping the client in s.clients for disposal and retry.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, packages/cli/src/mcp/index.ts:644-645):

Problem: Failed discovery leaves server status "connected" in state
Detail: The removed catch block set s.status[clientName] = { status: "failed", error } and deleted the client from s.clients; the replacement only logs and throws. Retaining the client for disposal/retry is deliberate (#127 comment), but the persisted status is never updated, so after a discovery failure the state still reports the broken server as connected/healthy and records no failure at all — any consumer of s.status (e.g. `aictrl mcp` status display) is misled.
Suggested fix: Before throwing in discoverTools (or at the tools()/toolEntries() call sites), still write s.status[clientName] = { status: "failed", error: message } while keeping the client in s.clients for disposal and retry.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The removed catch block set s.status[clientName] = { status: "failed", error } and deleted the client from s.clients; the replacement only logs and throws. Retaining the client for disposal/retry is deliberate (#127 comment), but the persisted status is never updated, so after a discovery failure the state still reports the broken server as connected/healthy and records no failure at all — any consumer of s.status (e.g. aictrl mcp status display) is misled.

    const toolsResults = await Promise.all(
      connectedClients.map(async ([clientName, client]) => {
        const mcpConfig = config[clientName]
        const timeout =
          (isMcpConfigured(mcpConfig) ? mcpConfig.timeout : undefined) ?? defaultTimeout ?? DEFAULT_TIMEOUT
        const toolsResult = await discoverTools(clientName, client, timeout)
        return { clientName, client, toolsResult }
      }),
    )

    for (const { clientName, client, toolsResult } of toolsResults) {

Comment thread .github/workflows/publish.yml Outdated
for attempt in {1..10}; do
if npm install "@aictrl/cli@${AICTRL_VERSION}" 2>&1 | tee install.log; then
echo "::notice::npm install @aictrl/cli@${AICTRL_VERSION} succeeded on attempt ${attempt}"
INSTALLED_VERSION=$(./node_modules/.bin/aictrl --version)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Missing CLI binary aborts smoke step without diagnostics.

Suggested change
INSTALLED_VERSION=$(./node_modules/.bin/aictrl --version)
INSTALLED_VERSION=$(./node_modules/.bin/aictrl --version || echo "binary-missing") so the existing != comparison emits the proper ::error message instead of a bare exit code.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, .github/workflows/publish.yml:193-194):

Problem: Missing CLI binary aborts smoke step without diagnostics
Detail: INSTALLED_VERSION=$(./node_modules/.bin/aictrl --version) runs under `set -euo pipefail` inside the success branch. If npm install exits 0 but the bin shim is missing or non-executable, the command substitution's non-zero status terminates the step with a bare exit code — no ::error annotation and no retry, unlike every other failure path in the loop.
Suggested fix: INSTALLED_VERSION=$(./node_modules/.bin/aictrl --version || echo "binary-missing") so the existing != comparison emits the proper ::error message instead of a bare exit code.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

INSTALLED_VERSION=$(./node_modules/.bin/aictrl --version) runs under set -euo pipefail inside the success branch. If npm install exits 0 but the bin shim is missing or non-executable, the command substitution's non-zero status terminates the step with a bare exit code — no ::error annotation and no retry, unlike every other failure path in the loop.

          # v0.4.4 took longer than 2.5 minutes to become installable (#126).
          # Ten attempts allow 8.5 minutes of propagation backoff, bounded by
          # the step timeout even if npm itself stalls.
          for attempt in {1..10}; do
            if npm install "@aictrl/cli@${AICTRL_VERSION}" 2>&1 | tee install.log; then
              echo "::notice::npm install @aictrl/cli@${AICTRL_VERSION} succeeded on attempt ${attempt}"
              INSTALLED_VERSION=$(./node_modules/.bin/aictrl --version)
              if [ "$INSTALLED_VERSION" != "$AICTRL_VERSION" ]; then
                echo "::error::Installed CLI reports $INSTALLED_VERSION, expected $AICTRL_VERSION"
                exit 1
              fi

Comment thread .github/workflows/publish.yml Outdated
for attempt in {1..10}; do
if npm install "@aictrl/cli@${AICTRL_VERSION}" 2>&1 | tee install.log; then
echo "::notice::npm install @aictrl/cli@${AICTRL_VERSION} succeeded on attempt ${attempt}"
INSTALLED_VERSION=$(./node_modules/.bin/aictrl --version)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Smoke step executes registry-fetched CLI in publish job.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, .github/workflows/publish.yml:193-194):

Problem: Smoke step executes registry-fetched CLI in publish job
Detail: The new version check executes code just fetched from the public npm registry inside the release job, which holds repo and npm publish credentials. The incremental risk over the pre-existing install is small (npm install already runs dependency lifecycle scripts, and the artifact was built from this same tagged commit), but the explicit execution sink is new — worth hardening given the job's privileges.
Suggested fix: Use `npm install --ignore-scripts` for the install attempts so only the explicit `--version` invocation runs foreign code, and/or run the smoke check in a separate job with a minimal permissions block.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The new version check executes code just fetched from the public npm registry inside the release job, which holds repo and npm publish credentials. The incremental risk over the pre-existing install is small (npm install already runs dependency lifecycle scripts, and the artifact was built from this same tagged commit), but the explicit execution sink is new — worth hardening given the job's privileges.

          for attempt in {1..10}; do
            if npm install "@aictrl/cli@${AICTRL_VERSION}" 2>&1 | tee install.log; then
              echo "::notice::npm install @aictrl/cli@${AICTRL_VERSION} succeeded on attempt ${attempt}"
              INSTALLED_VERSION=$(./node_modules/.bin/aictrl --version)
              if [ "$INSTALLED_VERSION" != "$AICTRL_VERSION" ]; then
                echo "::error::Installed CLI reports $INSTALLED_VERSION, expected $AICTRL_VERSION"
                exit 1
              fi
              exit 0
            fi

Comment thread packages/cli/src/mcp/index.ts Outdated
// A catalog from startup does not guarantee tools on this turn. Stop
// before submitting a reduced toolset. Retain the client for disposal
// and a subsequent retry instead of silently deleting it (#127).
throw new Error(`MCP tool discovery failed for server "${clientName}". Check the MCP server and retry.`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ discoverTools drops the original error cause.

@@ -623 +623,3 @@
-      throw new Error(`MCP tool discovery failed for server "${clientName}". Check the MCP server and retry.`)
+      throw new Error(`MCP tool discovery failed for server "${clientName}". Check the MCP server and retry.`, {
+        cause: error,
+      })
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, packages/cli/src/mcp/index.ts:623):

Problem: discoverTools drops the original error cause
Detail: The rethrown Error carries only the generic message; the original listTools rejection (stack, error code) is lost to callers — only the log.error keeps it. Attaching { cause: error } preserves root-cause diagnostics for the session_error/event payloads shown to users.
Suggested fix: Pass the original error as cause: throw new Error(msg, { cause: error }).

Suggested patch:
@@ -623 +623,3 @@
-      throw new Error(`MCP tool discovery failed for server "${clientName}". Check the MCP server and retry.`)
+      throw new Error(`MCP tool discovery failed for server "${clientName}". Check the MCP server and retry.`, {
+        cause: error,
+      })


Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The rethrown Error carries only the generic message; the original listTools rejection (stack, error code) is lost to callers — only the log.error keeps it. Attaching { cause: error } preserves root-cause diagnostics for the session_error/event payloads shown to users.

        error: error instanceof Error ? error.message : String(error),
      })
      // A catalog from startup does not guarantee tools on this turn. Stop
      // before submitting a reduced toolset. Retain the client for disposal
      // and a subsequent retry instead of silently deleting it (#127).
      throw new Error(`MCP tool discovery failed for server "${clientName}". Check the MCP server and retry.`)
    })
  }

@aictrl-dev

aictrl-dev Bot commented Oct 5, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 1 · 🟡 3 · ⚪ 3 · 0/7 resolved

  • ⚪ .github/workflows/publish.yml:193-194 — Missing CLI binary aborts smoke step without diagnostics
  • ⚪ .github/workflows/publish.yml:193-194 — Smoke step executes registry-fetched CLI in publish job
  • 🟡 bun.lock:213 — Lock bumps @aictrl/util to 1.2.17 with no manifest change
  • 🟠 packages/cli/src/mcp/index.ts:623-625 — MCP discovery throw hits all tools() callers, not just runs
  • ⚪ packages/cli/src/mcp/index.ts:623 — discoverTools drops the original error cause
  • 🟡 packages/cli/src/mcp/index.ts:643-651 — Discovery and tool-call timeout fallbacks diverge
  • 🟡 packages/cli/src/mcp/index.ts:644-645 — Failed discovery leaves server status "connected" in state
🤖 Fix all 7 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #128 (head branch).
Run the relevant tests/linters after each change.

1. .github/workflows/publish.yml:193-194 — Missing CLI binary aborts smoke step without diagnostics
   Detail: INSTALLED_VERSION=$(./node_modules/.bin/aictrl --version) runs under `set -euo pipefail` inside the success branch. If npm install exits 0 but the bin shim is missing or non-executable, the command substitution's non-zero status terminates the step with a bare exit code — no ::error annotation and no retry, unlike every other failure path in the loop.
   Suggested fix: INSTALLED_VERSION=$(./node_modules/.bin/aictrl --version || echo "binary-missing") so the existing != comparison emits the proper ::error message instead of a bare exit code.
2. .github/workflows/publish.yml:193-194 — Smoke step executes registry-fetched CLI in publish job
   Detail: The new version check executes code just fetched from the public npm registry inside the release job, which holds repo and npm publish credentials. The incremental risk over the pre-existing install is small (npm install already runs dependency lifecycle scripts, and the artifact was built from this same tagged commit), but the explicit execution sink is new — worth hardening given the job's privileges.
   Suggested fix: Use `npm install --ignore-scripts` for the install attempts so only the explicit `--version` invocation runs foreign code, and/or run the smoke check in a separate job with a minimal permissions block.
3. bun.lock:213 — Lock bumps @aictrl/util to 1.2.17 with no manifest change
   Detail: bun.lock bumps the @aictrl/util workspace entry 1.2.16→1.2.17, but no packages/util/package.json change appears in this PR. If that manifest still reads 1.2.16 on this branch, the newly-added `bun install --frozen-lockfile` in publish.yml (and RELEASING.md step 1) fails on manifest/lock mismatch and blocks the 0.4.5 release outright. Most likely this is benign catch-up (the cli lock entry jumps 0.3.3→0.4.5, so the lock was stale and util's manifest was presumably already 1.2.17 from an earlier PR) — but confirm the manifest reads 1.2.17 before tagging, since the diff alone cannot prove it.
   Suggested fix: Verify packages/util/package.json on this branch reads "version": "1.2.17". If it does not, either bump it in this PR or revert the bun.lock util hunk so lock and manifest agree under --frozen-lockfile.
4. packages/cli/src/mcp/index.ts:623-625 — MCP discovery throw hits all tools() callers, not just runs
   Detail: discoverTools() now throws on any single server's listTools failure, so Promise.all rejects and the whole MCP.tools()/MCP.toolEntries() call fails for ALL servers, where it previously resolved with partial results (failed server skipped). The stated intent (#127) covers headless runs, and the new e2e test proves the `run` path converts this into session_error — but the module's importers also include non-run flows (cli/cmd/mcp.ts, cli/cmd/tool-catalog.ts, session/prompt.ts interactive loop, command/index.ts), and the usual co-change partners (src/cli/cmd/run.ts, src/cli/cmd/tool-catalog.ts, test/tool-catalog.test.ts) are NOT adapted in this PR. Any of those callers without a try/catch now crashes with an unhandled error where it previously degraded gracefully to partial output.
   Suggested fix: Verify every caller of MCP.tools()/MCP.toolEntries() handles the new rejection: either catch at the run-loop call site and rethrow as session_error (scoping the abort to headless runs), or wrap the listing/catalog/interactive call sites (cli/cmd/mcp.ts, cli/cmd/tool-catalog.ts, session/prompt.ts) with error handling that surfaces the message instead of crashing.
5. packages/cli/src/mcp/index.ts:623 — discoverTools drops the original error cause
   Detail: The rethrown Error carries only the generic message; the original listTools rejection (stack, error code) is lost to callers — only the log.error keeps it. Attaching { cause: error } preserves root-cause diagnostics for the session_error/event payloads shown to users.
   Suggested fix: Pass the original error as cause: throw new Error(msg, { cause: error }).
6. packages/cli/src/mcp/index.ts:643-651 — Discovery and tool-call timeout fallbacks diverge
   Detail: The same conceptual value is now computed three divergent ways: tools() pre-loop uses (per-server timeout) ?? defaultTimeout ?? DEFAULT_TIMEOUT; the tools() result loop re-derives entry?.timeout ?? defaultTimeout (no DEFAULT_TIMEOUT fallback) for the converted tools; toolEntries() inlines a third variant with cfg.experimental?.mcp_timeout. When neither a per-server timeout nor defaultTimeout is set, discovery is bounded by DEFAULT_TIMEOUT but each tool call gets timeout=undefined (SDK default / unbounded), so a hung server still stalls tools/call. The duplication also invites future drift between the three sites.
   Suggested fix: Extract one helper, e.g. mcpTimeout(cfg, clientName) { const c = cfg.mcp?.[clientName]; return (isMcpConfigured(c) ? c.timeout : undefined) ?? cfg.experimental?.mcp_timeout ?? DEFAULT_TIMEOUT }, and use it at all three sites — including the per-tool timeout passed to convertMcpTool — so discovery and tool calls share the same bound.
7. packages/cli/src/mcp/index.ts:644-645 — Failed discovery leaves server status "connected" in state
   Detail: The removed catch block set s.status[clientName] = { status: "failed", error } and deleted the client from s.clients; the replacement only logs and throws. Retaining the client for disposal/retry is deliberate (#127 comment), but the persisted status is never updated, so after a discovery failure the state still reports the broken server as connected/healthy and records no failure at all — any consumer of s.status (e.g. `aictrl mcp` status display) is misled.
   Suggested fix: Before throwing in discoverTools (or at the tools()/toolEntries() call sites), still write s.status[clientName] = { status: "failed", error: message } while keeping the client in s.clients for disposal and retry.
📋 Out-of-diff findings (7)
Sev Location Finding
⚪ .github/workflows/publish.yml:193-194 Missing CLI binary aborts smoke step without diagnostics
⚪ .github/workflows/publish.yml:193-194 Smoke step executes registry-fetched CLI in publish job
🟡 bun.lock:213 Lock bumps @aictrl/util to 1.2.17 with no manifest change
🟠 packages/cli/src/mcp/index.ts:623-625 MCP discovery throw hits all tools() callers, not just runs
⚪ packages/cli/src/mcp/index.ts:623 discoverTools drops the original error cause
🟡 packages/cli/src/mcp/index.ts:643-651 Discovery and tool-call timeout fallbacks diverge
🟡 packages/cli/src/mcp/index.ts:644-645 Failed discovery leaves server status "connected" in state

Reviewed 8 files · 0 inline · view all 7 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov

Copy link
Copy Markdown
Contributor Author

Review response — PR #128

Verified the initial review against its reviewed revision, applied the confirmed fixes, and reran the relevant regressions.

Issues addressed (pushed to this PR)

  • Discovery and tool-call timeout fallbacks diverge — Share explicit timeout resolution and preserve SDK tool-call defaults; the SDK itself bounds unspecified calls at 60000ms. (commit 41fe095880c96baae5632e057ba21e010d2657ff)
  • Failed discovery leaves server status "connected" in state — Record discovery failure in status, retain ownership for cleanup, and retry only discovery failures so failed reconfiguration cannot revive an old server. (commit 41fe095880c96baae5632e057ba21e010d2657ff)
  • Missing CLI binary aborts smoke step without diagnostics — Report an explicit workflow error when the installed binary is missing or cannot run. (commit 41fe095880c96baae5632e057ba21e010d2657ff)
  • Smoke step executes registry-fetched CLI in publish job — Move registry install and binary smoke execution into a dependent job with no publication permissions. (commit 41fe095880c96baae5632e057ba21e010d2657ff)
  • discoverTools drops the original error cause — Preserve the original SDK error as cause while retaining the actionable server-specific terminal message. (commit 41fe095880c96baae5632e057ba21e010d2657ff)

Review claims verified false (no change needed)

  • MCP discovery throw hits all tools() callers, not just runs — The only tools caller is SessionPrompt.resolveTools; run rejects its promptResult, catalog collection has a catch, and index.ts catches other CLI errors. The cited TUI/server paths are absent.
  • Lock bumps @aictrl/util to 1.2.17 with no manifest change — packages/util/package.json already declares 1.2.17, matching the refreshed workspace entry; frozen install succeeds.

Not addressed here

None. GitHub CI and the next automated review must still pass on the updated head before promotion.

Verification

  • Final workspace: 1571 passed, 7 skipped, 0 failed; typecheck, native 0.4.5 build, binary discovery (5 cases), MCP recovery/reconfiguration and release workflow checks passed.
  • The initial GitHub CI failure was hosted-runner acquisition, with no steps executed. Its retry passed on the original head; the updated head needs its own CI result.

The recorded findings currently have null GitHub comment IDs. Exact records were resolved from the returned review ID, full claim title, file, starting line and matching claim text; every match was unique.

Comment thread packages/cli/src/mcp/index.ts Outdated
// A catalog from startup does not guarantee tools on this turn. Stop
// before submitting a reduced toolset. Retain the client for disposal
// and a subsequent retry instead of silently deleting it (#127).
throw new Error(message, { cause: error })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Discovery throw aborts unvetted tools() callers.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, packages/cli/src/mcp/index.ts:646):

Problem: Discovery throw aborts unvetted tools() callers
Detail: discoverTools now throws on every listTools failure, so MCP.tools()/toolEntries() reject instead of degrading to a reduced toolset. This PR updates and tests only the headless run path; the MCP module's other importers — session/prompt.ts (interactive turn loop), cli/cmd/mcp.ts, cli/cmd/tool-catalog.ts, command/index.ts, cli/error.ts — are untouched, so one flaky MCP server can abort an interactive session turn or crash the `aictrl mcp` / `tool-catalog` commands with an unhandled rejection where they previously continued. Repro: Given an interactive session with one healthy and one failing (500 on tools/list) MCP server, When session/prompt.ts builds the per-turn toolset via MCP.tools(), Then the failing server's rejection propagates out of Promise.all and aborts the turn with "MCP tool discovery failed for server ..." instead of proceeding with the healthy server's tools.
Suggested fix: Scope the hard-throw to the headless run path (e.g. an abortOnError option on tools()/toolEntries() set by command/index.ts), or catch the discovery error in session/prompt.ts and the mcp/tool-catalog commands so interactive callers surface the failure as a turn error / partial listing instead of an unhandled rejection.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

discoverTools now throws on every listTools failure, so MCP.tools()/toolEntries() reject instead of degrading to a reduced toolset. This PR updates and tests only the headless run path; the MCP module's other importers — session/prompt.ts (interactive turn loop), cli/cmd/mcp.ts, cli/cmd/tool-catalog.ts, command/index.ts, cli/error.ts — are untouched, so one flaky MCP server can abort an interactive session turn or crash the aictrl mcp / tool-catalog commands with an unhandled rejection where they previously continued. Repro: Given an interactive session with one healthy and one failing (500 on tools/list) MCP server, When session/prompt.ts builds the per-turn toolset via MCP.tools(), Then the failing server's rejection propagates out of Promise.all and aborts the turn with "MCP tool discovery failed for server ..." instead of proceeding with the healthy server's tools.

        log.error("MCP tool discovery failed", {
          clientName,
          error: error instanceof Error ? error.message : String(error),
        })
        // A catalog from startup does not guarantee tools on this turn. Stop
        // before submitting a reduced toolset. Retain the client for disposal
        // and a subsequent retry instead of silently deleting it (#127).
        throw new Error(message, { cause: error })
      })
  }

  export async function tools() {

Comment thread .github/workflows/publish.yml Outdated
# Registry install and binary execution receive no publication permissions.
permissions: {}
steps:
- uses: actions/setup-node@v6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 setup-node pinned to mutable v6 tag, not SHA.

Suggested change
- uses: actions/setup-node@v6
Pin to an immutable ref: `- uses: actions/setup-node@<full-40-char-SHA> # v6.x.y`.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, .github/workflows/publish.yml:178-179):

Problem: setup-node pinned to mutable v6 tag, not SHA
Detail: The new smoke job references actions/setup-node by mutable major tag instead of a full commit SHA. If the tag is repointed (tag hijack or maintainer compromise), arbitrary code runs inside the release workflow. Blast radius is reduced by the job's `permissions: {}` and absence of checkout/publish tokens, but this workflow performs npm publications, so supply-chain hardening here is cheap and high-value.
Suggested fix: Pin to an immutable ref: `- uses: actions/setup-node@<full-40-char-SHA> # v6.x.y`.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The new smoke job references actions/setup-node by mutable major tag instead of a full commit SHA. If the tag is repointed (tag hijack or maintainer compromise), arbitrary code runs inside the release workflow. Blast radius is reduced by the job's permissions: {} and absence of checkout/publish tokens, but this workflow performs npm publications, so supply-chain hardening here is cheap and high-value.

    permissions: {}
    steps:
      - uses: actions/setup-node@v6
        with:
          node-version: 22

      - name: Smoke test - verify @aictrl/cli installs cleanly via npm
        # Catches the v0.3.3-class bug where the published manifest carries

Comment thread packages/cli/src/mcp/index.ts Outdated
// A catalog from startup does not guarantee tools on this turn. Stop
// before submitting a reduced toolset. Retain the client for disposal
// and a subsequent retry instead of silently deleting it (#127).
throw new Error(message, { cause: error })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Stale client's discovery failure still aborts run.

Suggested change
throw new Error(message, { cause: error })
Only rethrow when `s.clients[clientName] === client`; for a stale client, log and resolve with an empty result (mirroring the stale success path, which silently returns) so a superseded client's in-flight failure cannot fail the catalog build.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, packages/cli/src/mcp/index.ts:646):

Problem: Stale client's discovery failure still aborts run
Detail: The catch block gates all state writes (status, discoveryFailures) behind the staleness check `s.clients[clientName] === client`, but the rethrow is unconditional. If add()/connect()/finishAuth() replaces the client (or disconnect() removes it) while its listTools is in flight, the superseded client's transport-closed rejection still rejects tools()/toolEntries() and aborts the run — precisely the outcome the staleness guard exists to prevent. Repro: Given a per-turn tools() discovery in flight for server X, When finishAuth(X) replaces X's client in the same process, Then the old client's listTools rejects, the guard skips the state writes, but the unconditional throw still aborts the turn for a server that now has a healthy replacement.
Suggested fix: Only rethrow when `s.clients[clientName] === client`; for a stale client, log and resolve with an empty result (mirroring the stale success path, which silently returns) so a superseded client's in-flight failure cannot fail the catalog build.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The catch block gates all state writes (status, discoveryFailures) behind the staleness check s.clients[clientName] === client, but the rethrow is unconditional. If add()/connect()/finishAuth() replaces the client (or disconnect() removes it) while its listTools is in flight, the superseded client's transport-closed rejection still rejects tools()/toolEntries() and aborts the run — precisely the outcome the staleness guard exists to prevent. Repro: Given a per-turn tools() discovery in flight for server X, When finishAuth(X) replaces X's client in the same process, Then the old client's listTools rejects, the guard skips the state writes, but the unconditional throw still aborts the turn for a server that now has a healthy replacement.

      .catch((error) => {
        const message = `MCP tool discovery failed for server "${clientName}". Check the MCP server and retry.`
        if (s.clients[clientName] === client) {
          s.status[clientName] = { status: "failed", error: message }
          s.discoveryFailures.add(clientName)
        }
        log.error("MCP tool discovery failed", {
          clientName,
          error: error instanceof Error ? error.message : String(error),
        })
        // A catalog from startup does not guarantee tools on this turn. Stop
        // before submitting a reduced toolset. Retain the client for disposal
        // and a subsequent retry instead of silently deleting it (#127).
        throw new Error(message, { cause: error })

Comment thread packages/cli/src/mcp/index.ts Outdated
const timeout = configuredTimeout(cfg, clientName)
// Listing uses the connection/discovery default. Calls retain the
// SDK's existing default when no explicit timeout is configured.
const toolsResult = await discoverTools(clientName, client, timeout ?? DEFAULT_TIMEOUT)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 30s discovery floor converts slow servers to aborts.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, packages/cli/src/mcp/index.ts:665):

Problem: 30s discovery floor converts slow servers to aborts
Detail: tools() and toolEntries() now bound every per-turn listTools with `timeout ?? DEFAULT_TIMEOUT` (module constant, 30s). Previously the per-turn listTools() call passed no timeout and used the MCP SDK request default (60s), so servers enumerating large toolsets in 30-60s succeeded; now they exceed the 30s cap, and because a discovery failure throws, the entire invocation aborts rather than merely being slow. A slow-but-healthy server becomes a hard run failure. Repro: Given a remote MCP server whose tools/list takes ~40s with no explicit timeout configured, When a headless run builds its per-turn catalog, Then listTools times out at 30s and the run aborts, where before this PR the same server enumerated successfully.
Suggested fix: Use a discovery-specific ceiling for listTools (e.g. keep the SDK's 60s request default, or max(DEFAULT_TIMEOUT, SDK default)) so slow-but-healthy servers are not converted into run-aborting failures; only genuine errors should trigger the #127 abort.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

tools() and toolEntries() now bound every per-turn listTools with timeout ?? DEFAULT_TIMEOUT (module constant, 30s). Previously the per-turn listTools() call passed no timeout and used the MCP SDK request default (60s), so servers enumerating large toolsets in 30-60s succeeded; now they exceed the 30s cap, and because a discovery failure throws, the entire invocation aborts rather than merely being slow. A slow-but-healthy server becomes a hard run failure. Repro: Given a remote MCP server whose tools/list takes ~40s with no explicit timeout configured, When a headless run builds its per-turn catalog, Then listTools times out at 30s and the run aborts, where before this PR the same server enumerated successfully.

    const toolsResults = await Promise.all(
      connectedClients.map(async ([clientName, client]) => {
        const timeout = configuredTimeout(cfg, clientName)
        // Listing uses the connection/discovery default. Calls retain the
        // SDK's existing default when no explicit timeout is configured.
        const toolsResult = await discoverTools(clientName, client, timeout ?? DEFAULT_TIMEOUT)
        return { clientName, client, toolsResult, timeout }
      }),
    )

Comment thread RELEASING.md Outdated
2. Run the headless release regressions from `packages/cli`:

```bash
bun test test/cli/run-mcp-discovery.test.ts test/cli/run-provider-finish.test.ts test/cli/run-signal-cancellation.test.ts test/cli/classify-session-error.test.ts test/session/idle.test.ts test/session/processor-idle.test.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Release regressions omit discovery-recovery test.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, RELEASING.md:9-10):

Problem: Release regressions omit discovery-recovery test
Detail: Step 2's headless regression list includes test/cli/run-mcp-discovery.test.ts but not packages/cli/test/mcp/discovery-recovery.test.ts — the direct regression this same PR adds for the exact #127 retry/retention semantics being shipped in 0.4.5 (failed status, client retained, retry succeeds). Following the doc verbatim skips the test that most tightly covers the fix.
Suggested fix: Add test/mcp/discovery-recovery.test.ts to the `bun test` command in RELEASING.md step 2.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Step 2's headless regression list includes test/cli/run-mcp-discovery.test.ts but not packages/cli/test/mcp/discovery-recovery.test.ts — the direct regression this same PR adds for the exact #127 retry/retention semantics being shipped in 0.4.5 (failed status, client retained, retry succeeds). Following the doc verbatim skips the test that most tightly covers the fix.

   `packages/cli/package.json`, and the matching `packages/cli` workspace
   version in `bun.lock`. Run `bun install --frozen-lockfile`.
2. Run the headless release regressions from `packages/cli`:

   ```bash
   bun test test/cli/run-mcp-discovery.test.ts test/cli/run-provider-finish.test.ts test/cli/run-signal-cancellation.test.ts test/cli/classify-session-error.test.ts test/session/idle.test.ts test/session/processor-idle.test.ts
   ```

   Wait for CI build, workspace typecheck and tests to pass and address reviews.

Comment thread packages/cli/src/mcp/index.ts Outdated
return undefined
})
return { clientName, client, toolsResult }
const timeout = configuredTimeout(cfg, clientName)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ timeout differs semantically in tools() vs toolEntries().

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, packages/cli/src/mcp/index.ts:662-666):

Problem: timeout differs semantically in tools() vs toolEntries()
Detail: In tools(), `timeout` is the configured per-server/experimental value (may be undefined) and is threaded through to convertMcpTool for tool calls, with DEFAULT_TIMEOUT applied only at the discoverTools call site. In the adjacent toolEntries(), the same identifier already has DEFAULT_TIMEOUT folded in and is then discarded. Two subtly different meanings for one name in sibling functions sharing the new helper invites passing the wrong one to a future caller.
Suggested fix: Name them distinctly, e.g. `callTimeout` in tools() (threaded to convertMcpTool) and `discoveryTimeout` in toolEntries().

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

In tools(), timeout is the configured per-server/experimental value (may be undefined) and is threaded through to convertMcpTool for tool calls, with DEFAULT_TIMEOUT applied only at the discoverTools call site. In the adjacent toolEntries(), the same identifier already has DEFAULT_TIMEOUT folded in and is then discarded. Two subtly different meanings for one name in sibling functions sharing the new helper invites passing the wrong one to a future caller.

      connectedClients.map(async ([clientName, client]) => {
        const timeout = configuredTimeout(cfg, clientName)
        // Listing uses the connection/discovery default. Calls retain the
        // SDK's existing default when no explicit timeout is configured.
        const toolsResult = await discoverTools(clientName, client, timeout ?? DEFAULT_TIMEOUT)
        return { clientName, client, toolsResult, timeout }
      }),

@aictrl-dev

aictrl-dev Bot commented Oct 5, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 1 · 🟡 4 · ⚪ 1 · 0/6 resolved

  • 🟡 .github/workflows/publish.yml:178-179 — setup-node pinned to mutable v6 tag, not SHA
  • 🟠 packages/cli/src/mcp/index.ts:646 — Discovery throw aborts unvetted tools() callers
  • 🟡 packages/cli/src/mcp/index.ts:646 — Stale client's discovery failure still aborts run
  • ⚪ packages/cli/src/mcp/index.ts:662-666 — timeout differs semantically in tools() vs toolEntries()
  • 🟡 packages/cli/src/mcp/index.ts:665 — 30s discovery floor converts slow servers to aborts
  • 🟡 RELEASING.md:9-10 — Release regressions omit discovery-recovery test
🤖 Fix all 6 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #128 (head branch).
Run the relevant tests/linters after each change.

1. .github/workflows/publish.yml:178-179 — setup-node pinned to mutable v6 tag, not SHA
   Detail: The new smoke job references actions/setup-node by mutable major tag instead of a full commit SHA. If the tag is repointed (tag hijack or maintainer compromise), arbitrary code runs inside the release workflow. Blast radius is reduced by the job's `permissions: {}` and absence of checkout/publish tokens, but this workflow performs npm publications, so supply-chain hardening here is cheap and high-value.
   Suggested fix: Pin to an immutable ref: `- uses: actions/setup-node@<full-40-char-SHA> # v6.x.y`.
2. packages/cli/src/mcp/index.ts:646 — Discovery throw aborts unvetted tools() callers
   Detail: discoverTools now throws on every listTools failure, so MCP.tools()/toolEntries() reject instead of degrading to a reduced toolset. This PR updates and tests only the headless run path; the MCP module's other importers — session/prompt.ts (interactive turn loop), cli/cmd/mcp.ts, cli/cmd/tool-catalog.ts, command/index.ts, cli/error.ts — are untouched, so one flaky MCP server can abort an interactive session turn or crash the `aictrl mcp` / `tool-catalog` commands with an unhandled rejection where they previously continued. Repro: Given an interactive session with one healthy and one failing (500 on tools/list) MCP server, When session/prompt.ts builds the per-turn toolset via MCP.tools(), Then the failing server's rejection propagates out of Promise.all and aborts the turn with "MCP tool discovery failed for server ..." instead of proceeding with the healthy server's tools.
   Suggested fix: Scope the hard-throw to the headless run path (e.g. an abortOnError option on tools()/toolEntries() set by command/index.ts), or catch the discovery error in session/prompt.ts and the mcp/tool-catalog commands so interactive callers surface the failure as a turn error / partial listing instead of an unhandled rejection.
3. packages/cli/src/mcp/index.ts:646 — Stale client's discovery failure still aborts run
   Detail: The catch block gates all state writes (status, discoveryFailures) behind the staleness check `s.clients[clientName] === client`, but the rethrow is unconditional. If add()/connect()/finishAuth() replaces the client (or disconnect() removes it) while its listTools is in flight, the superseded client's transport-closed rejection still rejects tools()/toolEntries() and aborts the run — precisely the outcome the staleness guard exists to prevent. Repro: Given a per-turn tools() discovery in flight for server X, When finishAuth(X) replaces X's client in the same process, Then the old client's listTools rejects, the guard skips the state writes, but the unconditional throw still aborts the turn for a server that now has a healthy replacement.
   Suggested fix: Only rethrow when `s.clients[clientName] === client`; for a stale client, log and resolve with an empty result (mirroring the stale success path, which silently returns) so a superseded client's in-flight failure cannot fail the catalog build.
4. packages/cli/src/mcp/index.ts:662-666 — timeout differs semantically in tools() vs toolEntries()
   Detail: In tools(), `timeout` is the configured per-server/experimental value (may be undefined) and is threaded through to convertMcpTool for tool calls, with DEFAULT_TIMEOUT applied only at the discoverTools call site. In the adjacent toolEntries(), the same identifier already has DEFAULT_TIMEOUT folded in and is then discarded. Two subtly different meanings for one name in sibling functions sharing the new helper invites passing the wrong one to a future caller.
   Suggested fix: Name them distinctly, e.g. `callTimeout` in tools() (threaded to convertMcpTool) and `discoveryTimeout` in toolEntries().
5. packages/cli/src/mcp/index.ts:665 — 30s discovery floor converts slow servers to aborts
   Detail: tools() and toolEntries() now bound every per-turn listTools with `timeout ?? DEFAULT_TIMEOUT` (module constant, 30s). Previously the per-turn listTools() call passed no timeout and used the MCP SDK request default (60s), so servers enumerating large toolsets in 30-60s succeeded; now they exceed the 30s cap, and because a discovery failure throws, the entire invocation aborts rather than merely being slow. A slow-but-healthy server becomes a hard run failure. Repro: Given a remote MCP server whose tools/list takes ~40s with no explicit timeout configured, When a headless run builds its per-turn catalog, Then listTools times out at 30s and the run aborts, where before this PR the same server enumerated successfully.
   Suggested fix: Use a discovery-specific ceiling for listTools (e.g. keep the SDK's 60s request default, or max(DEFAULT_TIMEOUT, SDK default)) so slow-but-healthy servers are not converted into run-aborting failures; only genuine errors should trigger the #127 abort.
6. RELEASING.md:9-10 — Release regressions omit discovery-recovery test
   Detail: Step 2's headless regression list includes test/cli/run-mcp-discovery.test.ts but not packages/cli/test/mcp/discovery-recovery.test.ts — the direct regression this same PR adds for the exact #127 retry/retention semantics being shipped in 0.4.5 (failed status, client retained, retry succeeds). Following the doc verbatim skips the test that most tightly covers the fix.
   Suggested fix: Add test/mcp/discovery-recovery.test.ts to the `bun test` command in RELEASING.md step 2.
📋 Out-of-diff findings (6)
Sev Location Finding
🟡 .github/workflows/publish.yml:178-179 setup-node pinned to mutable v6 tag, not SHA
🟠 packages/cli/src/mcp/index.ts:646 Discovery throw aborts unvetted tools() callers
🟡 packages/cli/src/mcp/index.ts:646 Stale client's discovery failure still aborts run
⚪ packages/cli/src/mcp/index.ts:662-666 timeout differs semantically in tools() vs toolEntries()
🟡 packages/cli/src/mcp/index.ts:665 30s discovery floor converts slow servers to aborts
🟡 RELEASING.md:9-10 Release regressions omit discovery-recovery test

Reviewed 9 files · 0 inline · view all 6 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov

Copy link
Copy Markdown
Contributor Author

Review response — PR #128

Verified the follow-up findings recorded by the automated review against their reviewed revision. These exact recorded findings are available in the data layer before the pending GitHub review delivery; IDs are taken directly from those structured records, with no location-based guesses.

Issues addressed (pushed to this PR)

  • Release regressions omit discovery-recovery test — Include the direct recovery and release workflow regressions in the documented release command. (commit 7c3e8f28695054a8eea510d2238767e7479ec8b2)
  • timeout differs semantically in tools() vs toolEntries() — Name tool-call and catalog discovery timeout variables separately and preserve the shared explicit timeout resolution. (commit 7c3e8f28695054a8eea510d2238767e7479ec8b2)
  • 30s discovery floor converts slow servers to aborts — Preserve the SDK's bounded 60-second request default instead of shortening unconfigured discovery to 30 seconds. (commit 7c3e8f28695054a8eea510d2238767e7479ec8b2)
  • setup-node pinned to mutable v6 tag, not SHA — Pin setup-node in publication and smoke jobs to the verified immutable v6 commit. (commit 7c3e8f28695054a8eea510d2238767e7479ec8b2)

Review claims verified false (no change needed)

  • Discovery throw aborts unvetted tools() callers — The cited commands do not all call tools(); the actual prompt, catalog and CLI boundaries handle rejections. This CLI has no interactive TUI/server paths.

Not addressed here

Informational or intentional behavior retained:

  • Stale client's discovery failure still aborts run — An in-flight catalog that cannot establish its complete toolset must fail; silently returning an empty result would recreate tool loss. Retry builds a fresh catalog.

Verification

A real SDK response delayed 31 seconds failed against the reviewed head at 30.23 seconds, then passed concurrently through both runtime tools and catalog with the corrected SDK default. Recovery: 2 passed; focused publication/headless cases: 15 passed; compiled 0.4.5 cases: 5 passed. Native build, typecheck and normal push hooks passed. GitHub must validate the new head.

@byapparov byapparov self-assigned this Oct 6, 2026
@byapparov byapparov added the bug Something isn't working label Oct 6, 2026
@byapparov byapparov changed the title fix: stop runs on MCP discovery failure; prepare 0.4.5 fix: retain MCP catalogs per connection; prepare 0.4.5 Oct 6, 2026
const catalog = toolCatalogs.get(client)
if (!catalog) throw new Error(`Missing MCP tool catalog for server "${serverName}"`)
await catalog.refresh
if (toolCatalogs.get(client) !== catalog)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Server swap mid-turn kills the in-flight turn.

Suggested change
if (toolCatalogs.get(client) !== catalog)
When the post-await identity check fails, re-resolve the client by name from state (s.clients[serverName]) once and use the fresh client's catalog instead of throwing; or have tools()/toolEntries() tolerate replaced catalogs for the current call, since the next turn picks up the new client.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, packages/cli/src/mcp/index.ts:166-167):

Problem: Server swap mid-turn kills the in-flight turn
Detail: catalogTools throws "MCP connection changed ... Retry the invocation" when add()/disconnect() replaces a client while tools() is awaiting that server's catalog.refresh. Because tools()/toolEntries() Promise.all every server, replacing or reconnecting ANY server during an in-flight turn (e.g. finishAuth() re-adds the server after an OAuth callback, or a concurrent MCP.add()/disconnect() in-process) rejects the entire turn with a session error, where the previous implementation still returned a usable toolset for the duration of the call. The error is actionable and covered by tests, but a routine reconnection now aborts the active model turn.
Suggested fix: When the post-await identity check fails, re-resolve the client by name from state (s.clients[serverName]) once and use the fresh client's catalog instead of throwing; or have tools()/toolEntries() tolerate replaced catalogs for the current call, since the next turn picks up the new client.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

catalogTools throws "MCP connection changed ... Retry the invocation" when add()/disconnect() replaces a client while tools() is awaiting that server's catalog.refresh. Because tools()/toolEntries() Promise.all every server, replacing or reconnecting ANY server during an in-flight turn (e.g. finishAuth() re-adds the server after an OAuth callback, or a concurrent MCP.add()/disconnect() in-process) rejects the entire turn with a session error, where the previous implementation still returned a usable toolset for the duration of the call. The error is actionable and covered by tests, but a routine reconnection now aborts the active model turn.

  async function catalogTools(client: MCPClient, serverName: string) {
    const catalog = toolCatalogs.get(client)
    if (!catalog) throw new Error(`Missing MCP tool catalog for server "${serverName}"`)
    await catalog.refresh
    if (toolCatalogs.get(client) !== catalog)
      throw new Error(`MCP connection changed for server "${serverName}". Retry the invocation.`)
    if (catalog.error) throw catalog.error
    return catalog.tools
  }


log.info("create() successfully created client", { key, toolCount: result.tools.length })
toolCatalogs.set(mcpClient, { tools: result.tools, refresh: Promise.resolve() })
registerNotificationHandlers(mcpClient, key, mcp.timeout ?? cfg.experimental?.mcp_timeout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Discovery and refresh resolve timeouts differently.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, packages/cli/src/mcp/index.ts:576):

Problem: Discovery and refresh resolve timeouts differently
Detail: The same tools/list operation now runs under three different timeout resolutions. create()'s initial discovery uses mcp.timeout ?? DEFAULT_TIMEOUT (30s, line 551); notification-driven refreshes use mcp.timeout ?? cfg.experimental?.mcp_timeout captured once at create time (line 576) — undefined there falls back to the MCP SDK's own 60s request default; and per-tool execution re-derives configuredTimeout() on every tools() call. With only experimental.mcp_timeout configured, startup discovery times out at 30s while refreshes use the experimental value; with neither configured, a hung refresh can wait 60s where startup waited 30s. The refresh timeout is also frozen at create time, so later config changes never affect it, while tool-call timeouts track current config — the same fact (a server's timeout) derived in multiple places with different lifetimes.
Suggested fix: Compute one resolver, e.g. `const discoveryTimeout = mcp.timeout ?? cfg.experimental?.mcp_timeout ?? DEFAULT_TIMEOUT`, and use it for both the initial withTimeout(mcpClient.listTools(), ...) call and registerNotificationHandlers (or store it in the ToolCatalog entry) so discovery, refresh and config updates share identical, current timeout semantics.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The same tools/list operation now runs under three different timeout resolutions. create()'s initial discovery uses mcp.timeout ?? DEFAULT_TIMEOUT (30s, line 551); notification-driven refreshes use mcp.timeout ?? cfg.experimental?.mcp_timeout captured once at create time (line 576) — undefined there falls back to the MCP SDK's own 60s request default; and per-tool execution re-derives configuredTimeout() on every tools() call. With only experimental.mcp_timeout configured, startup discovery times out at 30s while refreshes use the experimental value; with neither configured, a hung refresh can wait 60s where startup waited 30s. The refresh timeout is also frozen at create time, so later config changes never affect it, while tool-call timeouts track current config — the same fact (a server's timeout) derived in multiple places with different lifetimes.

    log.info("create() successfully created client", { key, toolCount: result.tools.length })
    toolCatalogs.set(mcpClient, { tools: result.tools, refresh: Promise.resolve() })
    registerNotificationHandlers(mcpClient, key, mcp.timeout ?? cfg.experimental?.mcp_timeout)
    return {
      mcpClient,

})
return { clientName, client, toolsResult }
}),
const catalogs = await Promise.all(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 One failed MCP server fails tools() for all servers.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, packages/cli/src/mcp/index.ts:654-658):

Problem: One failed MCP server fails tools() for all servers
Detail: tools() and toolEntries() (lines 681-686) Promise.all over per-server catalogTools, so a single server's cached catalog.error (failed refresh or closed connection) rejects the entire call: every model turn errors and healthy servers' tools are withheld until the failed server happens to send another tools/list_changed notification or the operator reconnects it. With multiple failed servers, Promise.all surfaces an arbitrary first rejection, hiding the other culprits from the error message. Failing the turn is the documented intent, but the blast radius spans unrelated servers and the reported error names only one of possibly several. Repro: with servers A (healthy) and B (dropped connection), the next model turn rejects with only B's error and A's tools are never dispatched, on every subsequent turn.
Suggested fix: Use Promise.allSettled over the per-server catalogTools calls and throw an aggregated error listing every failed server name and its message, so operators see all servers needing attention instead of an arbitrary first rejection.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

tools() and toolEntries() (lines 681-686) Promise.all over per-server catalogTools, so a single server's cached catalog.error (failed refresh or closed connection) rejects the entire call: every model turn errors and healthy servers' tools are withheld until the failed server happens to send another tools/list_changed notification or the operator reconnects it. With multiple failed servers, Promise.all surfaces an arbitrary first rejection, hiding the other culprits from the error message. Failing the turn is the documented intent, but the blast radius spans unrelated servers and the reported error names only one of possibly several. Repro: with servers A (healthy) and B (dropped connection), the next model turn rejects with only B's error and A's tools are never dispatched, on every subsequent turn.

    const s = await state()
    const cfg = await Config.get()
    const catalogs = await Promise.all(
      Object.entries(s.clients).map(async ([clientName, client]) => ({
        clientName,
        client,
        tools: await catalogTools(client, clientName),
      })),
    )

    for (const { clientName, client, tools } of catalogs) {

Comment thread packages/cli/src/mcp/index.ts Outdated
result[key] = s.status[key] ?? { status: "disabled" }
const client = s.clients[key]
const error = client && toolCatalogs.get(client)?.error
result[key] = error ? { status: "failed", error: error.message } : (s.status[key] ?? { status: "disabled" })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Server health now has two diverging sources of truth.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, packages/cli/src/mcp/index.ts:594):

Problem: Server health now has two diverging sources of truth
Detail: This PR makes catalog.error the authoritative failure signal for tools: status() synthesizes {status:"failed"} from it (line 594) while s.status[key] still says "connected", and prompts() (line 700) / resources() (line 721) still gate solely on s.status === "connected". After a transport close (client.onclose at line 130 sets only catalog.error) or a failed notification refresh, status() reports the server "failed" while prompts()/resources() keep invoking listPrompts/listResources on the dead client on every call; their per-call .catch swallows the error, so the server's prompts/resources silently vanish. Two hand-synced views of server health now coexist in one module and disagree in exactly the states this PR adds; they are kept in sync only by hand at the create/add/disconnect sites.
Suggested fix: Derive from one source: either have the onclose handler / refreshCatalog also write s.status[serverName] = { status: "failed", error } (and restore "connected" on successful refresh) so prompts()/resources() observe the failure through the existing gate and status() needs no catalog lookup; or gate prompts()/resources() on the same catalog error via a shared health helper (e.g. serverHealth(client, name) reading toolCatalogs.get(client)?.error).

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

This PR makes catalog.error the authoritative failure signal for tools: status() synthesizes {status:"failed"} from it (line 594) while s.status[key] still says "connected", and prompts() (line 700) / resources() (line 721) still gate solely on s.status === "connected". After a transport close (client.onclose at line 130 sets only catalog.error) or a failed notification refresh, status() reports the server "failed" while prompts()/resources() keep invoking listPrompts/listResources on the dead client on every call; their per-call .catch swallows the error, so the server's prompts/resources silently vanish. Two hand-synced views of server health now coexist in one module and disagree in exactly the states this PR adds; they are kept in sync only by hand at the create/add/disconnect sites.

    for (const [key, mcp] of Object.entries(config)) {
      if (!isMcpConfigured(mcp)) continue
      const client = s.clients[key]
      const error = client && toolCatalogs.get(client)?.error
      result[key] = error ? { status: "failed", error: error.message } : (s.status[key] ?? { status: "disabled" })
    }

RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
AICTRL_VERSION="${RELEASE_TAG#v}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Validate semver tag before npm spec and GITHUB_ENV.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, .github/workflows/publish.yml:193-206):

Problem: Validate semver tag before npm spec and GITHUB_ENV
Detail: The smoke job derives AICTRL_VERSION from github.event.release.tag_name with only the "v" prefix stripped and an empty-check, then interpolates it into an npm package spec (`npm install "@aictrl/cli@${AICTRL_VERSION}"`, line 204) and executes the installed `aictrl` bin (line 206); the publish job appends the same derived string to $GITHUB_ENV (line 63). Full quoting blocks shell injection, but npm accepts alias/URL spec syntax (npm:<pkg>, git/https URLs), so a crafted tag could redirect the install to an arbitrary registry package whose binary the step then runs. Today the publish job's strict PKG_VERSION equality gate plus `needs: publish` ordering means a non-matching tag never reaches the smoke job, so this is defense-in-depth rather than an exploitable path.
Suggested fix: After stripping the "v" prefix, validate the format before use in both jobs, e.g. `if ! printf '%s' "$AICTRL_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then echo "::error::Non-semver release tag: $RELEASE_TAG"; exit 1; fi`. This closes the npm alias/URL spec sink and the $GITHUB_ENV write independently of the package.json comparison.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The smoke job derives AICTRL_VERSION from github.event.release.tag_name with only the "v" prefix stripped and an empty-check, then interpolates it into an npm package spec (npm install "@aictrl/cli@${AICTRL_VERSION}", line 204) and executes the installed aictrl bin (line 206); the publish job appends the same derived string to $GITHUB_ENV (line 63). Full quoting blocks shell injection, but npm accepts alias/URL spec syntax (npm:, git/https URLs), so a crafted tag could redirect the install to an arbitrary registry package whose binary the step then runs. Today the publish job's strict PKG_VERSION equality gate plus needs: publish ordering means a non-matching tag never reaches the smoke job, so this is defense-in-depth rather than an exploitable path.

          RELEASE_TAG: ${{ github.event.release.tag_name }}
        run: |
          set -euo pipefail
          AICTRL_VERSION="${RELEASE_TAG#v}"
          smoke="${RUNNER_TEMP}/aictrl-publish-smoke"
          rm -rf "$smoke"
          mkdir -p "$smoke"
          cd "$smoke"
          npm init -y > /dev/null

Comment thread packages/cli/src/mcp/index.ts Outdated
} catch (error) {
if (toolCatalogs.get(client) !== catalog) return
catalog.error = new Error(
`MCP tool discovery failed for server "${serverName}". Check the MCP server and retry.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ "Check the server and retry" advice never rediscovers.

--- a/packages/cli/src/mcp/index.ts
+++ b/packages/cli/src/mcp/index.ts
@@ -147,7 +147,7 @@
       } catch (error) {
         if (toolCatalogs.get(client) !== catalog) return
         catalog.error = new Error(
-          `MCP tool discovery failed for server "${serverName}". Check the MCP server and retry.`,
+          `MCP tool discovery failed for server "${serverName}". Reconnect the MCP server and retry.`,
           { cause: error },
         )
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, packages/cli/src/mcp/index.ts:150):

Problem: "Check the server and retry" advice never rediscovers
Detail: The refresh-failure message tells the user to "Check the MCP server and retry", but retrying does not issue a new tools/list: catalogTools re-throws the cached catalog.error on every call, and the PR's own test asserts the server's tools/list count stays flat after a failed read ("failed catalog reads must not silently retry"). Recovery requires a new tools/list_changed notification from the server or an explicit reconnect — exactly what the sibling onclose message says ("Reconnect the MCP server and retry"). The instruction in this message does not match the implemented recovery path.
Suggested fix: Align with the onclose wording: `MCP tool discovery failed for server "${serverName}". Reconnect the MCP server and retry.` — the existing tests match on the unchanged "MCP tool discovery failed for server" prefix, so they keep passing.

Suggested patch:
--- a/packages/cli/src/mcp/index.ts
+++ b/packages/cli/src/mcp/index.ts
@@ -147,7 +147,7 @@
       } catch (error) {
         if (toolCatalogs.get(client) !== catalog) return
         catalog.error = new Error(
-          `MCP tool discovery failed for server "${serverName}". Check the MCP server and retry.`,
+          `MCP tool discovery failed for server "${serverName}". Reconnect the MCP server and retry.`,
           { cause: error },
         )

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The refresh-failure message tells the user to "Check the MCP server and retry", but retrying does not issue a new tools/list: catalogTools re-throws the cached catalog.error on every call, and the PR's own test asserts the server's tools/list count stays flat after a failed read ("failed catalog reads must not silently retry"). Recovery requires a new tools/list_changed notification from the server or an explicit reconnect — exactly what the sibling onclose message says ("Reconnect the MCP server and retry"). The instruction in this message does not match the implemented recovery path.

      } catch (error) {
        if (toolCatalogs.get(client) !== catalog) return
        catalog.error = new Error(
          `MCP tool discovery failed for server "${serverName}". Check the MCP server and retry.`,
          { cause: error },
        )
        log.error("MCP tool discovery failed", {


const connectedClients = Object.entries(clientsSnapshot).filter(
([clientName]) => s.status[clientName]?.status === "connected",
const catalogs = await Promise.all(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ tools() and toolEntries() duplicate the catalog fetch.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, packages/cli/src/mcp/index.ts:681-686):

Problem: tools() and toolEntries() duplicate the catalog fetch
Detail: tools() (lines 652-658) and toolEntries() (lines 681-686) each hand-roll the identical Promise.all(Object.entries(s.clients).map(...catalogTools...)) fetch over the same client set. The file already establishes the "single source of truth" convention for the shared key format (mcpToolKey, with its own doc comment); the shared catalog-fetch block is copy-pasted instead, so any future change to iteration or health handling must be applied twice and can silently diverge between dispatch and catalog.
Suggested fix: Extract a shared helper, e.g. `async function catalogsForClients(): Promise<{ name: string; client: MCPClient; tools: MCPToolDef[] }[]>`, consumed by both tools() and toolEntries(), matching the existing mcpToolKey single-source-of-truth pattern.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

tools() (lines 652-658) and toolEntries() (lines 681-686) each hand-roll the identical Promise.all(Object.entries(s.clients).map(...catalogTools...)) fetch over the same client set. The file already establishes the "single source of truth" convention for the shared key format (mcpToolKey, with its own doc comment); the shared catalog-fetch block is copy-pasted instead, so any future change to iteration or health handling must be applied twice and can silently diverge between dispatch and catalog.

  export async function toolEntries(): Promise<{ toolKey: string; serverName: string }[]> {
    const s = await state()
    const catalogs = await Promise.all(
      Object.entries(s.clients).map(async ([serverName, client]) => ({
        serverName,
        tools: await catalogTools(client, serverName),
      })),
    )
    return catalogs.flatMap(({ serverName, tools }) =>

@aictrl-dev

aictrl-dev Bot commented Oct 6, 2026

Copy link
Copy Markdown

Code review

Verdict: Looks good — only minor / nit comments below. · 🔴 0 · 🟠 0 · 🟡 4 · ⚪ 3 · 0/7 resolved

  • ⚪ .github/workflows/publish.yml:193-206 — Validate semver tag before npm spec and GITHUB_ENV
  • ⚪ packages/cli/src/mcp/index.ts:150 — "Check the server and retry" advice never rediscovers
  • 🟡 packages/cli/src/mcp/index.ts:166-167 — Server swap mid-turn kills the in-flight turn
  • 🟡 packages/cli/src/mcp/index.ts:576 — Discovery and refresh resolve timeouts differently
  • 🟡 packages/cli/src/mcp/index.ts:594 — Server health now has two diverging sources of truth
  • 🟡 packages/cli/src/mcp/index.ts:654-658 — One failed MCP server fails tools() for all servers
  • ⚪ packages/cli/src/mcp/index.ts:681-686 — tools() and toolEntries() duplicate the catalog fetch
🤖 Fix all 7 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #128 (head branch).
Run the relevant tests/linters after each change.

1. .github/workflows/publish.yml:193-206 — Validate semver tag before npm spec and GITHUB_ENV
   Detail: The smoke job derives AICTRL_VERSION from github.event.release.tag_name with only the "v" prefix stripped and an empty-check, then interpolates it into an npm package spec (`npm install "@aictrl/cli@${AICTRL_VERSION}"`, line 204) and executes the installed `aictrl` bin (line 206); the publish job appends the same derived string to $GITHUB_ENV (line 63). Full quoting blocks shell injection, but npm accepts alias/URL spec syntax (npm:<pkg>, git/https URLs), so a crafted tag could redirect the install to an arbitrary registry package whose binary the step then runs. Today the publish job's strict PKG_VERSION equality gate plus `needs: publish` ordering means a non-matching tag never reaches the smoke job, so this is defense-in-depth rather than an exploitable path.
   Suggested fix: After stripping the "v" prefix, validate the format before use in both jobs, e.g. `if ! printf '%s' "$AICTRL_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then echo "::error::Non-semver release tag: $RELEASE_TAG"; exit 1; fi`. This closes the npm alias/URL spec sink and the $GITHUB_ENV write independently of the package.json comparison.
2. packages/cli/src/mcp/index.ts:150 — "Check the server and retry" advice never rediscovers
   Detail: The refresh-failure message tells the user to "Check the MCP server and retry", but retrying does not issue a new tools/list: catalogTools re-throws the cached catalog.error on every call, and the PR's own test asserts the server's tools/list count stays flat after a failed read ("failed catalog reads must not silently retry"). Recovery requires a new tools/list_changed notification from the server or an explicit reconnect — exactly what the sibling onclose message says ("Reconnect the MCP server and retry"). The instruction in this message does not match the implemented recovery path.
   Suggested fix: Align with the onclose wording: `MCP tool discovery failed for server "${serverName}". Reconnect the MCP server and retry.` — the existing tests match on the unchanged "MCP tool discovery failed for server" prefix, so they keep passing.
3. packages/cli/src/mcp/index.ts:166-167 — Server swap mid-turn kills the in-flight turn
   Detail: catalogTools throws "MCP connection changed ... Retry the invocation" when add()/disconnect() replaces a client while tools() is awaiting that server's catalog.refresh. Because tools()/toolEntries() Promise.all every server, replacing or reconnecting ANY server during an in-flight turn (e.g. finishAuth() re-adds the server after an OAuth callback, or a concurrent MCP.add()/disconnect() in-process) rejects the entire turn with a session error, where the previous implementation still returned a usable toolset for the duration of the call. The error is actionable and covered by tests, but a routine reconnection now aborts the active model turn.
   Suggested fix: When the post-await identity check fails, re-resolve the client by name from state (s.clients[serverName]) once and use the fresh client's catalog instead of throwing; or have tools()/toolEntries() tolerate replaced catalogs for the current call, since the next turn picks up the new client.
4. packages/cli/src/mcp/index.ts:576 — Discovery and refresh resolve timeouts differently
   Detail: The same tools/list operation now runs under three different timeout resolutions. create()'s initial discovery uses mcp.timeout ?? DEFAULT_TIMEOUT (30s, line 551); notification-driven refreshes use mcp.timeout ?? cfg.experimental?.mcp_timeout captured once at create time (line 576) — undefined there falls back to the MCP SDK's own 60s request default; and per-tool execution re-derives configuredTimeout() on every tools() call. With only experimental.mcp_timeout configured, startup discovery times out at 30s while refreshes use the experimental value; with neither configured, a hung refresh can wait 60s where startup waited 30s. The refresh timeout is also frozen at create time, so later config changes never affect it, while tool-call timeouts track current config — the same fact (a server's timeout) derived in multiple places with different lifetimes.
   Suggested fix: Compute one resolver, e.g. `const discoveryTimeout = mcp.timeout ?? cfg.experimental?.mcp_timeout ?? DEFAULT_TIMEOUT`, and use it for both the initial withTimeout(mcpClient.listTools(), ...) call and registerNotificationHandlers (or store it in the ToolCatalog entry) so discovery, refresh and config updates share identical, current timeout semantics.
5. packages/cli/src/mcp/index.ts:594 — Server health now has two diverging sources of truth
   Detail: This PR makes catalog.error the authoritative failure signal for tools: status() synthesizes {status:"failed"} from it (line 594) while s.status[key] still says "connected", and prompts() (line 700) / resources() (line 721) still gate solely on s.status === "connected". After a transport close (client.onclose at line 130 sets only catalog.error) or a failed notification refresh, status() reports the server "failed" while prompts()/resources() keep invoking listPrompts/listResources on the dead client on every call; their per-call .catch swallows the error, so the server's prompts/resources silently vanish. Two hand-synced views of server health now coexist in one module and disagree in exactly the states this PR adds; they are kept in sync only by hand at the create/add/disconnect sites.
   Suggested fix: Derive from one source: either have the onclose handler / refreshCatalog also write s.status[serverName] = { status: "failed", error } (and restore "connected" on successful refresh) so prompts()/resources() observe the failure through the existing gate and status() needs no catalog lookup; or gate prompts()/resources() on the same catalog error via a shared health helper (e.g. serverHealth(client, name) reading toolCatalogs.get(client)?.error).
6. packages/cli/src/mcp/index.ts:654-658 — One failed MCP server fails tools() for all servers
   Detail: tools() and toolEntries() (lines 681-686) Promise.all over per-server catalogTools, so a single server's cached catalog.error (failed refresh or closed connection) rejects the entire call: every model turn errors and healthy servers' tools are withheld until the failed server happens to send another tools/list_changed notification or the operator reconnects it. With multiple failed servers, Promise.all surfaces an arbitrary first rejection, hiding the other culprits from the error message. Failing the turn is the documented intent, but the blast radius spans unrelated servers and the reported error names only one of possibly several. Repro: with servers A (healthy) and B (dropped connection), the next model turn rejects with only B's error and A's tools are never dispatched, on every subsequent turn.
   Suggested fix: Use Promise.allSettled over the per-server catalogTools calls and throw an aggregated error listing every failed server name and its message, so operators see all servers needing attention instead of an arbitrary first rejection.
7. packages/cli/src/mcp/index.ts:681-686 — tools() and toolEntries() duplicate the catalog fetch
   Detail: tools() (lines 652-658) and toolEntries() (lines 681-686) each hand-roll the identical Promise.all(Object.entries(s.clients).map(...catalogTools...)) fetch over the same client set. The file already establishes the "single source of truth" convention for the shared key format (mcpToolKey, with its own doc comment); the shared catalog-fetch block is copy-pasted instead, so any future change to iteration or health handling must be applied twice and can silently diverge between dispatch and catalog.
   Suggested fix: Extract a shared helper, e.g. `async function catalogsForClients(): Promise<{ name: string; client: MCPClient; tools: MCPToolDef[] }[]>`, consumed by both tools() and toolEntries(), matching the existing mcpToolKey single-source-of-truth pattern.
📋 Out-of-diff findings (7)
Sev Location Finding
⚪ .github/workflows/publish.yml:193-206 Validate semver tag before npm spec and GITHUB_ENV
⚪ packages/cli/src/mcp/index.ts:150 "Check the server and retry" advice never rediscovers
🟡 packages/cli/src/mcp/index.ts:166-167 Server swap mid-turn kills the in-flight turn
🟡 packages/cli/src/mcp/index.ts:576 Discovery and refresh resolve timeouts differently
🟡 packages/cli/src/mcp/index.ts:594 Server health now has two diverging sources of truth
🟡 packages/cli/src/mcp/index.ts:654-658 One failed MCP server fails tools() for all servers
⚪ packages/cli/src/mcp/index.ts:681-686 tools() and toolEntries() duplicate the catalog fetch

Reviewed 9 files · 0 inline · view all 7 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov

Copy link
Copy Markdown
Contributor Author

Review response — PR #128

Verified all seven findings from the catalog-cache review. Keep the requested minimal scope: fix recovery advice and the inconsistent health checks; retain the intentional fail-fast behavior and existing timeout policy.

Issues addressed (pushed to this PR)

  • Server health now has two diverging sources of truth — one clientStatus derivation now serves status, prompts and resources. Failed refreshes and closed connections suppress optional RPCs; successful notification recovery and reconnect restore them. (commit fb096d9355181135cf0137cac511e10c3d88eb65)
  • Retry advice never rediscovers — the refresh error now explicitly tells the operator to reconnect the MCP server and retry. (commit fb096d9355181135cf0137cac511e10c3d88eb65)

Review claims verified false (no change needed)

None. The other observations describe actual code; their proposed changes are intentional policy alternatives or optional improvements.

Intentional behavior and optional improvements (unchanged)

  • Server swap mid-turn kills the in-flight turn — Failing an invalidated in-flight catalog is intentional; silently using old bindings or a reduced snapshot would violate the complete-toolset guarantee.
  • Validate semver tag before npm spec and GITHUB_ENV — Independent semver validation is optional hardening; package-version equality before publication and needs: publish already block crafted nonmatching tags.
  • Discovery and refresh resolve timeouts differently — Different lifetimes preserve existing policy: startup retains its 30-second connection deadline; notified refresh uses explicit configuration or the bounded SDK default; calls retain current configuration.
  • One failed MCP server fails tools() for all servers — Rejecting the entire model turn is the intended complete-toolset guarantee. Aggregating all failing servers is optional diagnostics rather than a correctness requirement.
  • tools() and toolEntries() duplicate the catalog fetch — Both small projections already use the same catalogTools reader and mcpToolKey formatter; another iteration helper is optional abstraction.

Not addressed here

No deferred fixes. Five informational/policy observations are intentionally left unchanged, as explained above.

Verification

The real SDK regression failed at reviewed head: status reported failure while both prompt/resource request counters increased. The corrected regression proves healthy RPCs, suppression after discovery failure or closure, and restoration after notification recovery or reconnect. A programmatically added server absent from config remains usable. Full workspace: 1,575 passed, 7 skipped, 0 failed. Focused suite: 86 passed; isolated MCP suite: 23 passed; compiled 0.4.5 headless fixtures: 6 passed. Native build, workspace typecheck, frozen install and formatting passed. Exact-head CI is monitored separately.

The platform records have null GitHub comment IDs. Each exact finding ID was joined uniquely against the current review's file, starting line and complete normalized claim title; no location-only ambiguity was accepted.

@byapparov
byapparov merged commit 9fb930f into main Oct 6, 2026
4 checks passed
@byapparov
byapparov deleted the fix/mcp-discovery-failure branch October 6, 2026 09:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: MCP discovery failures silently remove model tools Chore: harden publish.yml release checks

1 participant