Skip to content

MCP OAuth: replica-safe authorization codes via mesh-backed store - #609

Merged
rbuergi merged 3 commits into
mainfrom
fix/mcp-oauth-multireplica
Jul 23, 2026
Merged

rbuergi merged 3 commits into
mainfrom
fix/mcp-oauth-multireplica

Conversation

@rbuergi

@rbuergi rbuergi commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

MCP clients could not connect to memex-cloud: the OAuth authorization-code store was an in-memory per-process dictionary, so with 2–8 KEDA replicas behind one ingress, /authorize minted the code on pod A and the client's /token exchange landed on pod B → unknown or already consumed code (never issued by this process) (observed on 3 pods in prod Loki, 2026-07-23 09:58–10:01). Pod restarts also wiped pending codes.

Fix

  • Codes are mesh nodes at Admin/OAuthCode/{sha256-prefix} (PG-persisted, shared across replicas), mirroring the ApiToken scheme: hash-keyed, raw code never stored, System identity writes, new OAuthCodeNodeType registration.
  • Atomic cross-replica single-use: consumption IS the DeleteNode — the exchange is honored only when this caller's delete removed the node; a lost race → invalid_grant, never a second success.
  • 5-min expiry checked post-consume; opportunistic cleanup of expired siblings on generate; PKCE unchanged; reactive surface with one ToTask bridge at the HTTP boundary; diagnosable FailureReason strings kept.
  • Framework fix the tests exposed: requests delivered to a per-node hub disposing after its delete were silently dropped, hanging the sender 30s. MessageService now NACKs the sender with DeliveryFailure(NotFound) via the parent hub (cascade-safe, same exclusions as ReportFailure).

Verification

  • Memex.Portal.Shared.Test OAuthCodeStore: 11/11 (incl. TwoStoreInstances_GenerateOnOne_ExchangeOnOther — two store instances on one mesh = two replicas on one PG)
  • MeshWeaver.Auth.Test OAuthConnectController: 26/26
  • MeshWeaver.Messaging.Hub.Test full regression: 102/102
  • Release -warnaserror clean for all touched projects + dependents
  • What's New entry included.

🤖 Generated with Claude Code

