Skip to content

fix(mcp): own discovery by connection generation - #2663

Merged
Astro-Han merged 7 commits into
apache:mainfrom
me2seeks:fix/1650-mcp-discovery-transaction
Aug 19, 2026
Merged

fix(mcp): own discovery by connection generation#2663
Astro-Han merged 7 commits into
apache:mainfrom
me2seeks:fix/1650-mcp-discovery-transaction

Conversation

@me2seeks

@me2seeks me2seeks commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Complete the generation-owned MCP Tool discovery transaction left as a follow-up to #1661.

  • install the generation-fenced tools/list_changed handler before initial discovery
  • route initial discovery, explicit refreshes, and notifications through one per-generation change epoch and single-flight owner
  • prepare each tool's output-schema validation at its first call: an unusable definition fails only that tool's invocation, while a failed refresh still preserves the previous callable snapshot
  • recheck generation authority after synchronous status listeners and remove the superseded parallel revision field

This keeps discovery, callable bindings, and snapshot publication under one manager-owned transaction without adding another notification queue or public interface.

Refs #1650

Verification

  • npm test — all workspace tests passed
  • npm --workspace @maka/mcp test — 67 passed
  • npm run typecheck
  • npm run lint
  • npm run format:check
  • npm run check:third-party-notices

Review focus

The key invariant is that a candidate can publish only while its connection generation and change epoch are still current. Tests cover a notification during initial discovery, a synchronous listener retiring the generation, a malformed output schema that fails only its own tool call before any wire round trip, and a refresh queued by a synchronous status listener during publication.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: OpenAI Codex (Maka) contributed implementation, regression tests, review remediation, and verification under the author’s direction.

Final squash trailer: Generated-by: Maka

@me2seeks
me2seeks force-pushed the fix/1650-mcp-discovery-transaction branch from 4bbc2fd to a0e4a94 Compare August 12, 2026 14:17

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Codex automated review

I reviewed exact head a0e4a944874e4d561730c4c7751f8d6c0ca1dc0c across initial discovery, explicit refresh, tools/list_changed, reconnect/disconnect, stale-client closure, schema preparation, snapshot/binding publication, and status/capability listeners. I found no specific, reproducible P0–P3 issue.

The single generation-owned discovery state is a real simplification: epoch coalescing suppresses superseded lists, ownership is checked before and after publication, and invalid preparation leaves the previous callable snapshot intact. The three-file change is one cohesive transaction and I did not find a useful split or low-value test block to remove. Current checks are green.

Disclosure: This is an automated review performed by Codex using delegated adversarial review passes and a final evidence check. It has not been independently verified by Astro-Han or another human reviewer, does not constitute human approval, and does not represent the final judgment of a human reviewer.

@me2seeks
me2seeks force-pushed the fix/1650-mcp-discovery-transaction branch from a0e4a94 to 6bf555d Compare August 13, 2026 14:30

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Following up my earlier Codex pass with a deeper look, since that one reported no findings and this PR carries a behaviour change. The concurrency design holds up — that part of the earlier conclusion stands, and I now have evidence for it rather than an absence of findings. What it missed is a blast-radius regression and the fact that the PR's own headline invariant is not pinned by its tests.

Concurrency: verified, not just unrefuted. 90 seeded fuzz iterations mixing refreshTools / list_changed / reconnect / disconnect / sync produced zero stale publishes, zero snapshot-status divergences, zero hangs. Specifically confirmed correct: epoch coalescing (10 notifications during a held list collapse to exactly one follow-up), single-flight (3 concurrent refreshes → 2 lists, all three get the newest result), stale rounds always rejecting during disconnect/reconnect, the post-publish ownership recheck catching a synchronous listener that retires the generation, and ABA being impossible since ownership compares discoveryState by object identity. Preparation failure keeping the previous snapshot is atomic and real — snapshot identity unchanged, revision unchanged, old bindings still callable.

One thing blocks: a single bad outputSchema now takes down the whole server. Same probe against both trees:

origin/main this PR
refresh with one bad tool among three ok, ["echo","bad","other"] whole refresh rejects
fresh connect with one bad tool connected, 3 tools usable error, 0 tools, transport closed

The bar is low. {type:"string",pattern:"["} does it, and so does any tool declaring draft-04 — the SDK's _engineFor throws for any $schema outside 2020-12 / 2019-09 / draft-07 / draft-06, which is common in shipped MCP servers. On refresh the tool list then freezes permanently with status.error stuck, because every subsequent list_changed fails identically.

