Skip to content

mcpx: guard unknown write outcomes (#1977) - #4721

Merged
dezren39 merged 2 commits into
mainfrom
queue/6667-1977-unknown-outcome-ledger
Nov 3, 2026
Merged

dezren39 merged 2 commits into
mainfrom
queue/6667-1977-unknown-outcome-ledger

Conversation

@dezren39

@dezren39 dezren39 commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Summary

transport confirms tools/call bytes written
  -> Registry records server/tool/SHA-256/time/surface on an error
  -> later non-read-only calls are refused until explicit clear
  • Persist the versioned ledger atomically under the daemon state directory; retain only the canonical-argument digest.
  • Exempt declared read-only calls and preserve the fixed ledger → argument-rules → mode → destructive-floor order.
  • Add mcpx writes clear <server> and POST /v1/writes/{server}/clear; generate CLI/API/plugin entries from the operation table.

Evidence

  • Before: latest origin/main had only the LedgerCheck hook stub and no ledger or clear operation.
  • After: go test ./internal/daemon -count=1, go test ./internal/mcpclient -count=1, and go test ./internal/api -count=1 pass. Targeted CLI parity and stop-all tests pass.
  • The full CLI package run, launched alongside other package suites, reported one daemon-shutdown failure (TestStopAll); its isolated rerun passed.
  • The mcpx workflow plugin-sync check may still report the pre-existing, untouched mcpx-session.ts copy drift (missedAt = 0 vs Date.now()) on base. The generated ops.gen.ts copies are synchronized.

Merge Danger

Door: two-way. Blast Radius: bounded.

A restart-persistent block is intentionally conservative and remains until an operator confirms the upstream outcome and clears it.


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

@dezren39
dezren39 requested a review from a team as a code owner November 3, 2026 01:58
@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 5 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 82dedba8-0f43-4dae-aeda-0d851f4c4d2e
Commits

Reviewing files that changed from the base of the PR and between c30c314 and f2f9225.

Files selected for processing: 19
  • .opencode/plugin/mcpx/ops.gen.ts
  • pkgs/mcpx/docs/parity.md
  • pkgs/mcpx/internal/api/ops.go
  • pkgs/mcpx/internal/api/ops_writes.go
  • pkgs/mcpx/internal/daemon/consumer.go
  • pkgs/mcpx/internal/daemon/input_required.go
  • pkgs/mcpx/internal/daemon/ops.go
  • pkgs/mcpx/internal/daemon/registry.go
  • pkgs/mcpx/internal/daemon/routes_proto.go
  • pkgs/mcpx/internal/daemon/write_ledger.go
  • pkgs/mcpx/internal/daemon/write_ledger_test.go
  • pkgs/mcpx/internal/daemon/write_policy_test.go
  • pkgs/mcpx/internal/mcpclient/dispatch.go
  • pkgs/mcpx/internal/mcpclient/dispatch_test.go
  • pkgs/mcpx/internal/mcpclient/http.go
  • pkgs/mcpx/internal/mcpclient/sse.go
  • pkgs/mcpx/internal/mcpclient/stdio.go
  • pkgs/mcpx/internal/mcpclient/supervised.go
  • pkgs/mcpx/plugin/opencode/mcpx/ops.gen.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-11-03T06:58:38.537790Z
We couldn’t complete this review. Comment @codex review to try again.
f2f9225 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 2a1859e2c1

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread pkgs/mcpx/internal/daemon/registry.go Outdated
Comment on lines +2303 to +2304
if err != nil && dispatched.Load() && !toolReadOnly(p, tool) && r.writeLedger != nil {
return nil, r.recordUnknownOutcome(p, server, tool, args, surface)

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 Preserve conclusive upstream rejections instead of blocking writes

When an upstream answers a dispatched write with a conclusive JSON-RPC rejection, such as -32602 for invalid parameters, mcpclient.CallTool returns that response as an error. This condition treats it like a lost response, replaces the original diagnostic with ErrUnknownOutcome, and durably blocks every subsequent write to the server until an operator clears it. Distinguish explicit rejections from genuinely unknown outcomes before recording the ledger entry so callers can correct rejected requests without requiring manual recovery.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f2f9225: callWithLedger now skips recording when mcpclient.IsConclusiveUpstreamError(err) (any JSON-RPC error without the mcpxStreamLost marker, e.g. -32602). Only a lost response records ErrUnknownOutcome. Covered by TestIsConclusiveUpstreamError.

Comment thread pkgs/mcpx/internal/daemon/registry.go Outdated
Comment on lines +2289 to +2292
tools, _, _ := p.CachedSchemas()
for _, candidate := range tools {
if candidate.Name == tool {
return policy.Classify(candidate.Annotations).ReadOnly

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 Reject stale annotations when exempting calls from the ledger

With write policy allow and the destructive floor disabled, applyWritePolicy skips schema refresh. If the cached read-only annotation has expired or been invalidated by tools/list_changed, this helper still trusts it: Pool.Invalidate clears the timestamp but retains the old tools. A tool that has become writable can therefore execute, lose its response, and be exempted from recording an unknown outcome, leaving retries unblocked. Require a fresh annotation before granting the read-only exemption; stale or invalidated metadata should conservatively retain the write block.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f2f9225: toolReadOnly now returns false on missing (zero timestamp), invalidated (Invalidate clears timestamp), or expired (SchemaExpired) catalogues, and the ledger gate refreshes fail-closed. Covered by TestToolReadOnlyRejectsStaleCatalogue.

Comment thread pkgs/mcpx/internal/daemon/consumer.go Outdated
Comment on lines +552 to +554
record, blocked := r.writeLedger.Lookup(serverName)
if !blocked {
return policy.Decision{}, false

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 Preserve ledger blocks when a shared pool's creating alias changes

If a server has an uncleared unknown write and a lexicographically earlier alias for the same process is added before restarting the daemon, this lookup no longer finds its persisted block. ResolveAll sorts names, and NewRegistry shares pools by process identity, so the new alias creates the pool and becomes p.Name(). Both the original server and its aliases then consult the new name rather than the name stored in the ledger, allowing the original write to be retried without an explicit clear. Resolve persisted blocks across all names sharing the pool, or use an identity that remains stable when aliases are added.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f2f9225: added ledgerLookupFor checking every name sharing the pool plus requested/canonical spellings, and recordUnknownOutcome persists under CanonicalName(requested) so a restart reordering aliases keeps the block. Clear path (clearUnknownOutcome) clears all sharing names. Covered by TestLedgerBlockSurvivesSharedPoolAliasChange and TestRecordUnknownOutcomeUsesRequestedServerName.

Comment on lines +70 to +71
l.path = filepath.Join(stateDir, writeLedgerFilename)
data, err := os.ReadFile(l.path)

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 Isolate ledger files between configuration-specific daemons

When two configuration-specific daemons run under the same state directory, they both open this fixed ledger filename: Paths.ForConfig changes only the socket and info paths, not State. Each ledger loads its own in-memory snapshot and Record or Clear replaces the entire file using only a process-local mutex. If both daemons start with an empty ledger, daemon A records an unknown write, and daemon B subsequently records another, B overwrites A's persisted block. Restarting A then silently permits retries of its unresolved write. Give each configuration-specific daemon an isolated ledger, or coordinate updates through shared transactional storage.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f2f9225: OpenWriteLedger(stateDir, configSources...) keys the filename unknown-writes-.json, so config-specific daemons sharing one state dir no longer overwrite each other; empty sources keep the legacy filename. Covered by TestWriteLedgerIsolatedPerConfig.

- conclusive JSON-RPC errors no longer record unknown outcomes
- read-only exemption requires fresh catalogue
- ledger lookup/clear span shared-pool aliases
- ledger isolated per config sources
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@dezren39
dezren39 merged commit a41cba3 into main Nov 3, 2026
2 checks passed
@dezren39
dezren39 deleted the queue/6667-1977-unknown-outcome-ledger branch November 3, 2026 06:59
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.

1 participant