Skip to content

fix(auth): keep the OAuth listTools request alive across every phase - #241

Closed
umutkeltek wants to merge 6 commits into
openclaw:mainfrom
umutkeltek:fix/daemon-listtools-progress-heartbeat
Closed

fix(auth): keep the OAuth listTools request alive across every phase#241
umutkeltek wants to merge 6 commits into
openclaw:mainfrom
umutkeltek:fix/daemon-listtools-progress-heartbeat

Conversation

@umutkeltek

@umutkeltek umutkeltek commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Resubmission of #237 with the correctness gap fixed and a clean history, now updated for the Codex and ClawSweeper review round.

The gap in #237

resolveOperationSocketBudget sized the listTools socket at 2 * timeoutMs + 5s: one phase for OAuth, one for a single tools/list. But McpRuntime.listTools() walks nextCursor and gives every page its own timeoutMs, so an OAuth wait plus two or more slow pages outlives the socket. The client sees a transport timeout, restarts the daemon, resends the listing, and the user gets the second OAuth flow this PR exists to prevent.

Any constant multiple has the same defect — it encodes a phase count the runtime does not promise.

The fix: observe the daemon instead of predicting it

While a request is in flight the daemon host emits newline-delimited progress frames. The client treats each frame as proof of life and restarts its socket deadline. That turns the socket deadline from an operation budget into a liveness budget:

  • a request survives however many sequential phases it needs — OAuth wait plus N pages — with no arithmetic on either side;
  • a daemon that actually goes silent is still torn down and restarted, exactly as before;
  • the operation deadline stays where it belongs: the daemon enforces it and answers operation_timeout, which the client does not retry.

resolveOperationSocketBudget and its 5s grace are gone. The listTools socket uses the caller deadline verbatim. Both peers decode frames incrementally (DaemonFrameDecoder), so the daemon's own status probe and stop handshake stay readable and a response with no trailing newline still parses. The daemon protocol is versioned, so a daemon started before this change is replaced rather than left speaking the old wire format.

Real behavior proof

A local OAuth-protected MCP server (dynamic client registration, PKCE S256, authorization code, bearer-gated /mcp) that paginates tools/list across 4 pages at 4s each. mcporter auth runs the real interactive browser flow — the browser launch is shimmed so approval lands 4s after the URL opens — routed through the real keep-alive daemon (lifecycle.mode = keep-alive, confirmed daemon pid). MCPORTER_OAUTH_TIMEOUT_MS=6000, so every individual phase is comfortably inside its deadline and only the total exceeds a fixed budget.

Before — 462865b, the head that was closed (2 * 6000 + 5000 = 17s socket budget):

[14:45:41.300] mcp -> 401 (no valid bearer token), advertising protected resource metadata
[14:45:45.352] authorize -> issuing code, redirecting to loopback callback
[14:45:45.362] token -> PKCE verified, access token issued
[14:45:45.388] tools/list page 1/4
[14:45:49.394] tools/list page 2/4
[14:45:53.398] tools/list page 3/4
[14:45:57.402] tools/list page 4/4
[14:45:58.729] tools/list page 1/4      <-- socket expired at ~17s; daemon restarted, listing replayed
[14:46:02.732] tools/list page 2/4
[14:46:06.735] tools/list page 3/4
[14:46:10.737] tools/list page 4/4

CLI exit 0, elapsed 34s
tools/list page 1 requests : 2
tools/list requests total  : 8

After — this branch:

[14:44:51.258] mcp -> 401 (no valid bearer token), advertising protected resource metadata
[14:44:55.771] authorize -> issuing code, redirecting to loopback callback
[14:44:55.777] token -> PKCE verified, access token issued
[14:44:55.795] tools/list page 1/4
[14:44:59.800] tools/list page 2/4
[14:45:03.805] tools/list page 3/4
[14:45:07.810] tools/list page 4/4

CLI exit 0, elapsed 21s
tools/list page 1 requests : 1
tools/list requests total  : 4

One authorization, one completed listing, no replay, no daemon restart. The replay in the "before" run begins 17.3s after the listing started, which is exactly the 2 * timeoutMs + 5s budget — the failure mode described in the #237 review, reproduced and then removed.

(The provider is a local fixture rather than a third-party service so the run is inspectable and repeatable; it exercises the real SDK OAuth client path — discovery, registration, PKCE, code exchange, bearer-gated MCP — not a stub.)

Review round: what changed