"Prepare validation fully before publishing" is the right goal and I don't want to lose it. The implementation is all-or-nothing, which is the opposite failure from silently dropping bad tools but not obviously better: one malformed advertisement disables every healthy tool on that server, with no recovery path short of the server changing what it advertises. Dropping just the offending tool from the published snapshot and surfacing a per-tool diagnostic keeps the guarantee for everything callable. Detail inline.

Note that the deleted rejects a malformed output schema before invoking the Tool was the test pinning the old blast radius, so this is a deliberate replacement rather than an oversight — it just deserves to be an explicit decision in the description rather than a side effect of the new preparation order.

The headline invariant is only half-pinned. The PR states that a candidate may publish only while its connection generation and change epoch are current. Mutation testing says the epoch half is solid — four separate mutations all go red. The generation half does not: deleting the entire generation fence on the tools/list_changed handler changes nothing, and the three identity clauses in ownsToolDiscovery can be removed together while every test stays green (so can the closing/isClosed pair, in the other direction). Only turning the whole guard into true reddens anything. So the tests pin "some ownership check exists", not "ownership is by connection generation". Inline.

Unrelated to this PR, worth its own issue. A server that emits tools/list_changed after every tools/list response livelocks discovery — I measured ~2000 list round trips in 4 seconds with sync() never settling and the manager unable to even remove that server. I initially took this for a regression from installing the handler before initial discovery; it is not. origin/main produces the same 2000-odd loop under the same probe. Pre-existing, and it recovers as soon as the server stops notifying, but a bound on the retry loop would be worth having, and this PR is a reasonable place to note it rather than fix it.

Remaining P2/P3 inline. Nothing else blocks.


Review assistance: Claude Code (Opus) ran three independent fresh-eye passes — concurrency/interleaving, state machine and failure paths, and mutation-based test effectiveness — each blind to the others, in isolated worktrees. I reproduced the blast-radius table and the livelock comparison against both trees myself, and discarded one reported P0 whose origin/main baseline did not hold up. Per AGENTS.md this is AI-assisted review and does not constitute the independent human review this change still needs.

中文

接着我之前那次 Codex 评审再深入看了一轮——那次报告没有发现,而这个 PR 带有行为变更。并发设计经得起检验,之前结论的这一部分成立,而且现在我有正面证据而不只是「没找到问题」。它漏掉的是一处爆炸半径回归,以及这个 PR 自己的核心不变量并没有被它的测试钉住。

并发:是验证过的,不只是没被推翻。 90 次 seeded fuzz,混合 refreshTools / list_changed / reconnect / disconnect / sync,零次陈旧发布、零次快照与状态分歧、零次挂起。明确确认正确的有:epoch 合并(一次 list 期间来 10 条通知,恰好合并成 1 次跟进)、single-flight(3 个并发 refresh → 2 次 list,三者都拿到最新结果)、disconnect/reconnect 期间陈旧轮次总是拒绝、发布后的所有权复检能抓住在同步监听器里退休 generation 的情况、以及 ABA 不可能(所有权按对象同一性比较 discoveryState)。准备失败时保留旧快照是原子且真实的——快照同一性不变、revision 不变、旧 binding 仍可调用。

有一处阻塞:一个坏的 outputSchema 现在会打死整台 server。 同一探针对两个源码树:

origin/main 本 PR
三个工具中一个坏 schema 的 refresh ok,["echo","bad","other"] 整次 refresh 拒绝
含一个坏工具的新建连接 connected,3 个工具可用 error0 个工具,传输被关闭

触发门槛很低。{type:"string",pattern:"["} 就够,任何声明 draft-04 的工具也一样——SDK 的 _engineFor 对 2020-12 / 2019-09 / draft-07 / draft-06 之外的任何 $schema 都直接抛错,而这在已发布的 MCP server 里很常见。走 refresh 路径时,工具列表会被永久冻结,status.error 卡住,因为之后每一次 list_changed 都以同样方式失败。

「发布前完整准备校验」这个目标是对的,我不希望丢掉它。但实现是全有全无,这是「静默丢弃坏工具」的反面,却不见得更好:一条畸形通告会让那台 server 上所有健康的工具全部不可用,而且除非 server 改变它通告的内容,否则没有恢复路径。只把出问题的那个工具从发布的快照里剔除、并给出 per-tool 诊断,就能对所有可调用的工具保住这个保证。细节在行内。

