Repository navigation
feat: per-resource subscriptions on subscriptions/listen from modern upstreams (#6644) - #7415
Merged
Lang-Akshay merged 7 commits intoNov 10, 2026
Conversation
msureshkumar88
requested review from
Lang-Akshay,
brian-hussey and
ja8zyjits
as code owners
November 2, 2026 15:33
This was referenced Nov 2, 2026
msureshkumar88
force-pushed
the
feat/6644-listen-resource-subscriptions
branch
from
November 5, 2026 10:32
b9f9157 to
c298aa3
Compare
Collaborator
|
|
msureshkumar88
force-pushed
the
feat/6644-listen-resource-subscriptions
branch
from
November 6, 2026 13:15
c298aa3 to
bef3543
Compare
msureshkumar88
force-pushed
the
feat/6644-listen-resource-subscriptions
branch
from
November 6, 2026 13:26
bef3543 to
5e5d222
Compare
msureshkumar88
force-pushed
the
feat/6644-listen-resource-subscriptions
branch
from
November 9, 2026 11:30
1ecfe0c to
1b89a0f
Compare
msureshkumar88
force-pushed
the
feat/6644-listen-resource-subscriptions
branch
from
November 9, 2026 11:37
1b89a0f to
186a5f2
Compare
msureshkumar88
force-pushed
the
feat/6644-listen-resource-subscriptions
branch
from
November 9, 2026 13:38
f2ef87a to
7349c49
Compare
msureshkumar88
force-pushed
the
feat/6644-listen-resource-subscriptions
branch
from
November 9, 2026 14:42
7349c49 to
68c7bcb
Compare
msureshkumar88
force-pushed
the
feat/6644-listen-resource-subscriptions
branch
from
November 10, 2026 16:23
68c7bcb to
5e04d70
Compare
added 2 commits
November 10, 2026 17:01
Add ResourceSubscriptionRelay. It holds one upstream subscriptions/listen stream per (gateway_id, uri) on the worker that holds the downstream stream, and shares it by reference count. Each upstream resources/updated event becomes a ResourceUpdated event on the worker-local listen bus. A pre-2026 upstream, or an upstream that does not honor the URI, ends the relay for that target. A dropped stream re-listens with jittered backoff and publishes one update to cover the gap. Refs #6644 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Add ResourceService.resolve_subscription_targets. It returns the upstream targets for each requested URI that the caller can see: an enabled resource that passes the visibility check, that belongs to the virtual server on a server-scoped path, and that comes from an enabled upstream a standing stream can reach (not SSE, not OAuth). One request considers at most 64 distinct URIs. Upstream auth headers come from gateway_listen_headers(), which the modern listener now shares. Refs #6644 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
added 5 commits
November 10, 2026 17:01
A 2026-07-28 listen request that names resource URIs now gets an ack with the URIs the caller can see, resolved by resolve_subscription_targets(). URIs need resources.read. A request with only URIs and no resources.read gets the same PermissionError as resources/read. Unknown or hidden URIs drop from the ack without an error. The stream holds a relay watch for each honored upstream resource, so upstream resources/updated events reach it with the subscription ID stamp. Modern capabilities advertise resources.subscribe again. Legacy capabilities stay unchanged. Refs #6644 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Extend the live SDK upstream with a test://note resource and a touch tool that announces an update. A live stream that subscribes to the URI gets the ack and a stamped notifications/resources/updated. Deny paths cover a URI outside the virtual server and a token without resources.read. server/discover now advertises resources.subscribe. The lifespan shuts the relay down. The quickstart and CHANGELOG describe the feature. Refs #6644 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
… relay watches Two upstream servers can expose one URI to different teams. The relay now publishes UpstreamResourceUpdated tagged with its gateway, and a stream that honored URIs listens through scoped_listen_bus(), so it never gets another upstream's update for the same URI. Each worker holds at most 256 distinct relay watches. A URI that would start one more drops from the ack. A pre-2026 upstream is remembered for UNSUPPORTED_REPROBE_SECONDS, so its URIs drop from later acks and no stream probes it again. Resolution also skips query-parameter auth, private-CA and mTLS upstreams, which the relay client cannot reach with stored headers. Refs #6644 Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
The interrogate hook requires a docstring on every function. The nested scoped() callback in _ScopedBus.subscribe had none, so the pre-commit job failed at 99.8% coverage. Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Signed-off-by: Suresh Kumar Moharajan <suresh.kumar.m@ibm.com>
Lang-Akshay
approved these changes
Nov 10, 2026
msureshkumar88
force-pushed
the
feat/6644-listen-resource-subscriptions
branch
from
November 10, 2026 17:03
5e04d70 to
6ab7dcf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pull Request
🔗 Related Issue
Closes #6644
Part of #6571. Stacked on #7414 (#6573): this PR targets
feat/6573-subscriptions-listenand shows only the #6644 work. I retarget it tomainafter #7414 merges.📝 Summary
A 2026-07-28 client can now name resource URIs in
resourceSubscriptionson asubscriptions/listenrequest. The gateway acknowledges the URIs the caller can see. When one of them changes on its modern upstream server, the client getsnotifications/resources/updatedwith thaturi, stamped with its subscription ID in_meta.#7414 dropped
resourceSubscriptionsand hidresources.subscribe(its D9), because the gateway had no source of per-resource events. This PR adds that source and reverses D9.✅ Acceptance criteria coverage
resourceSubscriptionsis acknowledged_adapt_subscriptions_listenresolves the URIs throughResourceService.resolve_subscription_targets()and puts the honored ones in the acknotifications/resources/updatedwith thaturi, tagged with the subscription ID in_metaResourceSubscriptionRelayholds an upstreamsubscriptions/listenfor each honored URI and republishes each update on the worker's listen bus. The SDKListenHandlerdelivers it only to streams that honored the URI, stamped with their subscription ID🏗️ Architecture
flowchart LR C[Modern client] -->|"subscriptions/listen<br/>resourceSubscriptions"| A[_adapt_subscriptions_listen] A -->|resources.read gate| R["resolve_subscription_targets()<br/>visibility · virtual server · reachable upstream"] R -->|honored URIs| H[SDK ListenHandler] A -->|"watch(targets) for the stream lifetime"| Y[ResourceSubscriptionRelay<br/>one per worker] Y -->|"subscriptions/listen<br/>resourceSubscriptions=[uri]"| U[Modern upstream] U -->|notifications/resources/updated| Y Y -->|"UpstreamResourceUpdated(uri, gateway)"| B[Listen bus, same worker] B -->|"scoped to the stream's (gateway, uri) pairs"| H H -->|stamped resources/updated| CGate (
mcpgateway/transports/streamablehttp_transport.py). URIs needresources.read, the same token-scope gate asresources/listandresources/read. Without it the URIs drop from the ack. A request with only URIs and noresources.readgets the samePermissionErrorasresources/read.Resolve (
ResourceService.resolve_subscription_targets). A URI is honored when an enabled resource with that URI:_check_resource_access()for the caller's scoped identity (Layer 1);/servers/{id}/mcp;Unknown and hidden URIs drop from the ack without an error, so the ack reveals nothing that
resources/readwould not. One request considers at most 64 distinct URIs. The DB session closes before the stream opens.Relay (
mcpgateway/services/resource_subscription_relay.py, new). The stream holdsrelay.watch(targets)for its lifetime. The relay keeps one upstreamClient.listen(resource_subscriptions=[uri])per(gateway_id, uri)on this worker, shared by reference count. The last release cancels it.UpstreamResourceUpdated(uri, gateway_id)on the worker-local listen bus.ListenHandleroverscoped_listen_bus(), which passes a resource update only for the(gateway_id, uri)pairs that stream watches.gateway_listen_headers(), now shared with the modern listener.Capabilities. 2026-07-28 capabilities (also
server/discover) advertiseresources.subscribeagain. Legacy capabilities stay byte-identical.Shutdown. The lifespan shuts the relay down with the other services.
🧭 Decisions and why
(gateway_id, uri), not one per upstream serverListenHandlerover a scoped bus for streams with URIsListenHandlermatches URIs exactly🔒 Security
resources.readgates the request (Layer-1 token-scope ceiling, same helper as*/list). Like*/list, no per-method Layer-2 RBAC check runs; [FEATURE]: Add per-method Layer-2 RBAC check to streamable HTTP catalog reads and listen #7494 tracks it._check_resource_access()and virtual-server membership filter each URI (Layer 1), from the server-side user context only. Nothing inparamschanges the identity.(gateway_id, uri)pairs, so an update from another team's upstream that shares the URI never reaches it.(gateway_id, uri), and the per-principal and anonymous stream caps from feat: serve MCP 2026-07-28 subscriptions/listen to modern clients (#6573) #7414 apply.resources.read→ URIs dropped orPermissionError; another team's resource, a public-only token, a URI outside the virtual server, a disabled resource or gateway, SSE, OAuth, query-parameter, private-CA and mTLS upstreams → URI dropped; an update from another upstream with the same URI → not delivered.🧪 Verification
make pre-commituv run pytest tests/unit/mcpgateway/transports/test_streamablehttp_transport.py tests/unit/mcpgateway/services/test_resource_subscription_relay.py tests/unit/mcpgateway/services/test_resource_subscription_targets.py tests/unit/mcpgateway/services/test_modern_listener_service.py tests/unit/mcpgateway/services/test_notification_service.py tests/unit/mcpgateway/services/test_resource_service.py tests/unit/mcpgateway/test_main_extended.py tests/unit/mcpgateway/test_main.py -n 8uv run pytest tests/live_gateway/mcp/test_subscriptions_listen.pypytest --doctest-moduleson the four touched modules, with the Makefile doctest secretsmake ruff pylint interrogate,make coverage diff-cover,make test-e2eLive proofs (real gateway and SDK
MCPServerupstream subprocesses): the upstream gains atest://noteresource and atouchtool that announces an update.test_listen_resource_subscription_receives_upstream_update: the ack liststest://noteand not an unknown URI;touchon the upstream delivers a stampednotifications/resources/updatedfortest://note.test_listen_resource_subscription_outside_virtual_server_not_ackedandtest_listen_resource_subscription_needs_resources_read: deny paths.test_modern_discover_advertises_resources_subscribe.Unit proofs: the relay against an in-memory SDK upstream (delivery, sharing, the watch cap, legacy upstream and its cooldown, URI not honored, reconnect, shutdown, the scoped bus); the resolver against a real SQLite session (14 cases); the adapter on the real 2026-07-28 wire, including an update from another upstream with the same URI.
Review: a fresh whole-branch review found no blockers. Its three functionally-impacting findings are fixed with tests: cross-upstream delivery for a shared URI, unbounded upstream streams, and upstreams the relay cannot reach. Its suggestion to remember pre-2026 upstreams is also in.
📁 Files
mcpgateway/services/resource_subscription_relay.py,tests/unit/mcpgateway/services/test_resource_subscription_relay.py,tests/unit/mcpgateway/services/test_resource_subscription_targets.pymcpgateway/transports/streamablehttp_transport.pymcpgateway/services/resource_service.py,mcpgateway/services/modern_listener_service.py(gateway_listen_headers())mcpgateway/main.pydocs/docs/architecture/get-stream-quickstart.md,CHANGELOG.md📏 Reviewability
triage🏷️ Type of Change
✅ Checklist
make black isort pre-commit)📓 Notes
Known limits
MCP_LISTEN_MAX_LIFETIME_SECONDS).resources/subscriberelaying is a possible follow-up.