Skip to content

feat: MCP over Fabric follow-up items - #31

Open
anton-i-affinidi wants to merge 7 commits into
mainfrom
feat/mcp-fabric-follow-ups
Open

anton-i-affinidi wants to merge 7 commits into
mainfrom
feat/mcp-fabric-follow-ups

Conversation

@anton-i-affinidi

@anton-i-affinidi anton-i-affinidi commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Review of modern MCP over Fabric left a set of follow-up items: transport correctness, tests and cleanup around Connection Points and Fabric streams. This PR delivers some of them, one commit per item.

Subtasks

Test mediator problem-report handling with a real envelope

When the mediator reports that it no longer has a Connection Point's account (e.p.account.not_found), the Connection Point must drop the stale socket and reconnect to re-register. The sender was matched to the mediator by base DID, but no test used a real mediator envelope.

  • Move the reader loop's account.not_found decision into mediator_account_loss, and the envelope-to-record conversion into received_message, so both can be unit-tested. Behaviour is unchanged.
  • Add unit tests that build the problem report the way the mediator's WebSocket handler does (ProblemReport body, mediator DID as from, session DID as to, pthid):
    • from the mediator: the reader ends with the report's code, so the listener reconnects. This also holds when the configured mediator DID has a #fragment;
    • the same report from another DID, including one that only starts with the mediator DID: ignored, the socket stays;
    • another mediator problem code (e.p.authorization.account.denied), and a non-report message: the socket stays.
  • Matching stays on the base DID. Alias matching is out of scope.

Reconnect a Connection Point whose account the mediator has lost

When the mediator's store is flushed while the gateways stay connected, mediator v0.33.1 does not send e.p.account.not_found to the Connection Points. It recreates an account only when that account sends. It refuses delivery to a missing account with e.p.delivery.refused, which is also its answer to an access-list refusal. A gateway that only receives therefore stayed unreachable, with its connection still shown as healthy, until it sent something, restarted or was reconnected by hand.

  • New connection_points::account_watch: every 30 s, each connected Connection Point probes its own mediator account (account_get).
    • e.p.account.not_found: the listener restarts, so it reauthenticates and reregisters.
    • A closed receive list: it is re-opened.
    • An open account, a probe error or a probe timeout: nothing changes.
  • The Gateway Ping timeout path uses the same probe and decision. Its copies in gateways/handlers.rs are removed, and its behaviour is unchanged.
  • The g2g harness can flush the scenario mediator's store. A new scenario shows that after the mediator loses every account, gateway 1 can ping gateway 2 again without gateway 2 sending first.
  • docs/FABRIC.md (Runtime health behavior) describes the periodic check.

Give each Fabric peer and tenant its own share of the shared stream tables

One peer could fill the capability offer table, the capability probe table or the Open replay table, and every other peer was then refused. Open admission (the peer lookup and route resolution) also ran on the Connection Point reader loop, so one slow admission stalled every other message.

  • New fabric_stream::share::SharedTable: counts each table's entries per peer and per tenant (the tenant owning the peer's Remote gateway record). One peer may hold an eighth of a table and the peers of one tenant half. These are interim constants until the final per-peer limits are set.
    • Capability offers (receiver, 256): one peer holds at most 32 live offers, still more than one, so its concurrent first requests each negotiate.
    • Capability probes (sender, 256): a full share is a capacity refusal, so the caller gets 429.
    • Open replay records (receiver, 65,536): the same shares.
  • Concurrent first requests to one peer each keep the agreement negotiated for their own probe. A later disclosure no longer cancels an earlier waiter.
  • Open admission runs off the reader loop:
    • The loop only reserves the stream and starts admission, at most 16 per listener at a time, each for up to 2 s.
    • Up to 8 frames that arrive for the stream before its Open is admitted are held. Only frames from the Open's sender are held.
    • The held frames are delivered in order once the Open is admitted, or dropped when it is refused.
  • Breaking: a full receiver cap (stream caps, the Open replay table or a share of it), the per-peer Open rate and a full admission pool now refuse with capacity_reached, so the caller gets 429 with Retry-After: 5 instead of 502. A 1.0.0 sender cannot parse capacity_reached and waits for its response deadline instead, so paired gateways need upgrading.
  • docs/MCP_METADATA.md (refusal codes, Framed Fabric limits, cap logging) and docs/FABRIC.md describe the shares, the admission and the new refusal code.