顺带说明:被删掉的 rejects a malformed output schema before invoking the Tool 正是钉住旧爆炸半径的那个测试,所以这是有意替换而非疏漏——只是它值得成为描述里的一个显式决定,而不是新的准备顺序带来的副作用。

核心不变量只钉住了一半。 PR 声明:候选只能在其 connection generation 与 change epoch 仍然当前时才能发布。Mutation testing 表明 epoch 那一半是扎实的——四处不同的 mutation 全部变红。generation 那一半则不然:把 tools/list_changed handler 上整段 generation fence 删掉,什么都不变;ownsToolDiscovery 里三个 identity 子句可以一起删掉而所有测试仍绿(反过来删掉 closing/isClosed 那一对也一样)。只有把整个 guard 变成 true 才会红。所以测试钉住的是「存在某种所有权判定」,而不是「所有权按 connection generation 判定」。细节在行内。

与本 PR 无关,值得单独开 issue。 一台在每次 tools/list 响应之后都发 tools/list_changed 的 server 会让 discovery 活锁——我实测 4 秒内约 2000 次 list 往返,sync() 永不 settle,manager 连移除这台 server 都做不到。我起初以为这是「把 handler 装在初次 discovery 之前」引入的回归,并不是:同一探针在 origin/main 上产生同样约 2000 次的循环。这是既有问题,而且 server 一停止发通知就会恢复;但给重试循环加一个上界是值得的,本 PR 是个适合记录(而非修复)它的地方。

其余 P2/P3 在行内。没有别的阻塞项。


评审协助说明:Claude Code (Opus) 跑了三轮相互隔离的 fresh-eye 审查——并发与交错、状态机与失败路径、基于 mutation 的测试有效性——彼此不知道对方的发现,各自在独立 worktree 中进行。爆炸半径那张表和活锁的双树对比是我自己复现的,另有一条被报告的 P0 因其 origin/main 基线站不住而被我剔除。按 AGENTS.md,这属于 AI 辅助评审,不构成这个改动仍然需要的独立人类评审。

Comment thread packages/mcp/src/index.ts Outdated
Comment thread packages/mcp/src/index.ts
Comment thread packages/mcp/src/index.ts Outdated
Comment thread packages/mcp/src/index.ts Outdated
Comment thread packages/mcp/src/tool-output-validation.ts Outdated
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary

This PR makes MCP tool discovery owned by the connection generation. It installs the generation-fenced tools/list_changed handler before initial discovery. Initial discovery, explicit refreshes, and notifications use one per-generation change epoch and single-flight refresh owner.

The PR preserves the last callable tool snapshot when refresh fails or refresh notifications exceed the retry budget. It validates each tool output schema during its first invocation. A malformed schema affects only that tool call.

The changes extend the existing discovery and snapshot paths. They do not create a parallel discovery path. The shared refresh owner handles initial discovery, explicit refreshes, and notification-triggered refreshes.

The solution is the smallest coherent design shown by the diff. The shared owner prevents duplicate requests. Generation checks prevent retired connections from publishing state. Rechecks after synchronous status listeners prevent retired generations from continuing. Per-call validator creation prevents $id collisions between independently prepared schemas. Abort handling preserves cancellation during initial discovery.

The tests cover:

  • Notifications during initial discovery.
  • Refreshes requested during snapshot publication.
  • Recovery after a failed refresh.
  • Notification storms and refresh-budget suppression.
  • Synchronous connection retirement.
  • Independent validation of colliding output-schema $id values.
  • Behavior across both supported transports.

No safe deletion or simplification is evident. The removed toolSnapshotRevisionValue field reduces obsolete state. The added tests provide regression coverage for race conditions, connection retention, refresh recovery, and schema validation.

Complexity delta

  • Authorities: Removes the superseded revision authority. Uses connection generation, the per-generation change epoch, and the shared refresh owner.
  • States: Adds initial-refresh state and abort-aware refresh handling. Preserves the last valid callable snapshot during failed or suppressed refreshes.
  • Branches: Adds handling for initial discovery, cancellation, generation retirement, synchronous status-listener effects, refresh failure, and notification storms.
  • Configuration: Adds no configuration.
  • Public surface: Adds no exported or public entities.
  • Test-maintenance burden: Adds focused race, transport, notification-storm, recovery, and schema-validation tests.