The OAuth authorization-code store was an in-memory per-process dictionary,
so with multiple KEDA replicas behind one ingress the /authorize code minted
on pod A could not be exchanged at /token on pod B ("unknown or already
consumed code") — MCP clients could not connect at all.

Codes are now mesh nodes at Admin/OAuthCode/{sha256-prefix} (PG-persisted,
shared by all replicas), mirroring the ApiToken scheme: hash-keyed, raw code
never stored, written under System identity. Single-use is atomic across
replicas — consumption IS the DeleteNode; a lost race maps to invalid_grant,
never a second success. 5-minute expiry checked post-consume, opportunistic
cleanup of expired siblings on the generate path, PKCE unchanged, reactive
surface end-to-end with a single ToTask bridge at the HTTP boundary.

Also fixes the framework defect this exposed: a request delivered to a
per-node hub disposing after its delete was silently dropped, hanging the
sender for its full 30s timeout. MessageService now NACKs the awaiting
sender with DeliveryFailure(NotFound) via the parent hub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 23, 2026 11:30

Copilot AI 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.

Pull request overview

This PR makes MCP OAuth authorization codes replica-safe by replacing the per-process in-memory authorization-code store with a mesh-backed (PG-persisted) store, so /authorize and /token can land on different portal replicas without breaking the OAuth code exchange. It also hardens message delivery during hub shutdown to fail fast (instead of silently dropping awaited requests), and adds regression tests + a What’s New entry.

Changes:

  • Persist OAuth authorization codes as Admin/OAuthCode/{sha256-prefix} mesh nodes (raw code never stored) with atomic single-use semantics via DeleteNode.
  • Update OAuth controller + tests to use the reactive mesh-backed store and validate cross-instance exchange behavior.
  • NACK awaited requests that are dropped during hub shutdown, and document the behavior change.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test/MeshWeaver.Auth.Test/OAuthConnectControllerTests.cs Updates controller tests to use the mesh-backed OAuthCodeStore and await code-node visibility before exchanging.
test/Memex.Portal.Shared.Test/OAuthCodeStoreTest.cs Converts OAuthCodeStore tests to run on a full monolith mesh and adds a cross-store-instance exchange test (replica simulation).
test/Memex.Portal.Shared.Test/Memex.Portal.Shared.Test.csproj Adds Monolith testbase reference required for mesh-backed OAuthCodeStore tests.
src/MeshWeaver.Messaging.Hub/MessageService.cs Adds shutdown-path NACK behavior to avoid silent drops that can stall callers awaiting responses.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-07-23-mcp-connect-reliability.md Adds a What’s New entry describing reliable MCP sign-in with multiple replicas.
memex/Memex.Portal.Shared/MemexConfiguration.cs Registers the OAuthCode NodeType/content type in the portal mesh configuration.
memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs Bridges /authorize to the reactive, mesh-backed code store and updates /token to use the new exchange result shape.
memex/Memex.Portal.Shared/Authentication/OAuthCodeStore.cs Replaces in-memory dictionary with mesh-node persistence and single-use exchange via delete-first semantics.
memex/Memex.Portal.Shared/Authentication/OAuthCodeNodeType.cs Introduces the OAuthCode NodeType definition and registers the AuthorizationCode content type.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   12 files  ±0     12 suites  ±0   4m 29s ⏱️ +11s
  965 tests +4    961 ✅ +4  4 💤 ±0  0 ❌ ±0 
1 026 runs  +4  1 022 ✅ +4  4 💤 ±0  0 ❌ ±0 

Results for commit 18cec4b. ± Comparison against base commit a472b19.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 903 tests  ±0   1 715 ✅ ±0   5m 25s ⏱️ +16s
   12 suites ±0     188 💤 ±0 
   12 files   ±0       0 ❌ ±0 

Results for commit 18cec4b. ± Comparison against base commit a472b19.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 088 tests  ±0   1 087 ✅ ±0   5m 43s ⏱️ -1s
   12 suites ±0       1 💤 ±0 
   12 files   ±0       0 ❌ ±0 

Results for commit 18cec4b. ± Comparison against base commit a472b19.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

934 tests  ±0   928 ✅ ±0   4m 53s ⏱️ -7s
  5 suites ±0     6 💤 ±0 
  5 files   ±0     0 ❌ ±0 

Results for commit 18cec4b. ± Comparison against base commit a472b19.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 146 tests  ±0   1 145 ✅ ±0   6m 48s ⏱️ +16s
   10 suites ±0       1 💤 ±0 
   10 files   ±0       0 ❌ ±0 

Results for commit 18cec4b. ± Comparison against base commit a472b19.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 105 tests  ±0   1 002 ✅ ±0   5m 19s ⏱️ -17s
   10 suites ±0     103 💤 ±0 
   10 files   ±0       0 ❌ ±0 

Results for commit 18cec4b. ± Comparison against base commit a472b19.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   61 files  ±0     61 suites  ±0   32m 39s ⏱️ +19s
7 141 tests +4  6 838 ✅ +4  303 💤 ±0  0 ❌ ±0 
7 202 runs  +4  6 899 ✅ +4  303 💤 ±0  0 ❌ ±0 

Results for commit 18cec4b. ± Comparison against base commit a472b19.

♻️ This comment has been updated with latest results.

rbuergi and others added 2 commits July 23, 2026 15:19
…— sync streams ride it out

Fixes the two CI regressions from the shutdown-drop NACK (run 30003419841:
NodeTypeCompileParkTest.ParkedNodeType_RecycleRetry_AfterFix_SettlesOkAndUnparks,
ExportImportAccessControlTest.Export_EditorFullAccess_ExportsWholeSubtree)
WITHOUT reverting to silent drops (the OAuth consumed-code 30s-hang fix stays).

Root cause: the NACK a disposing hub now posts for a dropped delivery was shaped
as a terminal failure (NotFound). But the very same drop fires when a
SubscribeRequest races a RECYCLE's DisposeRequest — the address is about to
REACTIVATE. The sync-stream subscribe machinery treated the correlated
DeliveryFailure as "owner does not exist": reduced.OnError + keepAlive.Dispose()
tore down exactly the recovery machinery (keep-alive + change-feed resubscribe
latch, which demonstrably re-sends the SubscribeRequest ~2s after a recycle)
that used to ride out the silent drop — so every reader of a mid-recycle
NodeType waited to its timeout and the recycle-retry compile flow wedged.

Fix shape — make the transient nature first-class:
- ErrorType.ShuttingDown (Messaging.Contract): transient rejection; the address
  may reactivate on the next probe. Mirrors the Orleans mid-DeactivateOnIdle
  reject ("invalid activation. Rejecting now.").
- MessageService.ScheduleNotify stamps the shutdown-drop NACK with it.
- JsonSynchronizationStream's initial-subscribe error path keeps the stream and
  keep-alive ALIVE on ShuttingDown (recovery stays event-driven via the
  change-feed resubscribe latch — no timer, no re-post); all other failures
  remain terminal (never heartbeat a non-existent owner).
- SynchronizationStream's uncorrelated DeliveryFailure handler likewise ignores
  ShuttingDown instead of erroring the stream.
- MeshNodeStreamCache.IsTransientOwnerFailure + AreaErrorClassifier
  .IsTransientHubFailure recognise the "is shutting down" banner so all layers
  classify it transient (cache never negative-caches it; GUI retries).

Verified locally: RecycleRetry green in 5s (was 90s timeout; pre-NACK baseline
bisected green to pin the NACK as the trigger; msg-trace pinned the drop to the
cache's SubscribeRequest racing the recycle DisposeRequest and the +2s latch
resubscribe that the terminal error was killing). Full battery green:
NodeTypeCompileParkTest 6/6, Export_EditorFullAccess 1/1, OAuthCodeStore 11/11,
OAuthConnectController 26/26, MeshWeaver.Messaging.Hub.Test 102/102; Release
-warnaserror clean for Messaging.Contract/Hub, Data, Hosting, Layout,
Memex.Portal.Shared.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… immutability fixes

1. MessageService: the shutdown-drop NACK now fires only for deliveries a caller
   can actually be awaiting — typed IRequest messages, plus RawJson. RawJson must
   stay in the gate: cross-hub deliveries reach ScheduleNotify UNDESERIALIZED
   (the MESHWEAVER_MSG_TRACE captures show the dropped GetDataRequest and
   SubscribeRequest both as msg=RawJson at this gate), so an IRequest-only gate —
   the review's literal suggestion — would silently skip exactly the two flows
   the NACK exists for (the OAuth consumed-code read and the sync-stream
   subscribe) and reintroduce the 30s hang. RawJson payloads that look like a
   DeliveryFailure are skipped via a cheap content sniff (a NACK-of-NACK between
   two concurrently-disposing hubs would ping-pong; a sniff false-positive only
   reverts that delivery to the historical silent drop). Typed fire-and-forget
   events (DataChangedEvent & co) revert to the historical silent drop — nobody
   awaits them, and NACKing them was pure disposal noise.

2. OAuthCodeStore.ExtractEntry doc: AuthorizationCode IS registered
   (AddOAuthCodeType/WithMeshType); the JsonElement fallback covers hubs that
   have not registered the type and older persisted rows.

3. OAuthCodeNodeType: ExcludeFromContext now ImmutableHashSet per the
   collections policy.

Verified: NodeTypeCompileParkTest 6/6, Export_EditorFullAccess 1/1,
OAuthCodeStore 11/11 (covers the NACK fast-fail via the single-use replay
tests), MeshWeaver.Messaging.Hub.Test 102/102; Release -warnaserror clean for
MeshWeaver.Messaging.Hub + Memex.Portal.Shared.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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