Add Fabric compatibility and replay proof tests and the multi-node note

  • A modern sender talking to a legacy-only receiver fails fast: a new g2g scenario in mcp_over_fabric.feature.
    • The receiving gateway runs the legacy-only Fabric receive policy. The g2g harness switches it on with AG_BDD_FABRIC_RECEIVE_LEGACY_ONLY=true, which is read only when AG_TEST_MODE=true, like the other AG_BDD_* switches. No product setting is added.
    • The caller gets 400 and -32022 listing only 2024-11-05, in under 5 s, and the MCP server is not called.
  • Replay proof: one test replaces the two Open and capability-query replay tests, which duplicated their setup and admitted Opens under a hand-built version policy.
    • It admits Opens through the receiver's own prepare_incoming, with both envelope times set as the current sender sets them.
    • It covers Open replay (after the stream ends and after its clamped lifetime) and capability-query replay (a captured query cannot recreate the expired offer).
    • It fails when the capability-query replay check is removed. This was checked by removing the check and running the test.
  • Multi-node note: docs/CAPABILITIES.md (High availability) and the AG_IDENTITY_HASH_PEPPER row of docs/CONFIGURATION_REFERENCE.md say to set the same pepper on every node. The High availability section also says used OAuth state nonces are checked per node, not across nodes.
  • The mediator account loss scenario now arranges the flush as a Given, so it has a single When step as the BDD lint requires.

End running Fabric streams when a peer's access is revoked