Total maintenance complexity appears justified and likely decreases because the PR removes parallel revision state and consolidates discovery ownership.

Validation

The PR reports successful workspace tests, MCP tests, type checking, linting, formatting checks, third-party notice checks, and five consecutive runs of the race-focused tests. The reported MCP test result is 66/66. Direct evidence for the final check results is not available in the provided context, so required checks remain unverified here.

Review-relevant risks

The PR changes user-visible MCP behavior by changing tool discovery timing, refresh coalescing, cancellation, failure recovery, notification suppression, and output-schema validation. Material changes in these areas require independent human review under repository policy.

No public API declaration changes, security changes, licensing changes, release changes, or governance changes were identified in the current diff. The person performing the merge reviews the final diff, and a maintainer makes the final determination.

Walkthrough

The PR unifies initial MCP tool discovery with refresh handling, adds cancellation and race recovery, and publishes current snapshots after queued refreshes. It also creates output-schema validators per prepared tool and adds regression coverage.

Changes

MCP refresh and validation updates

Layer / File(s) Summary
Refresh state and connection setup
packages/mcp/src/index.ts
Refresh options now include initial-discovery and abort-signal state. Connection setup registers notification handling before initial discovery.
Initial discovery and refresh execution
packages/mcp/src/index.ts, packages/mcp/src/__tests__/manager.test.ts
Initial discovery uses the shared refresh loop. The loop propagates cancellation, coalesces queued refreshes, validates connection state, publishes snapshots, handles suppression and failures, resets idle notification budgets, and clears refresh state. Tests cover notification races, refresh recovery, and both transports.
Per-tool output validator isolation
packages/mcp/src/tool-output-validation.ts, packages/mcp/src/__tests__/tool-output-validation.test.ts
Each prepared tool now receives a new output validator. Tests verify that colliding schema identifiers remain isolated.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 17760

The MCP discovery changes are merge-ready after normal checks and review; the only identified concern is optional defensive hardening for an unreachable refresh path, so no actionable merge-blocking risk remains.

Sequence Diagram(s)

sequenceDiagram
  participant MCPClient
  participant startToolRefresh
  participant refreshToolLoop
  participant ToolServer
  participant ToolSnapshot
  MCPClient->>startToolRefresh: Start initial discovery
  startToolRefresh->>refreshToolLoop: Pass discovery and abort options
  refreshToolLoop->>ToolServer: Request tools/list
  ToolServer-->>MCPClient: Send tools/list_changed
  MCPClient->>startToolRefresh: Queue notification refresh
  ToolServer-->>refreshToolLoop: Return tool descriptors
  refreshToolLoop->>ToolSnapshot: Publish the latest snapshot
Loading

Possibly related PRs

Suggested reviewers: jackwener, m4n5ter, xonkelx

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Ai Use Disclosure ⚠️ Warning The PR description selects neither AI-use declaration, and all six introduced commits lack a standalone Generated-by trailer. If generative tooling made a substantive contribution, select that declaration, name the tool and scope, and add trailers to affected commits; see CONTRIBUTING.md’s “Human ownership and AI attribution” section. Ensure trailers survive sq...
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: MCP discovery ownership by connection generation.
Description check ✅ Passed The description covers the required summary, verification, AI use, checklist, behavior change, and review focus details.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed exact head bf35afa3602d36ba9ce1f48390f4104633d8a895, the complete diff, CI, all previous review threads and author replies, and the current MCP discovery lifecycle.

The output-schema blast radius, refresh-gate settlement window, failed-refresh release, and cross-tool $id collision findings are fixed on this head. Per-tool call preparation and isolated Ajv compilation are sensible corrections.

One P2 remains in the PR’s core claim: initial discovery is still not owned by the same notification/refresh transaction.

connectEntry() awaits the first tools/list before installing the manager’s notifications/tools/list_changed handler. I reproduced this with a real SSE MCP transport:

  1. the server begins the initial tools/list;
  2. it sends tools/list_changed;
  3. it returns the old list;
  4. the client receives the notification;
  5. only then does the manager install its handler.

Both the base and this head perform one list request and publish ["old"]; no follow-up discovery occurs. This is not a new regression, but the PR explicitly claims to install the generation-fenced handler before initial discovery and route initial discovery, explicit refresh, and notifications through one owner. The latest implementation still leaves initial discovery as a parallel path.

The minimal root fix is to install the generation-owned handler first and make initial discovery enter the same coalescing/single-flight owner as later refreshes. Merely moving the handler earlier while retaining two concurrent discovery paths would leave another race.

I would hold approval until that stated invariant is actually true and covered by a response-before-notification transport regression. No PR split is needed.

Disclosure: Codex coordinated independent read-only review passes and a final real-transport reproduction on the exact head. The human contributor remains responsible for verifying the evidence and deciding whether to merge.

中文

既有 schema、gate 和 $id 问题已修。仍有一条 P2:初次 discovery 依然先 list、后安装通知 handler。真实 SSE 探针证明响应前通知会被丢弃并发布旧 snapshot。应让初次 discovery 真正进入同一个 generation-owned transaction。

@me2seeks
me2seeks force-pushed the fix/1650-mcp-discovery-transaction branch from bf35afa to 32bea79 Compare August 18, 2026 10:19
@me2seeks

Copy link
Copy Markdown
Contributor Author

The remaining P2 is fixed at head 32bea7911 (branch rebased onto latest main 5d9a5b029, no conflicts).

Root fix, as suggested: the generation-fenced notifications/tools/list_changed handler is now installed in connectEntry() before initial discovery, and the first tools/list no longer runs as a parallel direct call — it enters the same single-flight/coalescing owner (startToolRefreshrefreshToolLoop) as explicit refreshes and notification-driven refreshes.

  • A list-changed that arrives while the first response is in flight now joins the active initial pass (pending/pendingNotification), so the loop re-lists once and the connection cannot settle on a snapshot the server already declared stale. There is no second concurrent discovery path.
  • The refresh loop's 'connected'-status guard is relaxed only for the initial-scoped state (identity invariants unchanged), and the connect abort signal is threaded into the in-flight first list, so cancelling an installation still settles promptly instead of waiting out the server — the existing cancels installation after remote tool discovery starts contract is preserved.
  • Failure semantics are unchanged: initial discovery failure still tears the connection down through the same catch; the notification handler's catch no-ops once the generation is cleared.

Regression (real SSE transport, response-after-notification): the server holds the first tools/list, changes its tool list, sends tools/list_changed, and only then answers with the stale list. The manager must publish the replacement through exactly one coalesced follow-up list (tools/list count == 2). Verified against the unfixed behavior: the notification is dropped, the snapshot stays stale, and the test fails.

Evidence: @maka/mcp 66/66 (was 65; +1 regression), the race-focused tests pass 5 consecutive runs, biome clean.

中文

初次 discovery 不再绕过事务:handler 先于初次 tools/list 安装,初次 list 进入与显式 refresh/通知相同的 single-flight owner;响应前到达的 list_changed 会合入当前 pass 并恰好补一次 list,不再被丢弃。连接期取消语义不变(signal 穿透到首个 list)。真实 SSE 回归已新增并对未修复行为验证失败。mcp 66/66,竞态用例 5 连跑全绿。

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/mcp/src/index.ts (1)

332-343: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Assign entry.refreshState before invoking the loop. Disposition: optional.

refreshToolLoop is async, so its prologue (Lines 708-728) runs synchronously until the first await. If that prologue throws, the finally at Line 791 executes before Line 342, so the entry.refreshState === state guard fails and Line 342 then installs an already-rejected state as the active gate. Every later join for the same client and generation would receive that rejected promise permanently.

The path is not reachable today: refreshToolsAfterNotification validates the notification state, suppressed, and the pass budget before it calls startToolRefresh, so the prologue cannot throw on the only path that starts with pendingNotification: true. The ordering is still fragile against future prologue checks, and the correction is one statement swap.

♻️ Assign the gate first
-    state.promise = this.refreshToolLoop(serverId, entry, state);
     entry.refreshState = state;
+    state.promise = this.refreshToolLoop(serverId, entry, state);
     return state.promise;
packages/mcp/src/__tests__/manager.test.ts (1)

151-156: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

waitFor weakens the claim this test makes. Disposition: optional.

The behavior under test is that the notification joins the initial pass, so the replacement snapshot must already be published when syncPromise resolves. waitFor at Line 152 also accepts a snapshot that lands after connect. The tools/list count of 2 still rejects a dropped notification, so coverage is not lost; asserting directly after await syncPromise pins the ownership claim more precisely.

♻️ Assert synchronously after connect resolves
       await syncPromise;
-      await waitFor(() => manager.toolSnapshot().tools[0]?.descriptor.name === 'replacement');
       assert.deepEqual(
         manager.toolSnapshot().tools.map(({ descriptor }) => descriptor.name),
         ['replacement'],
       );

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2c07dbcc-cdbd-4df9-9de0-c0e341a7ac82

📥 Commits

Reviewing files that changed from the base of the PR and between 5d9a5b0 and 32bea79.

📒 Files selected for processing (4)
  • packages/mcp/src/__tests__/manager.test.ts
  • packages/mcp/src/__tests__/tool-output-validation.test.ts
  • packages/mcp/src/index.ts
  • packages/mcp/src/tool-output-validation.ts

Included review availability: Your plan includes up to 3 reviews per rolling hour; 0 remain after this review.

Comment thread packages/mcp/src/index.ts
Comment thread packages/mcp/src/index.ts Outdated

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for consolidating initial discovery and notification refresh under one connection-generation owner. I reviewed this four-file change with an independent @reviewer pass plus a read-only ollama-cloud/deepseek-v4-flash:high pass. Installing the generation-fenced notification handler before initial discovery and committing the initial snapshot together with connected are both the right invariants.

The remaining issue is in the post-connect branch: the loop publishes each candidate snapshot before checking whether a list-changed notification arrived during the awaited tools/list. That briefly advertises exactly the snapshot the server has declared stale, and callTool can resolve a binding from it before the next pass finishes.

The first-principles/Occam model is one refresh transaction: collect candidates privately, observe pending/generation/connection ownership, and publish once only when the pass is quiescent (or at the explicit suppression boundary). Initial and later refreshes can share that same commit rule instead of having separate publication timing.

Current CI is green. No local test suite was run during this review; conclusions are based on source, test, and current CI inspection. Codex performed the final adjudication; external-model output was treated as unverified until checked against the code.

中文摘要

感谢把 initial discovery 和 notification refresh 收敛到同一个 connection-generation owner。提前安装 generation-fenced notification handler,并把 initial snapshot 与 connected 一起提交,都是正确不变量。

剩余问题在 post-connect 分支:tools/list await 期间若收到 list-changed,当前 loop 会先发布这份已被服务器声明过期的 candidate,再检查 pending 并进入下一轮;这段窗口里 callTool 可能解析到 stale binding。

最小模型是一笔 refresh transaction:candidate 保持私有,先核对 pending/generation/connection ownership,只有 quiescent 或明确 suppression boundary 才一次发布。initial 与 later refresh 可以共享同一 commit rule,不需要不同的 publication timing。

当前 CI 全绿。本次未运行本地测试套件;结论来自源码、测试与 CI 检查。外部模型输出在核对代码前均视为未验证输入。

Comment thread packages/mcp/src/index.ts
The shared SDK validator cache resolved schemas by $id, so two tools that
reused an $id shared whichever validator was registered first, across
connection generations and server trust boundaries. Compile per preparation
so each published tool owns its validator.
A promise .finally cleared the refresh state one microtask after the loop
settled, so a refresh requested from a subscriber queued during publication
joined a settled promise and received descriptors from a list that started
before it asked. Clear the gate in the loop's finally so every exit releases
it before the promise settles, and pin the failure-then-success release.
The generation-fenced list-changed handler was installed only after the
first tools/list completed, and initial discovery ran as a direct list
outside the single-flight refresh owner. A tools/list_changed that arrived
while the first response was still in flight was dropped, so the connection
settled on a snapshot the server had already declared stale, and no
follow-up discovery occurred.

Install the handler before initial discovery and run the first list through
startToolRefresh/refreshToolLoop with an initial-scoped state: the connected
status guard is relaxed only for that state, the connect abort signal is
threaded into the in-flight first list so cancelling an installation still
settles promptly, and a notification received during the pass joins the
same coalescing owner as later refreshes.