[P1] Liveness frames could outlive a wedged request. The premise as stated — that listResources/readResource have no operation timeout — does not hold: every SDK request carries DEFAULT_REQUEST_TIMEOUT_MSEC (60s) and every OAuth wait carries DEFAULT_OAUTH_CODE_TIMEOUT_MS (300s). But the underlying worry was real and I found a stronger case than the one reported: McpRuntime.listTools() walks nextCursor with no guard, so a server that repeats a cursor pages forever — and heartbeats would then keep the client waiting forever. Both are now closed:

  • operations with a fixed phase count (at most one connect plus one MCP request) carry an absolute ceiling equal to the sum of the deadlines the daemon already applies to those phases. It is derived, not guessed, and can only fire once every real deadline has been blown; a server that accepts and never answers now yields a non-retryable operation_timeout instead of an indefinite wait.
  • listTools, whose page count is data-dependent, keeps its per-page deadline and gains a repeated-cursor guard in the runtime.

[P2] Short deadlines could expire before the first frame. Fixed, and slightly further than suggested: the first frame is written immediately when the request is dispatched, and the cadence is derived from the caller's own deadline (resolveProgressInterval, sent on the request envelope) rather than fixed at 250ms. An immediate frame alone would still lose a sub-250ms deadline on the second gap.

[P3] Release-owned changelog. Correct — checked against this repo's history: my own merged #234 did not touch CHANGELOG.md; the maintainer added the entry at release with the thanks @… credit line. The entry has been removed from this branch.

[P1, ClawSweeper] An upgraded client could stop a live shared daemon mid-request. The protocol-version check classified any pre-v2 daemon as stale and called stop() on it from ensureDaemon(). A second client upgrading mid-request — OAuth code wait or a delayed cursor page — could race ahead of the first client's in-flight call, kill the daemon, and force the very replay this PR is meant to remove. The replacement is now an idle-drain transition, not a preempt:

  • the host's status response reports the live in-flight count (StatusResult.activeRequests, sampled at response time so a long-running request still shows as in flight). The field is optional, so v1 daemons that cannot advertise it still answer old clients.
  • restartDaemon() polls the live daemon up to MCPORTER_DAEMON_DRAIN_TIMEOUT_MS (60s default, env-var overridable) for the counter to read zero before issuing stop. Pre-v2 daemons are treated as permanently busy, so the drain timeout is also their safety bound.
  • a busy daemon that another client replaces mid-drain is left alone — waitForDaemonIdle returns both drained and replacedByPeer, and the upgrading client bails out without stop.

If the daemon is still busy past the timeout the replacement is abandoned and the caller fails loud rather than killing the peer.

Regression coverage

tests/daemon-listtools-progress.test.ts drives the real client transport against the real host framing over a socket:

  • survives an OAuth wait followed by several paginated tools/list pages — an OAuth wait plus three delayed pages, every phase longer than the socket deadline. Asserts the tools resolve, listTools ran exactly once (no replay), one listTools request reached the daemon, and launchDaemonDetached was never called (no restart). Verified it fails without the mechanism: stubbing out the progress emitter makes it die with Daemon did not stop before restart could begin.
  • reaches a deadline shorter than the default progress interval — the P2 case.
  • emits the first progress frame before any cadence interval can elapse — pins the P2 fix at a 30ms deadline, well under the 250ms default cadence.
  • still trips the socket deadline when the daemon stops sending progress frames — silence is still fatal.
  • walks every cursor page but refuses to page forever on a repeated cursor — the P1 case.

tests/daemon-client-timeout.test.ts pins the budget itself: the listTools socket deadline equals the caller deadline, so a phase-count multiplier cannot come back unnoticed.

tests/daemon-host.test.ts pins the operation ceiling for the fixed-phase methods against a wedged runtime: callTool, listResources, and readResource all return operation_timeout after the sum of MCPORTER_OAUTH_TIMEOUT_MS and DEFAULT_REQUEST_TIMEOUT_MSEC, using fake timers so the test does not wait the production minute-plus.

tests/daemon-client-config-stale.test.ts pins the ClawSweeper P1 fix:

  • waits for a busy shared daemon to drain before sending stop — the upgrading client observes activeRequests flip to zero before issuing stop, proving no in-flight OAuth or cursor page was killed.
  • does not stop a peer fresh daemon if it replaces the busy one mid-drain — a second client that wins the race to replace the busy daemon during the drain window has its fresh daemon left alone; the upgrading client does not call stop and does not relaunch.
  • refuses to replace a daemon that never drains before the timeoutMCPORTER_DAEMON_DRAIN_TIMEOUT_MS=500 exercises the refusal branch without waiting the production minute, asserting the busy peer's daemon is left running.

Housekeeping

  • Clean commit history, no AI co-author trailers, single author.
  • Rebased onto current main.
  • pnpm check and pnpm test green locally (860 passed, 3 skipped); CI green on Ubuntu, macOS and Windows.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ab62f071c3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/daemon/host.ts Outdated