Running framed streams kept going after a peer left active or was removed, a surface was un-exposed, or a Connection Point was disabled. A disabled Connection Point also still admitted new streams until its listener restarted, because admission read the record the listener started with.

  • fabric_stream::Revocation says which streams an access change ends:
    • the peer leaves active, or its record is removed: every stream with it, in both directions;
    • its exposure stops admitting a surface: the streams it opened here to that surface;
    • a Connection Point is disabled or removed: every stream it carries;
    • a Connection Point's exposure list stops admitting a surface: the streams opened through it to that surface.
  • The gateway and Connection Point stores apply it on every write, as access invalidation of subscriptions is applied from storage. Only a change that narrows access revokes anything, so unrelated writes don't disturb streams. When the previous record can't be read, the revocation fails closed.
  • Revoked streams end the way a replaced listener ends its streams (unavailable to each side). cancel_listener now shares the same registry code (cancel_where).
  • A revocation is counted. An Open (receiver) or an outbound stream (sender) admitted while one ran is refused, so it cannot outlive the change.
  • Open admission reads the Connection Point's current record, so a disabled one admits no new stream and a narrowed exposure applies at once.
  • docs/FABRIC.md (Peer exposure, "Revoking access ends running streams") describes it.
  • Merging main (fix: make fabric exposure explicit for remote gateways #28, fix: stream connection guard #29) needed one conflict resolved: the replay proof fixture's peer now has exposure mode all, since new peers start with none.

Notes for reviewers

  • One wire change, on an exceptional path only. Normal traffic never sees it. It applies only when the receiving gateway is out of room:

    • a framed-stream cap is full;
    • the Open replay table, or the peer's or its tenant's share of it, is full;
    • the listener is already admitting 16 Opens;
    • the peer is past its Open rate.

    In those cases the receiver now refuses with capacity_reached instead of unavailable, so the caller gets 429 with Retry-After: 5 instead of 502. A 1.0.0 sender can't parse capacity_reached and waits for its response deadline instead. That's why the changelog lists it under breaking. Please confirm that's acceptable for the 1.0.0 peers still paired.

  • Merge order: fix: per-route opt-in for delegated credentials over Fabric #32 also edits docs/FABRIC.md and docs/MCP_METADATA.md. Merging this PR first leaves the doc merge on fix: per-route opt-in for delegated credentials over Fabric #32's side, which is small.

Testing

  • cargo test --no-fail-fast --all-targets --all-features, with main (fix: make fabric exposure explicit for remote gateways #28, fix: stream connection guard #29) merged in: 4,537 passed, 0 failed (15 ignored).

  • cargo clippy --all-targets --all-features -- -D warnings, cargo fmt --all -- --check and make pre-commit-rust: clean.

  • New unit tests:

    • per-peer and per-tenant shares, positive and negative, for each table;
    • more than 256 queries from one peer, after which a second peer's query still succeeds;
    • concurrent first requests to one peer;
    • held frames delivered in order after admission, dropped on refusal and bounded at 8;
    • admission returning while it is still pending, with a full pool refused at once with capacity_reached;
    • the replay proof, which fails when the capability-query replay check is removed;
    • the legacy-only receive switch, honoured only in test mode;
    • which streams each revocation covers, and which record changes revoke anything (an unrelated write revokes nothing);
    • a registry revocation ends only the matching streams and is counted.
  • Component tests for stream revocation, one per action, through the real gateway and Connection Point stores. Each shows the affected streams end with unavailable while other peers', surfaces' and Connection Points' streams keep running:

    • a peer disabled, then removed: its streams end in both directions;
    • a peer's exposure narrowed, then set to none;
    • a Connection Point disabled, its exposure narrowed, then removed.

    All three fail when revocation is turned off.

  • g2g scenario for the mediator account loss, against mediator v0.33.1:

    • Without that change, it fails: every ping retry for 60 s times out with e.p.delivery.refused.
    • With it, it passes. The idle gateway's account probe got e.p.account.not_found and its Connection Points restarted about 30 s after the flush. The next ping round trip took 12 ms.
  • scripts/bdd-gherkin.py --lint: no errors.

  • make gw-e2e (real mediators, 8 scenarios in parallel), with main merged in and stream revocation added: 47 scenarios, 383 steps, all passed, including the legacy-only receiver scenario. In the previous round's run the issuer-DID request in gateway_did_validation.feature timed out once (504). That feature passed in all 3 re-runs and in this run. Its exchange does not use framed streams.

- move the reader's account.not_found decision and envelope conversion into functions
- unit-test them with a problem report built as the mediator sends it
@anton-i-affinidi
anton-i-affinidi requested a review from a team as a code owner October 9, 2026 09:46
- check each connected connection point's own mediator account every 30 s
- restart the listener when the account is gone, re-open a closed receive list
- share the account probe and its decision with the gateway ping timeout path
- g2g scenario: a gateway that only receives is reachable after a mediator store flush
- per-peer and per-tenant shares of the capability offer, probe and open replay tables
- admit opens off the reader loop, holding early frames until admission ends
- keep each concurrent first request's own capability agreement
- answer full receiver caps and the open rate with capacity_reached (429)
- arrange the mediator store flush as a given, so the scenario passes the bdd lint
- g2g scenario: a modern sender to a legacy-only receiver fails fast with -32022
- one replay proof test for open and capability-query replay through prepare_incoming
- document the shared identity pepper and per-node oauth state checks for multiple nodes
- gateway and connection point writes end the streams a narrowed access no longer allows
- a peer leaving active or removed ends its streams in both directions
- refuse an open admitted while a revocation runs, and admit against the current connection point

@mohamed-affinidi mohamed-affinidi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review of a39b121: two code passes, a design/business pass and a behaviour pass. Requesting changes. Details are inline; one finding sits outside the diff and is listed below.

Blocking

  1. Held early-frame entries leak when an admission is aborted by a listener restart (inline, ws_listener.rs:2319). This is the one to fix first.
  2. The revocation-race, admission-timeout, CP re-read and fail-closed store paths have no tests (inline, ws_listener.rs:2435).
  3. Revocation runs only on this node's own store writes, while the docs and changelog say "every write" (inline, gateways/filesystem.rs:84).
  4. Outside this diff: the release notes will contradict themselves. .changelog/wip/fix_fabric-stream-cap-429.yaml (#24, unreleased, same release) still says: "Receivers still refuse a full cap with unavailable (502), because a 1.0.0 sender cannot parse capacity_reached...". This PR's breaking entry says the opposite. Please reword or remove that sentence in this PR.

Non-blocking: the minor comments inline. On your question about the wire change, see the comment on fabric_stream/mod.rs:553. In short, it's acceptable with upgrade-order guidance, or the receiver flip can wait one release.

Looks sound:

  • SharedTable accounting on every insert, remove and retain path;
  • revocation ordering (store write, then a SeqCst counter bump before the sweep);
  • held frames are checked against the sender, bounded, and delivered in order;
  • base-DID mediator matching is unchanged, with spoof tests added;
  • the Gateway Ping refactor preserves behaviour;
  • the watch is idle in Standby;
  • AG_BDD_FABRIC_RECEIVE_LEGACY_ONLY is honoured only under AG_TEST_MODE=true and only narrows admission.

Validation:

  • Run at a39b121: filtered cargo test --bin agent-gateway covering fabric_stream, revocation, share, account_watch, open_admission and held frames, about 100 tests, all passed.
  • Not run: the full suite, clippy, fmt, gw-e2e and the g2g BDD scenarios.
  • CI on this SHA ran only CodeQL, so the Rust build and tests aren't covered by CI here.

.from_did
.as_deref()
.ok_or("Fabric sender is missing")?;
registry.hold(stream_id, peer_did)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[important] Held early-frame entries leak when an admission is aborted

hold() inserts into the process-global ReceiveRegistry.held map, and only release() inside the admission task removes it. The task lives in the listener-local stream_tasks JoinSet. On a listener stop or restart, which the new account watch now triggers unattended, the JoinSet is dropped and the task is aborted mid-admit(). Nothing else cleans up: cancel_listener / cancel_where (registry.rs:858) touch only senders and entries, and held has no expiry.

Each restart during admission therefore orphans up to 16 entries, each with up to 8 cloned frames, for the life of the process, and was_opened() stays true for those ids. A panic in admission leaks the same way.

Fix: hold through a guard that calls release(&stream_id, false) on Drop unless disarmed, or store the listener instance in HeldOpen and clear it in cancel_listener. Add a test that aborts the JoinSet mid-admission and asserts !was_opened(id).

.revocations()
!= revocations
{
return Err("Fabric access changed while the Open was admitted".into());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[important] The revocation-race and admission security paths have no tests

Deleting this guard fails no test. open_admission_runs_off_the_reader_loop_and_is_bounded drives stub closures, so none of these are exercised:

  • OpenAdmission::admit: this counter mismatch, current_connection_point re-reading a disabled or removed CP, and the 2 s timeout → release(false);
  • the outbound counter check at fabric_forward.rs:330;
  • the store hooks' Err(previous) fail-closed arms;
  • gateway and CP delete revocation;
  • check_listener's RestartListener / ReopenReceiveList branches, which run only in the Docker g2g scenario.

AGENTS.md rule 14 asks for positive and negative cases.

Suggested tests through OpenAdmission with a stub ConnectionPointStore:

  • get() calls revoke(...): the Open is refused and nothing stays registered;
  • get() returns enabled: false: the Open is refused;
  • get() sleeps past OPEN_ADMISSION_TIMEOUT: the held frames are dropped.

Also add an outbound race test, store tests with unreadable JSON and for delete, and a check_listener test with a stubbed probe outcome.

/// Saves `gateway` and ends the running Fabric streams the change revokes.
/// When the previous record cannot be read, every stream with the peer
/// ends.
async fn save_and_revoke(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[important] Revocation runs only on writes through this node's store, but the docs and changelog say "every write"

revoke() is called only from save_and_revoke and the two delete methods. These paths update the cache without revoking:

  • refresh_from_disk (cache_refresh_interval_secs). It emits StorageEvent::{Upsert,Delete}{source: RemoteWrite}, but only the surface store subscribes to those events;
  • a manual _storage/ edit plus POST /v1/config/reload (AGENTS.md rule 12);
  • a restore;
  • a write made on another node sharing storage.

On these paths, running streams for a peer that has been disabled or narrowed keep going. Admission re-reads the record, so new Opens are refused.

docs/FABRIC.md says The gateway and Connection Point stores apply it on every write of a record, and the security changelog entry says Every write of a gateway or Connection Point record now ends, at once, ....

Question: can more than one node be Active with Fabric listeners at once? Either subscribe to gateway and CP RemoteWrite events and revoke what the refreshed record no longer admits, or narrow FABRIC.md and the changelog to "writes through this node's management API". In the second case, also say that refreshed, reloaded, restored or other-node changes only block new Opens.

self.storage
.delete(id)
.await?;
if let Ok(Some(previous)) = &previous

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[minor] Gateway delete doesn't fail closed when the previous record can't be read

Gateway save, CP save and CP delete all revoke on Err(previous). Gateway delete ignores it, so the delete succeeds and the peer's running streams continue. That contradicts the PR's "fails closed when the previous record can't be read". New Opens are still refused. With only the id at hand there's no DID to revoke by.

Options:

  • revoke by a DID read from the cache;
  • revoke every inbound stream on Err;
  • narrow the claim to exclude gateway delete.

Refusing the delete seems wrong, since operators need to be able to remove a corrupt record. Please add a test for whichever you choose.

return None;
}
match current {
Some(current) if current.enabled => (current.exposed_channels != previous.exposed_channels)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[minor] Any exposure difference, even a widening or a reorder, refuses every in-flight Open on the node

exposed_channels is compared as a Vec, so adding a surface or reordering the list also yields Some. revoke() then bumps the appliance-wide counter. Two things fail as a result:

  • every Open being admitted at that moment, for any peer and through any CP;
  • every outbound first request inside its probe window (fabric_forward.rs:330, which returns 502).

Inbound, the replay record is already in opened, so refused_open_binding returns None. The unrelated sender never gets an answer and waits for its deadline. The doc comment "Only a change that narrows its access revokes them" doesn't match this.

Fix: return Some only when the new set drops a surface the old set admitted (an empty list means all), and add widening and reorder cases to only_a_change_that_narrows_access_revokes_streams. The same applies to line 825.

cap = limit.as_str(),
"Fabric capability offer cap reached"
);
"Fabric capability offer capacity is unavailable".to_string()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[minor] A full per-peer offer share shows up as a 5 s stall and then 502, not 429

The per-peer offer share is MAX_OFFERS / 8 = 32. Offers live for PEER_TTL (300 s) and aren't removed on use. Senders don't single-flight first probes, so a cold burst of more than 32 concurrent first requests to one receiver exhausts the share, and it stays full for up to 5 minutes.

The refused query yields ProcessingResult::Failed, which is only logged, so no reply is sent. The sender waits min(deadline, 5 s) in peers.wait and returns StreamingUnavailable (502). That is not the 429 the PR describes for capacity refusals.

Fix (any of):

  • single-flight the sender's probe per PeerKey;
  • answer a capacity-refused query so the sender maps it to 429;
  • size the share for the expected burst.

peer_did: binding.peer_did.clone(),
tenant_id,
};
if opened.admits(&owner).is_err() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[minor] Question: does the per-peer replay share cap a busy single peer?

One peer's eighth of the table is 128 × 512 / 8 = 8,192 records. A record lives up to the offer expiry (≤ 300 s), so a single busy peer reaches its share at about 27–55 Opens/s sustained, below the 50/s rate limit, and then gets 429s.

In a deployment with one paired peer, 7/8 of the table is unreachable. Is that intended, given the OPEN_REPLAY_RECORDS_PER_STREAM comment "bounds memory without capping ordinary traffic"?

If not intended, options:

  • raise the per-peer share to at least rate × offer TTL;
  • enforce shares only above a fill threshold;
  • document the cap.

fn from(error: registry::RegisterError) -> Self {
match error {
registry::RegisterError::CapacityReached(reason) => reason.into(),
registry::RegisterError::CapacityReached(reason) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[minor] On the capacity_reached wire change you asked about: acceptable, but please add upgrade guidance

Per-peer negotiation isn't cheap. StreamCapabilities is deny_unknown_fields with version: 1, so a new flag would itself break 1.0.0, and gateway records carry no software version. Released 1.0.0 has no CapacityReached; sender-side parsing arrived in #24, which is unreleased. So the breaking label is accurate.

The cost is fail-slow, not data loss. Under receiver overload, a 1.0.0 sender's callers wait for their deadline instead of getting a fast 502, and hold the sender's own stream slots meanwhile. This covers full caps, the replay share, the new 16-slot admission limit, and the per-peer Open rate. The changelog's "upgrade paired gateways" doesn't describe what happens while a pair runs mixed versions, or what order to upgrade in.

Options:

  • (a) Keep the flip, and say in the breaking entry and docs/FABRIC.md what happens while a pair runs mixed versions and that both sides should be upgraded together.
  • (b) Ship the shares, revocation and watch now, and flip From<RegisterError> for OpenRefusal next release, once every sender parses it.

Either way, consider keeping the rate-limit and admission-slot refusals on unavailable, since they happen before any offer lookup.

Comment thread docs/MCP_METADATA.md
| Opens per peer | 50/s, burst 100, per listener | The Open is refused with `capacity_reached` before any lookup; the caller gets `429` |
| Opens being admitted | 16 at a time per listener, off the listener's reader loop, each for up to 2 s | The Open is refused with `capacity_reached`; the caller gets `429` |
| Frames held per Open being admitted | 8, from the Open's sender only | The admitted stream is dropped, and its caller waits for the response deadline |
| Open replay records | 512 per allowed stream (65,536); one peer holds at most an eighth of them and the peers of one tenant half | The Open is refused with `capacity_reached`; the caller gets `429` |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[minor] The tenant share isn't documented for the appliance-global and single-tenant cases

Peers with tenant_id: None count against no tenant share. share.rs says so, but this table and FABRIC.md don't.

The reverse case is a question. If every Remote gateway is created under one tenant-scoped PAT, all peers share one tenant and together get only half of each table, for no fairness gain. Is that real in practice? If so, consider applying the tenant share only when more than one tenant holds entries.

security:
- timestamp: '2026-10-09T12:30:00.000Z'
title: ⭐ Give each Fabric peer and tenant its own share of the shared stream tables
description: One peer could fill the capability offer, capability probe or Open replay table and get every other peer refused. Each peer may now hold an eighth of a table and the peers of one tenant half of it, so a second peer is still served after one peer sends hundreds of refused requests. The listener also admits Opens off its reader loop, at most 16 at a time, holding up to 8 early frames per stream until its Open is admitted, so a slow admission no longer stalls every other message. The share values are interim constants.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[minor] Two changelog nits on this entry

  1. "The share values are interim constants." is temporary-plan language in a durable file (AGENTS.md rule 7), and it doesn't tell operators anything they can act on. Drop it, or say the values are fixed and not configurable.
  2. Neither this entry nor the breaking one mentions that a full capability-probe share or table on the sender now answers the caller 429 with Retry-After: 5 instead of 502 (fabric_forward.rs:214, .map_err(registration_failure)?). MCP_METADATA.md documents it. A clause here would cover it.

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