Regression: a real SSE transport holds the first tools/list, changes its
list, sends the notification, and only then answers with the stale list;
the manager must publish the replacement through exactly one follow-up
list. The test fails against the previous behavior (notification dropped,
snapshot stays stale).
Prepare initial discovery snapshots without exposing them while the client is still connecting. Commit the callable snapshot with the connected status, and degrade notification storms to the last valid initial result instead of tearing down a live server.
@me2seeks
me2seeks force-pushed the fix/1650-mcp-discovery-transaction branch from 18d9127 to 85e39fb Compare August 18, 2026 15:37

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The problem and owner are correct: initial discovery, explicit refresh, and notifications should share one generation-fenced publication transaction. Two concrete P2 gaps remain in that state machine. The notification budget is connection-lifetime rather than burst-scoped, and synchronous onChange re-entry happens after the final pending/authority check, so a queued refresh can be lost or a retired generation can still resolve successfully. Both belong in the existing refresh loop; no second authority is needed.

AI-assisted review: Codex coordinated two independent reviewer passes and an OpenCode Go DeepSeek V4 Flash high-effort adversarial pass. I verified exact head 85e39fb372a3a5b1338377318a73187eee013724, the synchronous listener seam, refresh/generation state, and current CI. No local tests were run.

中文审查

问题定义和责任层正确:initial discovery、显式 refresh 与 notification 应共享同一个 generation-fenced 发布事务。当前还有两个 P2:notification budget 实际绑定整个连接生命周期,而不是一次 burst;同步 onChange 重入发生在最后一次 pending/authority 检查之后,会丢掉排队刷新,或让已退休 generation 仍成功返回。两项都应在现有 refresh loop 内修复,不需要第二套权威。

本次为 AI 辅助审查,已核验精确 head、同步 listener seam、刷新/generation 状态和当前 CI;未运行本地测试。

Comment thread packages/mcp/src/index.ts
Comment thread packages/mcp/src/index.ts
@Astro-Han

Copy link
Copy Markdown
Contributor

/agentic_review

@qodo-code-review

qodo-code-review Bot commented Aug 18, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Initial refresh misses owner ✓ Resolved 🐞 Bug ≡ Correctness
Description
Fix-now: refreshTools() waits for connect() to finish before consulting entry.refreshState, so
an explicit refresh made while initial tools/list is in flight cannot join that generation's owner
and starts a second list after connection. This contradicts the PR's stated single-flight behavior
and is reproducible by holding initial discovery, calling refreshTools(), releasing it, and
observing two discovery requests instead of one.
Code

packages/mcp/src/index.ts[R318-319]

+    const result = await this.startToolRefresh(serverId, entry, client, connectionGeneration);
+    return result.descriptors;
Relevance

●●● Strong

Directly violates the PR’s stated initial-discovery single-flight invariant; recent MCP precedent
accepts real notification refresh race coverage.

PR-#1661
PR-#2989

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Initial discovery now installs a refresh owner before awaiting its list, but the public refresh path
awaits the whole connection before it checks that owner. Since connectEntry() resolves only after
initial refresh and publication, the later startToolRefresh() cannot join the original promise and
opens another pass.

packages/mcp/src/index.ts[220-234]
packages/mcp/src/index.ts[299-319]
packages/mcp/src/index.ts[322-350]
packages/mcp/src/index.ts[578-615]
packages/mcp/src/tests/manager.test.ts[136-178]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An explicit refresh requested during initial discovery waits for connection completion and then starts a redundant second discovery instead of joining the active generation-owned refresh.

## Issue Context
Reuse the existing `refreshState` single-flight seam; no new queue, state, or public behavior is needed. Check for the generation-matching active owner before awaiting `connect()`, and return its descriptors when present; add a regression test with initial `tools/list` held in flight.

## Fix Focus Areas
- packages/mcp/src/index.ts[299-319]
- packages/mcp/src/__tests__/manager.test.ts[136-193]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Listener refresh gets dropped ✓ Resolved 🐞 Bug ≡ Correctness
Description
Fix-now: during post-connect publication, refreshToolLoop() checks state.pending before
update() synchronously invokes listeners, then returns without checking it again. If an onChange
listener directly calls refreshTools(), that call joins the active promise and sets pending, but
no second tools/list occurs, so the explicit refresh request is silently satisfied by a list that
began before the request.
Code

packages/mcp/src/index.ts[823]

+        return finish(snapshot, false);
Relevance

●●● Strong

Synchronous listener reentrancy can set pending after the sole check; recent MCP precedent accepts
refresh race and notification correctness fixes.