preParsedRequest
);
socket.write(JSON.stringify(response), () => {
const stopProgress = startProgressFrames(socket, preParsedRequest?.id ?? 'unknown');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Limit heartbeat frames to operations with their own deadline

When a keep-alive server accepts resources/list or resources/read but never responds, this unconditional heartbeat continues while the daemon event loop remains healthy, and the client resets its 30-second socket deadline on every frame. Those runtime methods have no operation timeout, so the CLI now waits forever instead of timing out, restarting the daemon, and retrying as it did before this commit. Restrict these heartbeats to the bounded listTools path or retain an absolute recovery deadline for the other methods.

Useful? React with 👍 / 👎.

Comment thread src/daemon/protocol.ts
// deadline, so a request stays alive for as many phases as it needs -- an OAuth
// code wait plus any number of paginated `tools/list` pages -- without the
// client having to predict how many phases there will be.
export const DAEMON_PROGRESS_INTERVAL_MS = 250;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Emit a heartbeat before short idle deadlines

When an API caller supplies ListToolsOptions.timeoutMs <= 250 (also accepted by --oauth-timeout), the client arms that same socket timeout immediately, but the host waits 250 ms before its first progress frame. Because the daemon starts the operation timeout only after receiving and dispatching the request, the socket deadline can fire first, causing invoke() to classify this as a transport failure and restart/replay the request instead of returning the non-retryable operation_timeout. Send an initial frame immediately or coordinate the interval with the requested timeout.

Useful? React with 👍 / 👎.

@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from ab62f07 to 9a296ab Compare July 27, 2026 13:57
@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P1 Urgent regression or broken agent/channel workflow affecting real users now. merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. labels Jul 27, 2026
@clawsweeper

clawsweeper Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Codex review: found issues before merge. Reviewed August 2, 2026, 9:07 PM ET / August 3, 2026, 01:07 UTC.

ClawSweeper review

What this changes

This branch forwards OAuth-aware tool-discovery timing through the CLI and local daemon, adds daemon progress frames and operation bounds, guards repeated pagination cursors, and changes daemon replacement to drain in-flight work.

Merge readiness

⚠️ Needs maintainer review before merge - 4 items remain

Keep this PR open: the previously identified P1 remains at the unchanged head, and the branch is now dirty against a substantially changed current main. The OAuth replay motivation is partly reduced by merged work, but the proposed daemon liveness behavior remains distinct and needs a corrected idle-drain implementation plus a rebase.

Priority: P1
Reviewed head: fb1901d325fa7ae4c84438b1b791e2b8d49b3846

Review scores

Measure Result What it means
Overall readiness 🦐 gold shrimp (3/6) The contributor provides strong runtime proof for the original replay case, but a confirmed P1 defect and the required current-main rebase keep the patch below merge-ready quality.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (terminal): The PR body includes an after-fix local OAuth-protected, paginated MCP run through the real keep-alive daemon, with one authorization and four tool-list requests instead of a replayed eight-request run.
Patch quality 🦐 gold shrimp (3/6) 1 actionable review finding remain.

Verification

Check Result Evidence
Real behavior Verified Sufficient (terminal): The PR body includes an after-fix local OAuth-protected, paginated MCP run through the real keep-alive daemon, with one authorization and four tool-list requests instead of a replayed eight-request run.
Evidence reviewed 5 items Unfixed active-request defect: The host increments activeDaemonRequests before dispatching every socket request, including status; the status response reads that same counter before the probe completes. An idle v2 daemon therefore reports at least one active request to the drain loop.
Prior finding remains unchanged: The current PR head is the same SHA reviewed in the prior completed cycle, so the active-request finding was not resolved by a subsequent commit.
Current-main integration drift: Current main migrated the client runtime to MCP SDK v2; its ListToolsOptions no longer exposes this branch's timeout option and its tool listing now calls the newer SDK surface directly. The PR needs a real rebase rather than merge-by-assumption.
Findings 1 actionable finding [P1] Exclude status probes from active-request accounting
Security None None.

How this fits together

MCPorter can route CLI operations through a persistent local daemon, which invokes the MCP runtime and may wait for OAuth authorization or paginated tool discovery. The changed path determines when the socket sees liveness, when an operation fails, and whether an upgraded client may replace a shared daemon.

flowchart LR
  CLI[CLI auth or list command] --> Client[Keep-alive daemon client]
  Client --> Socket[Local daemon socket]
  Socket --> Host[Daemon host]
  Host --> Runtime[MCP runtime]
  Runtime --> OAuth[OAuth authorization]
  Runtime --> Tools[Paginated tool discovery]
  Host --> Frames[Progress and result frames]
  Frames --> Client
Loading

Before merge

  • Exclude status probes from active-request accounting (P1) - status is counted before dispatch, then reads activeRequests before its own decrement. Consequently an idle v2 daemon never reports zero to waitForDaemonIdle(), so a required protocol/config replacement waits for the drain timeout and fails. This is the still-unfixed P1 from the prior review cycle.
  • Resolve merge risk (P1) - An otherwise idle compatible daemon reports the status probe itself as active, so replacement waits until MCPORTER_DAEMON_DRAIN_TIMEOUT_MS and then fails instead of stopping and relaunching it.
  • Resolve merge risk (P1) - GitHub reports this branch as dirty, and current main materially changed the runtime and daemon paths; merging without a rebase risks carrying obsolete SDK assumptions into the new runtime.
  • Complete next step (P2) - The P1 repair is mechanically clear, but the dirty branch also needs a nontrivial rebase over the MCP SDK v2 migration before an automated repair lane can safely preserve its intended behavior.

Findings

  • [P1] Exclude status probes from active-request accounting — src/daemon/host.ts:171
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Patch scope 17 files changed; 1,568 additions and 107 deletions The branch crosses CLI parsing, runtime calls, daemon protocol framing, replacement logic, and regression coverage.
Core-path drift 4 central implementation files changed on main after the PR base src/runtime.ts, daemon host/client, and the runtime wrapper all need refreshed integration review.

Merge-risk options

Maintainer options:

  1. Repair and rebase the daemon transition (recommended)
    Rebase on current main, make status sampling independent of the active application-operation count, and add an end-to-end idle replacement regression before merge.
  2. Pause the branch
    Do not merge the stale protocol implementation if the rebased design cannot retain its distinct multi-phase liveness benefit over current main.
Copy recommended automerge instruction
@clawsweeper automerge

Special instructions:
Rebase over current main, exclude status probes from active-request accounting, and add a real host/socket regression for idle daemon replacement.

Technical review

Best possible solution:

Rebase the daemon liveness design onto the MCP SDK v2 runtime, exclude control probes from active-operation accounting, and prove both idle replacement and busy-peer preservation through the real host/socket path.

Do we have a high-confidence way to reproduce the issue?

Yes — source inspection gives a high-confidence path: the host increments the active counter for status, and the status response samples it before that request's finally decrement. A real host/socket regression should demonstrate that an idle compatible daemon can then be replaced promptly.

Is this the best way to solve the issue?

No — the proposed liveness approach may still be useful, but its idle-drain implementation is incorrect and the branch must be adapted to current main's MCP SDK v2 runtime before it is a maintainable solution.

Full review comments:

  • [P1] Exclude status probes from active-request accounting — src/daemon/host.ts:171
    status is counted before dispatch, then reads activeRequests before its own decrement. Consequently an idle v2 daemon never reports zero to waitForDaemonIdle(), so a required protocol/config replacement waits for the drain timeout and fails. This is the still-unfixed P1 from the prior review cycle.
    Confidence: 0.99

Overall correctness: patch is incorrect
Overall confidence: 0.99

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against ed2dee1cf8df.

Labels

Label justifications:

  • P1: The active-request defect can make a normal keep-alive command fail during daemon protocol/config replacement.
  • merge-risk: 🚨 availability: The changed replacement path can leave an idle daemon unusable until the drain deadline is exhausted.
  • merge-risk: 🚨 compatibility: The branch introduces a daemon protocol version transition and is not currently integrated with the MCP SDK v2 runtime on main.
  • rating: 🦐 gold shrimp: Overall readiness is 🦐 gold shrimp; proof is 🦞 diamond lobster and patch quality is 🦐 gold shrimp.
  • status: ⏳ waiting on author: ClawSweeper has contributor-facing work open and is waiting for author action. Sufficient (terminal): The PR body includes an after-fix local OAuth-protected, paginated MCP run through the real keep-alive daemon, with one authorization and four tool-list requests instead of a replayed eight-request run.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes an after-fix local OAuth-protected, paginated MCP run through the real keep-alive daemon, with one authorization and four tool-list requests instead of a replayed eight-request run.

Evidence

What I checked:

  • Unfixed active-request defect: The host increments activeDaemonRequests before dispatching every socket request, including status; the status response reads that same counter before the probe completes. An idle v2 daemon therefore reports at least one active request to the drain loop. (src/daemon/host.ts:171, fb1901d325fa)
  • Prior finding remains unchanged: The current PR head is the same SHA reviewed in the prior completed cycle, so the active-request finding was not resolved by a subsequent commit. (src/daemon/host.ts:171, fb1901d325fa)
  • Current-main integration drift: Current main migrated the client runtime to MCP SDK v2; its ListToolsOptions no longer exposes this branch's timeout option and its tool listing now calls the newer SDK surface directly. The PR needs a real rebase rather than merge-by-assumption. (src/runtime.ts:60, ed2dee1cf8df)
  • Merged adjacent behavior: Current main includes merged OAuth single-flight and unauthorized no-restart behavior, which overlaps the duplicate-prompt motivation but does not implement this branch's daemon progress protocol. (src/daemon/runtime-wrapper.ts:182, e4966179b6b8)
  • Feature-history ownership: The current runtime and daemon path has been primarily maintained by Peter Steinberger, including the SDK v2 migration and recent OAuth/daemon changes. (src/runtime.ts:259, 7c4fae335619)

Likely related people:

  • steipete: Recent merged OAuth serialization, daemon behavior, and MCP SDK v2 migration commits cover the same runtime and keep-alive boundaries this branch must rebase onto. (role: recent subsystem owner; confidence: high; commits: e4966179b6b8, 7c4fae335619, cb04b4597107; files: src/runtime.ts, src/daemon/runtime-wrapper.ts, src/daemon/host.ts)

Rank-up moves

Optional improvements that raise the rating; they are not merge blockers.

  • Repair active-request accounting and add real host/socket coverage for idle daemon replacement.
  • Rebase the implementation and tests over current main's MCP SDK v2 runtime.
  • After updating the branch, provide redacted focused test output; if an automatic review does not start, ask a maintainer to comment @clawsweeper re-review.

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (29 earlier review cycles; latest 8 shown)
  • reviewed 2026-08-01T20:35:30.476Z sha fb1901d :: needs changes before merge. :: [P1] Exclude status probes from the active-request count
  • reviewed 2026-08-01T22:59:26.009Z sha fb1901d :: needs changes before merge. :: [P1] Exclude status probes from the active-request count
  • reviewed 2026-08-02T09:39:56.424Z sha fb1901d :: found issues before merge. :: [P1] Exclude status probes from active-request accounting
  • reviewed 2026-08-02T11:54:38.886Z sha fb1901d :: needs changes before merge. :: [P1] Exclude status probes from active-request accounting
  • reviewed 2026-08-02T14:30:48.029Z sha fb1901d :: needs changes before merge. :: [P1] Exclude status probes from active-request counting
  • reviewed 2026-08-02T16:41:14.924Z sha fb1901d :: needs changes before merge. :: [P1] Exclude status probes from active-request counting
  • reviewed 2026-08-02T17:39:29.741Z sha fb1901d :: needs changes before merge. :: [P1] Exclude status probes from active-request accounting
  • reviewed 2026-08-02T19:45:46.303Z sha fb1901d :: needs changes before merge. :: [P1] Exclude status probes from active-request accounting

`mcporter auth` runs the interactive OAuth browser flow inside the SDK's
`tools/list` request, but `listTools` never forwarded a per-request timeout. The
SDK's 60s DEFAULT_REQUEST_TIMEOUT_MSEC killed the request long before the 300s
OAuth code wait could finish, so slow providers could never be authorized --
every `mcporter auth` died with `MCP error -32001: Request timed out`.

Mirror the existing `callTool` timeout forwarding:

- add `timeoutMs` to `ListToolsOptions` and pass `{ timeout,
  resetTimeoutOnProgress, maxTotalTimeout }` to `client.listTools`, with
  `raceWithTimeout` as an outer guard;
- have the `auth` path pass `MCPORTER_OAUTH_TIMEOUT_MS` (default 300s) so the
  request lives at least as long as the OAuth code wait;
- carry `timeoutMs` across the daemon protocol so keep-alive servers get the
  same deadline, and report a phase that blows it as `operation_timeout` so the
  keep-alive wrapper stops treating it as a dead server worth restarting;
- version the daemon protocol so a daemon started before this change is
  replaced instead of silently dropping the new field.
@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from 9a296ab to 6d72274 Compare July 27, 2026 14:47
@umutkeltek

Copy link
Copy Markdown
Contributor Author

Thanks — both review passes found real things. All three items are addressed and the PR body now carries an inspectable before/after run. Summary:

P1 — liveness frames outliving a wedged request. The stated premise doesn't hold: listResources/readResource do have an operation timeout, because every SDK request carries DEFAULT_REQUEST_TIMEOUT_MSEC (60s, shared/protocol.js) and every OAuth wait carries DEFAULT_OAUTH_CODE_TIMEOUT_MS (300s). But chasing it turned up a stronger case than the one reported: McpRuntime.listTools() walks nextCursor with no guard, so a server that repeats a cursor pages forever — and heartbeats would then keep a caller waiting forever. Both ends are now closed:

  • fixed-phase operations (at most one connect plus one MCP request) carry an absolute ceiling equal to the sum of the deadlines the daemon already applies to those phases — derived rather than guessed, so it can only fire once every real deadline has been blown. A server that accepts and never answers now returns a non-retryable operation_timeout instead of hanging.
  • listTools — the one operation whose phase count is data-dependent — keeps its per-page deadline and gains a repeated-cursor guard.

P2 — short deadlines expiring before the first frame. Fixed, and a step further than suggested: an immediate first frame alone still loses a sub-250ms deadline on the second gap, so the cadence is now derived from the caller's own deadline (sent on the request envelope) and the first frame is written immediately on dispatch.

P3 — release-owned changelog. Correct, and I checked it against this repo's history rather than taking it on trust: my own merged #234 didn't touch CHANGELOG.md — the maintainer added the entry at release with the thanks @… credit. Entry removed.

Real behavior proof is in the PR body: a local OAuth-protected MCP server (dynamic registration, PKCE S256, bearer-gated /mcp) paginating tools/list over 4 pages, driven by a real mcporter auth browser flow through the real keep-alive daemon. On 462865b the listing is replayed — page 1 requested twice, 8 tools/list requests, 34s — with the replay starting 17.3s in, exactly the 2 * timeoutMs + 5s budget. On this branch: one authorization, 4 requests, 21s, no restart, no replay.

Each item has regression coverage; pnpm check and pnpm test are green locally (851 passed) and CI is green on Ubuntu, macOS and Windows.

@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jul 27, 2026
@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from 6d72274 to 2d6d820 Compare July 27, 2026 15:08
@umutkeltek

Copy link
Copy Markdown
Contributor Author

Fixed the remaining P2 — good catch, the finding is exact.

resolveProgressInterval() clamped the cadence up to a 25ms floor, which meant that for any caller deadline at or below 25ms the interval overshot the very deadline it was meant to refresh: the immediate frame landed, then the socket expired before the next one, and invoke() classified that as a dead transport and replayed. The floor was the one constant left in a mechanism whose whole point is not to use constants, so it is gone rather than lowered — the cadence is now min(250, max(1, floor(deadline / 3))), strictly inside the caller's deadline for every deadline above 1ms.

I did not reject short values at the input boundary instead: timeoutMs: 1 is accepted today and pinned by an existing test (clamps daemon status preflight timeout for tiny per-call timeouts), so tightening the boundary would be a separate behavior change. A 1ms deadline stays unachievable at any cadence — as it was before progress frames existed.

Regression added below the old floor, asserting the invariant directly rather than racing a timer: resolveProgressInterval(n) < n across 2, 3, 5, 12, 24, 25, 74, 75, 300, 30_000, 300_000, plus the 1ms boundary and the default-cadence fallbacks.

pnpm check and pnpm test green (853 passed, 3 skipped).

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Jul 27, 2026
Sizing the daemon socket deadline for a fixed number of phases cannot work.
`McpRuntime.listTools()` issues one `tools/list` request per cursor page and
gives each its own `timeoutMs`, so an OAuth wait followed by two or more slow
pages outlives any constant multiple of the caller deadline. The socket then
expires mid-flight, the client restarts the daemon and resends the listing, and
the user is walked through a second OAuth flow.

Stop predicting the phase count and observe the daemon instead. While a request
is in flight the host emits newline-delimited progress frames, and the client
treats each frame as proof of life and restarts its socket deadline. The deadline
becomes a liveness budget rather than an operation budget: a request survives
however many phases it needs, while a daemon that goes silent is still torn down
and restarted exactly as before.

Liveness alone would be too weak, so every operation stays provably bounded:

- the first frame goes out immediately and the cadence is derived from the
  caller's own deadline, so a deadline shorter than the default interval cannot
  expire before the daemon has proved it is alive;
- operations with a fixed phase count -- at most one connect plus one MCP
  request -- carry an absolute ceiling equal to the sum of the deadlines the
  daemon already applies to those phases, so a server that accepts a request and
  never answers yields a non-retryable `operation_timeout` rather than an
  indefinite wait;
- `listTools` keeps its per-page deadline and gains a repeated-cursor guard, so
  the one operation whose phase count is data-dependent cannot page forever.

Both peers decode frames incrementally, so the daemon's own status probe and stop
handshake stay readable, and a response with no trailing newline still parses.

Regression coverage drives the real client transport and the real host framing
over a socket: an OAuth wait followed by three delayed `tools/list` pages
resolves with one `listTools` request, no replay, and no daemon relaunch. Sibling
cases cover a deadline below the default frame interval, a daemon that goes
silent, and a server that repeats a pagination cursor.
@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from 2d6d820 to 0e37f7e Compare July 27, 2026 15:17
@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels Jul 30, 2026
The previous code's protocol-version check classified any pre-v2 daemon as
stale and called stop() on it from ensureDaemon(). A second client upgrading
mid-request -- OAuth code wait or a delayed cursor page -- would race ahead
of the first client's in-flight call, kill the daemon, and force the very
replay the liveness mechanism was added to prevent.

The fix makes the replacement an idle-drain transition, not a preempt:

* host: the status response now reports the live in-flight count, sampled
  at response time so a long-running request still shows up as in flight.
* protocol: StatusResult.activeRequests is optional so a v1 daemon that
  cannot advertise it does not break old clients.
* client: before restartDaemon() issues stop(), it polls the live daemon
  up to MCPORTER_DAEMON_DRAIN_TIMEOUT_MS (60s default) for activeRequests
  to read zero. Pre-v2 daemons are treated as permanently busy, so the
  drain timeout is the only knob for them too. If the daemon is still
  busy past the timeout, the replacement is abandoned and the caller
  fails loud rather than killing the peer.

Drain timeout is env-var overridable so the test suite can exercise the
refusal branch without waiting the full minute.
Regression coverage for the Codex/ClawSweeper review round:

* daemon-client-config-stale: two new tests pin the drain behavior. The
  first asserts a busy shared daemon is left alone until its in-flight
  count flips to zero, then replaced; the second asserts a daemon that
  never drains is left running rather than killed mid-request.
* daemon-host: three new tests pin the operation ceiling for callTool,
  listResources, and readResource against a wedged runtime, using
  MCPORTER_OAUTH_TIMEOUT_MS=50 and fake timers so the
  OAuth-timeout-plus-60s-DEFAULT_REQUEST_TIMEOUT_MSEC ceiling fires in
  milliseconds rather than the production minute-plus.
* daemon-listtools-progress: one new test exercises a 30ms caller
  deadline -- well under the 250ms default progress cadence -- to prove
  the first progress frame beats the deadline. The existing 120ms test
  already exercised this implicitly; the 30ms case makes the P2 fix
  visible in the test name.
@clawsweeper clawsweeper Bot added status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Jul 31, 2026
The earlier drain landed a contract bug: it only checked whether the
original daemon had gone idle, then immediately fell through to stop().
A second client that won the race to replace the busy daemon during the
drain would see its fresh daemon killed by the upgrading client the
moment the drain returned.

Three changes close the gap:

* client: add `probeLiveStatus` that sends a raw status probe without
  the pid-match filter `readVerifiedStatus` applies. `readVerifiedStatus`
  collapses pid mismatches into null, which would short-circuit the
  drain check and re-introduce the very regression the drain exists to
  prevent.
* client: `waitForDaemonIdle` now returns both `drained` and
  `replacedByPeer`. The pid is tracked across polls so a swap mid-wait
  is visible at the moment the drain resolves.
* client: `restartDaemon` uses `probeLiveStatus` and bails out on
  `replacedByPeer` instead of issuing `stop`.

A third regression test pins the new path: a busy daemon that another
client replaces mid-drain must not receive stop, and the upgrading
client must not relaunch a daemon of its own.
The drain handles the case where a peer swaps in a fresh daemon *while*
the upgrading client is waiting. A subtler race happens *before* the
drain even starts: a peer has already replaced the busy daemon, but the
on-disk metadata still names the old PID, so the previous early-return
check (which also required a fresh config) did not fire and the code
fell through to stop() against the peer's fresh daemon.

Make the pid-mismatch short-circuit in restartDaemon unconditional: if
the live daemon's pid does not match the expected one, do not stop it,
whether or not the metadata still reads stale. The peer owns the
replacement; the upgrading client just uses whatever daemon is live.
This subsumes the pid-mismatch + fresh-config fast path, which is
removed rather than stacked on. The transport-error path is unaffected
because expectedPid is undefined there and the guard is skipped, so the
client still self-heals when no daemon is responding.

A new regression test pins the scenario: a peer wins the swap before
the drain starts, the upgrading client must not call stop, and the
peer's daemon must keep serving.
@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels Jul 31, 2026
@steipete

steipete commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Heads-up for the next rebase: #253 just landed on main and overlaps this PR's territory. shouldRestartDaemonServer in src/daemon/runtime-wrapper.ts now returns false for unauthorized errors (restart/replay on 401 was one source of the duplicate OAuth prompts in #247), and tests/keep-alive-runtime.test.ts grew a case locking that in.

That may shrink what this PR needs to solve — the OAuth-replay motivation is partly addressed — but the core liveness-frame design for multi-phase listTools deadlines is still open and worth pursuing. The remaining review item is the P1 active-request accounting defect (a status probe counting itself as active work, preventing idle drain); once that's fixed and the branch is rebased over #253/#248, this is ready for another look.

@steipete

steipete commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Triage update, and an apology for the timing.

Your analysis holds, and the bug is still real on main. I verified it against 0.13.0: listTools reaches invoke('listTools', params) with no timeout, so it falls back to the flat DEFAULT_DAEMON_TIMEOUT_MS of 30s, while the daemon-side handler calls runtime.listTools(...) with autoAuthorize and will wait up to DEFAULT_OAUTH_CODE_TIMEOUT_MS — 5 minutes — for browser consent. There are no liveness frames in the daemon protocol. So a user who spends more than 30 seconds at the consent screen still gets a client-side transport timeout, a daemon restart, and the second OAuth prompt you set out to eliminate.

Your central point survives too: resolveOperationSocketBudget is gone, but what replaced it is a flat 30s, which is the same mistake in simpler clothing — a deadline that encodes an assumption about how long legitimate work takes. The liveness-budget framing is the right answer, and "observe the daemon instead of predicting it" is a better articulation of it than anything in the current code.

The bad news is that this branch cannot be rebased. Between your submission and now, the repo migrated to MCP SDK v2 for the 2026-07-28 protocol (#255), reworked elicitation and the daemon runtime (#256, #262), and rewrote large parts of src/daemon/{client,host,protocol}.ts, src/runtime.ts, src/cli.ts and src/cli/auth-command.ts — 17 of the files your patch touches. GitHub already marks it CONFLICTING, and the conflict is semantic rather than textual: the manual tools/list cursor walk your budget arithmetic reasoned about was replaced by the SDK's aggregating walk.

Asking you to redo 1,500 lines against a moved target would be a poor trade for your time, so I am reimplementing your design directly, with Co-authored-by: Umut Keltek <umut.keltek@gmail.com> on the commits and a changelog credit. The frame decoder, the progress-frame liveness reset, the daemon-side operation deadline with a non-retryable timeout code, and the protocol version for client/daemon skew all come from your patch — I am adapting them to the current shapes, not replacing the design.

I will link the replacement PR here and close this one once it lands. If you would rather carry it yourself against 0.13.0, say so and I will stand down — it is your work and I would rather you got the merge.

Thank you for the report and for the second iteration on it; the diagnosis was correct and it outlived the code it was written against.

@steipete

steipete commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Landed as #270 (5ea1970), with your Co-authored-by on the commit and a changelog credit under 0.13.1.

The implementation follows your design: progress frames from the daemon, an idle deadline on the client that they refresh, a daemon-owned operation deadline returned as a non-retryable error so a slow request is never replayed into a second prompt, and incremental frame decoding so split and coalesced writes parse. Version skew is handled in both directions — progress emission requires an explicit v2 opt-in, so an old client never sees a frame it cannot parse, and a new client talking to a daemon that outlived its build simply retains the flat deadline.

One detail from verification you may find interesting: Node's socket.setTimeout is an inactivity timer, so incoming progress data resets it natively. The explicit re-arm on the client is redundant belt-and-braces — the real mechanism is the daemon writing at all. I confirmed that by mutation: suppressing the daemon's emission fails the liveness test with Daemon request timed out, while deleting the client-side re-arm changes nothing. Worth knowing before someone "simplifies" that line away.

Closing this in favour of the replacement, but the diagnosis and the design are yours — thank you for both, and for the second iteration on the original report. Sorry the migration landed on top of it.

@umutkeltek

Copy link
Copy Markdown
Contributor Author

Thank you. This is a great outcome. I am happy for #270 to be the canonical implementation, and I really appreciate you preserving the design, carrying the attribution through as co-author and in the changelog, and verifying the original failure mode against current main.

The socket.setTimeout inactivity behavior is a useful detail too. It is good to know that the daemon’s progress writes are the actual mechanism and the explicit re-arm is just belt and braces.

No apology needed. The migration timing was simply unlucky. I am glad the diagnosis and liveness-budget framing survived the rewrite and landed cleanly. Thanks again for handling this so thoughtfully.

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

Labels

merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. P1 Urgent regression or broken agent/channel workflow affecting real users now. proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants