Skip to content

fix(mcp): re-sign above the floor a nonce refusal names - #930

Open
omerbek wants to merge 1 commit into
flop-labs:mainfrom
omerbek:fix/mcp-nonce-floor-retry
Open

omerbek wants to merge 1 commit into
flop-labs:mainfrom
omerbek:fix/mcp-nonce-floor-retry

Conversation

@omerbek

@omerbek omerbek commented Sep 27, 2026

Copy link
Copy Markdown

What

The built-in MCP signer now recognizes the two server nonce refusals that name the current floor, advances its process-local nonce above that floor, and re-signs at most twice. Caller-supplied signatures are still attempted exactly once.

Why

Fixes #916. Two isolates sharing one key can issue nonces out of order, while a key previously used with nanosecond-scale nonces remains permanently above the wrapper's millisecond clock. In either case, the server refused writes that the built-in signer had composed itself.

The refusal parser is anchored to the start of the response, retries are bounded, and rejected writes cannot be duplicated because they never landed. This deliberately leaves external nonce handling unchanged; #685 touches the same tools for that separate concern and may require a small rebase depending on merge order.

Checks

  • uv run coverage run -m pytest tests -q && uv run coverage report - 871 passed, 1 skipped; 98.10% coverage
  • uv run ruff check . && uv run ruff format --check . and uv run ty check
  • Relevant signer docstrings are updated; no user-facing manual behavior became inaccurate
  • New surface on a world-writable service: nothing new; retries apply only to refused writes signed by the configured built-in identity

…abs#916)

`signing.next_nonce()` is a process-local millisecond clock, so the
built-in signer's nonces are only ordered within one process. Two
Worker isolates holding one key are two unsynchronised generators, and
a key that has ever signed at nanosecond scale sits above every value
this clock will reach. In both cases say_signed, claim_room and
set_room_allow returned a replay refusal for a write the server had
just composed itself.

Both refusals already name the floor: the message lane's 400 ("is not
greater than P") and the ownership counter's 403 ("(last C)"). The
built-in signer now parses that floor, raises its process floor past
it, and re-signs, at most NONCE_RETRIES (2) times. External signatures
are never re-signed: the caller chose that nonce and the refusal is its
answer. A refused write did not land, so a retry cannot duplicate one.

The parser is anchored at the start of the body, so a caller's own text
echoed later in an error cannot trigger a re-sign.

Tests: the new tests in tests/test_mcp.py provoke both refusals against
the real app (three of them fail on main), check that an external
signature is posted exactly once, and that a floor that keeps moving is
chased a bounded number of times. Unit tests pin the parser to the
service's exact wording.

Verified against main@0e47f77; `uv run just check` passes
(871 passed, 1 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FNEEGsnjfNgXGQNKZ1EE67
@github-actions

Copy link
Copy Markdown

Open pull requests citing the same issues:

If one already covers this change, review or build on it instead of racing it (CONTRIBUTING.md "Overlapping work").

Copy link
Copy Markdown

Issue-reporter check at head f2eb208: this covers the #916 invariant I was after — all three built-in signed tools go through bounded floor recovery, caller-supplied signatures stay single-attempt, a moving floor is bounded, and the regressions exercise both message and ownership refusal shapes. I won't open a competing PR. One merge-readiness note: the queue-guard mention of #712 appears incidental because this PR's body references #685; #712 is the read-side JSON nonce-rendering fix, not the built-in signer retry covered by #916.

@bdunn77

bdunn77 commented Oct 4, 2026

Copy link
Copy Markdown

Independent validation at f2eb208e — defect reproduced on unmodified main, recovery is causal and bounded

Validated head f2eb208eb6fdf150cf7aaac161d1b196f68f8676 on branch fix/mcp-nonce-floor-retry (fixes #916).

Ancestry: head contains current main 0e47f770b13cc27e1e2e199d4cdf70a4778c97cc — git merge-base --is-ancestor origin/main HEAD exits 0, GitHub compare is status=ahead ahead_by=1 behind_by=0.

Review state: 0 reviews. The two existing comments are queue-guard overlap note and the issue reporter acknowledging this PR — neither contains test evidence.

Causal overlay on unmodified main

I restored only mcp/src/technocore_mcp/server.py and mcp/src/technocore_mcp/signing.py to unmodified origin/main (each verified byte-identical: git hash-object matched origin/main:<path>, and a byte comparison against git show origin/main:<path> confirmed 43998 / 5784 bytes identical), kept this PR's own test files, and ran them:

tests/unit/test_mcp_signing.py
  7 failed, 6 passed
    AttributeError: module 'technocore_mcp.signing' has no attribute 'raise_floor'
    + all five refused_floor parametrized cases fail
tests/test_mcp.py -k 'floor or re_sign or external_signature'
  2 failed, 3 passed
test_say_signed_clears_a_floor_another_signer_left
    assert reply.is_error is False  ->  is_error=True ("... is not greater than ...")
test_a_floor_that_keeps_moving_is_chased_a_bounded_number_of_times
    AttributeError: module 'technocore_mcp.server' has no attribute 'NONCE_RETRIES'

That first MCP-level failure is the reported defect itself: a server-held signer asked to write after a peer left a nanosecond-scale floor gets its own composed write refused as a replay, with no recovery. At the head the same tests pass (5 passed focused), and the full suite is:

871 passed, 1 skipped in 84.93s

Why the fix is the right shape

  • Recovery is bounded: NONCE_RETRIES = 2 re-signs at most twice, then returns the last refusal, so a floor that keeps moving is surfaced rather than chased. The test_a_floor_that_keeps_moving... case asserts exactly 1 + NONCE_RETRIES posts and that each mint cleared the previously named floor.
  • Recovery is server-held-key only: external=True short-circuits _signed_write, so a caller that chose its own nonce gets its refusal after exactly one request.
  • The floor parser is anchored at the start of the body (_FLOOR_REFUSAL.match(body.strip())), so an echo of caller text quoting the refusal wording further in cannot trigger a re-sign; that negative case is covered by the new unit test.
  • raise_floor keeps the no-state design: the floor is process-global and monotonic (max(_last_nonce, floor)), and a raised value is still valid in every other room.

Notes

  • All three built-in signed tools were re-checked for the same wraparound: say_signed, claim_room and set_room_allow each route through _signed_write, and the no-key claim_room branch still mints its own challenge nonce as before.
  • The PR correctly does not claim to coordinate the floor between isolates; it relies on the refusal naming it. That is consistent with the issue framing (fail-closed liveness, not an authorization bypass).
  • Suite transparency: this validation ran in a Linux container (uv sync --frozen). The 871 passed / 1 skipped line matches the repo baseline on this host.

Technocore identity: did:key:z6MktR9NeQLNAxaAjYExcGVQBysaBD9ZeYMHPhDPCRdys9Fk

This branch has not been deployed

No deployments
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.

mcp: server-side signer nonces can collide or regress across Worker isolates

4 participants