PR-#1661
PR-#2989

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Manager listeners execute inline. The active-owner branch sets pending and returns the same
promise, but the loop's only pending check precedes replaceToolSnapshot()/update(); the added
regression test avoids the bug by wrapping the listener's refresh in Promise.resolve().then(...),
allowing the current promise to finish and its gate to clear first.

packages/mcp/src/index.ts[165-168]
packages/mcp/src/index.ts[306-336]
packages/mcp/src/index.ts[812-830]
packages/mcp/src/index.ts[880-886]
packages/mcp/src/tests/manager.test.ts[374-395]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A refresh requested directly from a synchronous status listener is absorbed into the currently publishing pass because the loop returns without rechecking the pending flag set by that listener.

## Issue Context
Reuse the existing `pending` flag and refresh loop rather than adding a queue or authority. After synchronous publication, revalidate generation/connection authority and continue when `state.pending` was set; update the test to call `refreshTools()` directly in the listener rather than deferring it to a microtask.

## Fix Focus Areas
- packages/mcp/src/index.ts[812-823]
- packages/mcp/src/__tests__/manager.test.ts[374-400]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: 🧠 Deep: This changes a concurrency-sensitive, generation-owned discovery state machine across initial sync, notifications, refresh coalescing, publication, cancellation, and per-tool schema validation, creating multiple independent, easy-to-miss correctness risks.

Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread packages/mcp/src/index.ts
Comment thread packages/mcp/src/index.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/mcp/src/index.ts (1)

342-353: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Assign entry.refreshState before you start the loop.

refreshToolLoop is called at Line 351, and entry.refreshState = state is set at Line 352. The loop body runs synchronously up to the first await at Line 775. If that prefix throws (notification state mismatch or budget exhaustion at Lines 751-769), the finally at Line 842 runs before Line 352 executes. The guard entry.refreshState === state is then false, so the gate is not cleared. Line 352 afterwards installs a state whose promise is already rejected, and every later refreshTools or notification joins that settled rejection for the life of the connection.

The current callers make that prefix unreachable: refreshToolsAfterNotification re-checks suppression and the pass budget synchronously before it calls startToolRefresh, and non-notification passes never enter that branch. So this is not a reachable defect today. Disposition: optional. The reordering removes the dependency on that caller-side invariant at no cost.

♻️ Install the gate before the loop can settle
-    state.promise = this.refreshToolLoop(serverId, entry, state);
     entry.refreshState = state;
+    state.promise = this.refreshToolLoop(serverId, entry, state);
     return state.promise;

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8ddadba0-7865-4513-89b8-2372f76479fc

📥 Commits

Reviewing files that changed from the base of the PR and between 18d9127 and 1776017.

📒 Files selected for processing (2)
  • packages/mcp/src/__tests__/manager.test.ts
  • packages/mcp/src/index.ts

Included review availability: Your plan provides up to 3 included reviews per hour; 1 remains after this review.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The generation-owned single-flight design is now coherent across initial discovery, explicit refresh, and tools/list_changed. Reentrant callbacks, synchronous disconnect, failed-refresh gate release, and notification bursts all have focused coverage, with no parallel discovery authority left behind.

No remaining P0-P3 findings on this exact head. The current Desktop E2E failure is in the unrelated /side command menu spec rather than this packages/mcp change; it still needs a successful rerun before merge.

AI-assisted review disclosure: Codex reviewed exact head 7da2d17, the concurrency/error paths, focused tests, current CI, and existing thread state.

中文说明

初始 discovery、显式 refresh 和 tools/list_changed 现在统一由 connection generation 的 single-flight 管理;同步重入、disconnect、失败后的 gate 释放和 notification burst 都有覆盖。当前 head 没有剩余 finding。现有 Desktop E2E 失败来自无关的 /side 菜单测试,合并前仍需重跑通过。

@Astro-Han

Copy link
Copy Markdown
Contributor

Before merging, Could we add the AI tool used in this PR so the merge commits would contain this information?

@me2seeks

Copy link
Copy Markdown
Contributor Author

Added the repository-template AI use section to the PR body, naming OpenAI Codex (Maka), its implementation/test/review scope, and the Generated-by: Maka final squash trailer. I left the approved code head unchanged.

@Astro-Han
Astro-Han merged commit 6635263 into apache:main Aug 19, 2026
25 of 26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants